From d314bb854039a07f9eec412d0a55f0f586ac1183 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 17 Sep 2026 12:11:52 +0200 Subject: [PATCH 001/103] Migrate Language component to the Component Revision architecture and introduce Activities --- .../tests/ilCertificateDateHelperTest.php | 2 +- components/ILIAS/File/File.php | 7 + components/ILIAS/File/lang/ilias_de.lang | 115 + components/ILIAS/File/lang/ilias_en.lang | 115 + components/ILIAS/Init/Init.php | 11 +- .../ILIAS/Init/src/AllModernComponents.php | 20 +- components/ILIAS/Language/Language.php | 281 ++- components/ILIAS/Language/README.md | 122 +- components/ILIAS/Language/ROADMAP.md | 16 +- ...ss.ilLanguageMetricsCollectedObjective.php | 20 +- .../Setup/class.ilLanguageObjective.php | 7 + .../Setup/class.ilLanguageSetupAgent.php | 28 +- ...lLanguagesInstalledAndUpdatedObjective.php | 110 +- .../class.ilLanguagesUpdatedObjective.php | 103 - .../classes/Setup/class.ilSetupLanguage.php | 578 ++---- .../Language/classes/class.ilLanguage.php | 10 +- .../classes/class.ilLanguageFolderTable.php | 11 +- .../Language/classes/class.ilObjLanguage.php | 259 ++- .../classes/class.ilObjLanguageAccess.php | 6 +- .../classes/class.ilObjLanguageDBAccess.php | 185 -- .../classes/class.ilObjLanguageExt.php | 6 +- .../classes/class.ilObjLanguageExtGUI.php | 153 +- .../classes/class.ilObjLanguageFolderGUI.php | 690 ++++-- .../src/Activities/AddLanguageEntry.php | 319 +++ .../AmbiguousLanguageTitleException.php | 25 + .../DeclaresLanguageKeysOnlyInput.php | 52 + .../src/Activities/GrindingInputAccess.php | 134 ++ .../src/Activities/GrindsFormInput.php | 222 ++ .../src/Activities/InstallLanguage.php | 257 +++ .../src/Activities/InvalidInputException.php | 25 + .../src/Activities/LanguageActivity.php | 132 ++ .../src/Activities/ParsesLanguageKeyList.php | 71 + .../Activities/RemoveLocalLanguageChanges.php | 160 ++ .../ResolvesLanguageKeysToObjIds.php | 59 + .../Activities/SafeToDisplayActivityError.php | 25 + .../SetLanguageDetectionEnabled.php | 104 + .../SetLanguageTranslationEnabled.php | 154 ++ .../src/Activities/UninstallLanguage.php | 172 ++ .../src/Activities/UpdateLanguage.php | 141 ++ .../ComponentLanguageFileDirectory.php | 68 + .../CustomizingLanguageFileDirectory.php | 30 + .../LanguageFileDirectory.php | 47 + .../LanguageFileDirectoryManager.php | 113 + .../MainLanguageFileDirectory.php | 48 + .../Language/src/RendersActivityErrors.php | 111 + .../InstalledLanguageDatabaseRepository.php | 343 +++ .../src/Setup/InstalledLanguageRepository.php | 116 ++ .../src/Setup/LanguageFileParsing.php | 53 + .../src/Setup/LanguageInstallationManager.php | 432 ++++ .../Activities/ActivityContractTestCase.php | 97 + ...ivityWithPerformResultContractTestCase.php | 57 + .../tests/Activities/AddLanguageEntryTest.php | 1497 +++++++++++++ .../tests/Activities/GrindsFormInputTest.php | 300 +++ .../Activities/GrindsFormInputTestHost.php | 40 + .../tests/Activities/InstallLanguageTest.php | 705 +++++++ .../tests/Activities/LanguageActivityTest.php | 220 ++ .../tests/Activities/RealFieldsUiFactory.php | 94 + .../RemoveLocalLanguageChangesTest.php | 763 +++++++ .../SetLanguageDetectionEnabledTest.php | 445 ++++ .../SetLanguageTranslationEnabledTest.php | 718 +++++++ .../Activities/UninstallLanguageTest.php | 750 +++++++ .../tests/Activities/UpdateLanguageTest.php | 409 ++++ .../ComponentLanguageFileDirectoryTest.php | 98 + .../tests/LanguageComponentGraphTest.php | 284 +++ .../tests/RendersActivityErrorsTest.php | 276 +++ ...nstalledLanguageDatabaseRepositoryTest.php | 522 +++++ .../Setup/LanguageInstallationManagerTest.php | 964 +++++++++ ...guagesInstalledAndUpdatedObjectiveTest.php | 233 +++ components/ILIAS/Language/tests/bootstrap.php | 2 +- .../tests/ilLanguageSetupAgentTest.php | 25 +- .../tests/ilObjLanguageDBAccessTest.php | 107 - .../tests/ilObjLanguageExtGUITest.php | 594 ++++++ .../tests/ilObjLanguageFolderGUITest.php | 1848 +++++++++++++++++ .../Language/tests/ilObjLanguageTest.php | 62 + .../Language/tests/ilSetupLanguageTest.php | 321 ++- docs/development/language.md | 21 +- lang/ilias_de.lang | 84 - lang/ilias_en.lang | 84 - 78 files changed, 16419 insertions(+), 1469 deletions(-) create mode 100644 components/ILIAS/File/lang/ilias_de.lang create mode 100644 components/ILIAS/File/lang/ilias_en.lang delete mode 100755 components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php delete mode 100755 components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php create mode 100644 components/ILIAS/Language/src/Activities/AddLanguageEntry.php create mode 100644 components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php create mode 100644 components/ILIAS/Language/src/Activities/DeclaresLanguageKeysOnlyInput.php create mode 100644 components/ILIAS/Language/src/Activities/GrindingInputAccess.php create mode 100644 components/ILIAS/Language/src/Activities/GrindsFormInput.php create mode 100644 components/ILIAS/Language/src/Activities/InstallLanguage.php create mode 100644 components/ILIAS/Language/src/Activities/InvalidInputException.php create mode 100644 components/ILIAS/Language/src/Activities/LanguageActivity.php create mode 100644 components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php create mode 100644 components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php create mode 100644 components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php create mode 100644 components/ILIAS/Language/src/Activities/SafeToDisplayActivityError.php create mode 100644 components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php create mode 100644 components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php create mode 100644 components/ILIAS/Language/src/Activities/UninstallLanguage.php create mode 100644 components/ILIAS/Language/src/Activities/UpdateLanguage.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectoryManager.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/RendersActivityErrors.php create mode 100644 components/ILIAS/Language/src/Setup/InstalledLanguageDatabaseRepository.php create mode 100644 components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php create mode 100644 components/ILIAS/Language/src/Setup/LanguageFileParsing.php create mode 100644 components/ILIAS/Language/src/Setup/LanguageInstallationManager.php create mode 100644 components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php create mode 100644 components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php create mode 100644 components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php create mode 100644 components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php create mode 100644 components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php create mode 100644 components/ILIAS/Language/tests/Activities/InstallLanguageTest.php create mode 100644 components/ILIAS/Language/tests/Activities/LanguageActivityTest.php create mode 100644 components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php create mode 100644 components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php create mode 100644 components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php create mode 100644 components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php create mode 100644 components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php create mode 100644 components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php create mode 100644 components/ILIAS/Language/tests/LanguageComponentGraphTest.php create mode 100644 components/ILIAS/Language/tests/RendersActivityErrorsTest.php create mode 100644 components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php create mode 100644 components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php delete mode 100755 components/ILIAS/Language/tests/ilObjLanguageDBAccessTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageTest.php 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..573ab640fdd6 --- /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, wenden Sie sich an die Administrierenden der Plattform. +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..79ad17b707bd --- /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 the administrators of this platform. +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..4a26ba22810d 100644 --- a/components/ILIAS/Init/Init.php +++ b/components/ILIAS/Init/Init.php @@ -173,7 +173,16 @@ 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], + $use[\ILIAS\Language\Language::class], ); } } diff --git a/components/ILIAS/Init/src/AllModernComponents.php b/components/ILIAS/Init/src/AllModernComponents.php index e50f113ad099..44c165ed6487 100644 --- a/components/ILIAS/Init/src/AllModernComponents.php +++ b/components/ILIAS/Init/src/AllModernComponents.php @@ -144,7 +144,16 @@ 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, + protected \ILIAS\Language\Language $language, ) { } @@ -257,6 +266,15 @@ 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; + $DIC[\ILIAS\Language\Language::class] = fn(): \ILIAS\Language\Language => $this->language; } public function getName(): string diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 2cbc888fc36a..83b4be1440c5 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\Setup\InstalledLanguageRepository; +use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; +use ILIAS\Language\Setup\LanguageInstallationManager; + class Language implements Component\Component { public function init( @@ -34,23 +49,275 @@ public function init( ): void { $define[] = \ILIAS\Language\Language::class; + // Shared by every $internal service below that needs the Setup/ + // installation database - deduplicated here instead of being + // reconstructed identically in each closure. + $resolve_db = static fn(): \ilDBInterface => $GLOBALS['ilDB'] ?? $GLOBALS['DIC']->database(); + $ilias_root = (string) realpath(__DIR__ . '/../../../'); + + // Shared by every Activity below that needs a "write" RBAC check on + // the language folder ref_id (all seven), or the system settings + // service (SetLanguageDetectionEnabled/SetLanguageTranslationEnabled) - + // deduplicated here for the same reason as $resolve_db/$ilias_root + // above. These stay Closures, not resolved values: init() runs at + // bootstrap-build time, without a database or a fully booted $DIC. + $rbac_system = static fn(): \ilRbacSystem => $GLOBALS['DIC']->rbac()->system(); + $lang_folder_ref_id = static fn(): int => \ilObjLanguageAccess::_lookupLangFolderRefId(); + $settings = static fn(): \ILIAS\Administration\Setting => $GLOBALS['DIC']->settings(); + + // --- $internal: wiring local to this component ----------------- + // Ordered so each entry's dependencies are declared above it. + + $internal[LanguageFileDirectoryManager::class] = static fn() => + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + ...$seek[LanguageFileDirectory::class] + ); + + // Read (InstalledLanguageRepository) and write (LanguageInstallationManager) + // access to the language installation domain, extracted from + // ilSetupLanguage per docs/development/repository-pattern.md - see that + // class' docblock. ilSetupLanguage itself keeps delegating to both and + // remains the \ILIAS\Language\Language implementation used during Setup + // (see $implement[...] below); these two are for consumers that only + // need install/retrieval behaviour, not txt() - see $provide[...] below + // and components/ILIAS/Language/README.md. + $internal[InstalledLanguageDatabaseRepository::class] = static fn() => + new InstalledLanguageDatabaseRepository( + $resolve_db, + $internal[LanguageFileDirectoryManager::class], + $ilias_root + ); + + $internal[LanguageInstallationManager::class] = static fn() => + new LanguageInstallationManager( + $resolve_db, + $internal[LanguageFileDirectoryManager::class], + $ilias_root, + $internal[InstalledLanguageDatabaseRepository::class] + ); + + $internal[\ilSetupLanguage::class] = static fn() => + new \ilSetupLanguage( + "en", + $internal[LanguageFileDirectoryManager::class] + ); + + $internal[InstallLanguage::class] = static fn() => + new InstallLanguage( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + // The Activity needs no database of its own: every database + // access in perform() goes through ilSetupLanguage, which + // resolves it itself. + $internal[\ilSetupLanguage::class], + $lang_folder_ref_id + ); + + $internal[UpdateLanguage::class] = static fn() => + new UpdateLanguage( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + // Same reasoning as InstallLanguage above: no database of + // its own, ilSetupLanguage resolves it. + $internal[\ilSetupLanguage::class], + $lang_folder_ref_id + ); + + // Unlike InstallLanguage/UpdateLanguage, UninstallLanguage needs no + // ilSetupLanguage - uninstalling a language is a runtime-only action with no + // equivalent step during Setup - and so no forSetup() factory either; its two legacy + // collaborators (enumerating "lng" objects, constructing an ilObjLanguage by id) are + // left at their defaults, which resolve the exact same legacy calls the extracted GUI + // code used directly before. + $internal[UninstallLanguage::class] = static fn() => + new UninstallLanguage( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $lang_folder_ref_id + ); + + // Same reasoning as UninstallLanguage above: no Setup counterpart, no + // ilSetupLanguage, no forSetup() factory; its two legacy + // collaborators (enumerating "lng" objects, constructing an + // ilObjLanguage by id) are left at their defaults, which resolve the + // exact same legacy calls the extracted GUI code used directly + // before. + $internal[RemoveLocalLanguageChanges::class] = static fn() => + new RemoveLocalLanguageChanges( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $lang_folder_ref_id + ); + + // Same reasoning again, minus the Setup/forSetup() bridge, which does not apply here + // either - adding a single language entry is a runtime-only action with no equivalent + // step during Setup. Unlike UninstallLanguage/RemoveLocalLanguageChanges, this needs no + // "lng" object enumeration or ilObjLanguage-by-id closures - it reads the set of + // installed languages from InstalledLanguageDatabaseRepository (already built above for + // other consumers) and writes single entries via the two legacy closures defaulted + // inside AddLanguageEntry itself; the second of those two (updateModuleCache()) needs a + // database connection, supplied here via the same $resolve_db closure every other + // database consumer in this component shares, rather than AddLanguageEntry reaching + // into $GLOBALS['DIC'] directly. + $internal[AddLanguageEntry::class] = static fn() => + new AddLanguageEntry( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $internal[InstalledLanguageDatabaseRepository::class], + // $db is a mandatory (non-nullable) collaborator - AddLanguageEntry needs no + // database of its own beyond what updateModuleCache()'s default resolves via + // this same $resolve_db closure, already shared by + // InstalledLanguageDatabaseRepository/LanguageInstallationManager. + $resolve_db, + language_folder_ref_id: $lang_folder_ref_id, + // replace_lang_entry/update_module_cache/user_login: left at + // their defaults (see AddLanguageEntry's own constructor + // docblock). + ); + + // Same reasoning again, minus the Setup/forSetup() bridge, which does not apply here + // either - toggling a system setting is a runtime-only action with no equivalent step + // during Setup. Unlike every other Activity above, this needs no "lng" object + // enumeration, no ilObjLanguage-by-id closure and no installed-language repository - it + // only ever reads/writes the single "lang_detection" system setting, via the same + // $GLOBALS['DIC']->settings() call the extracted GUI code used directly (as + // $this->settings). + $internal[SetLanguageDetectionEnabled::class] = static fn() => + new SetLanguageDetectionEnabled( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $settings, + $lang_folder_ref_id + ); + + // Same reasoning again, minus the Setup/forSetup() bridge, which does not apply here + // either - toggling a per-language setting is a runtime-only action with no equivalent + // step during Setup. Like SetLanguageDetectionEnabled, this needs no "lng" object + // enumeration and no ilObjLanguage-by-id closure - it only ever reads/writes a single + // "lang_translate_" system setting, via the same $GLOBALS['DIC']->settings() call + // the extracted GUI code used directly (as $DIC->settings() inside + // saveSettingsObject()). Unlike SetLanguageDetectionEnabled, it now also needs the + // installed-language repository (already built above for AddLanguageEntry) to validate + // that the given language_key is actually installed. + $internal[SetLanguageTranslationEnabled::class] = static fn() => + new SetLanguageTranslationEnabled( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $settings, + $internal[InstalledLanguageDatabaseRepository::class], + language_folder_ref_id: $lang_folder_ref_id, + ); + + // LanguageLegacyInitialisationAdapter has no constructor of its own, + // so it never needs the LanguageFileDirectoryManager argument - + // it purely proxies to $DIC->language() at call time. This slot used + // to be misleadingly named $internal[\ilLanguage::class] even though + // it never held an \ilLanguage instance. + $internal[Language\LanguageLegacyInitialisationAdapter::class] = static fn() => + new Language\LanguageLegacyInitialisationAdapter(); + + // --- $implement -------------------------------------------------- + + // This component registers TWO candidate implementations for + // \ILIAS\Language\Language. Both assignments below are intentional - + // ILIAS\Component\Dependencies\Reader::cacheImplement() collects every + // $implement[...] assignment into a list rather than overwriting a + // plain array key, and the generated RenamingDIC keeps each candidate + // under its own offset, so neither line is dead code. + // Consumers that $use[\ILIAS\Language\Language::class] (e.g. UI.php, + // Setup.php, Refinery.php) are disambiguated per bootstrap entry point: + // - components/ILIAS/Setup/resources/dependency_resolution.php -> ilSetupLanguage + // - components/ILIAS/Init/resources/dependency_resolution.php -> LanguageLegacyInitialisationAdapter + // See docs/development/components-and-directories.md and cli/build_bootstrap.php. $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: services made available to the rest of the system - + + // Make the resolved language services available outside this component. + $provide[LanguageFileDirectoryManager::class] = static fn() => + $internal[LanguageFileDirectoryManager::class]; + + $provide[InstalledLanguageRepository::class] = static fn() => + $internal[InstalledLanguageDatabaseRepository::class]; + + $provide[LanguageInstallationManager::class] = static fn() => + $internal[LanguageInstallationManager::class]; + + // --- $contribute: contributions to other components' collection + // points. Relative order preserved from before this file's + // reorganisation, since components may in general add multiple + // contributions to the same collection point sequentially (see + // docs/development/components-and-directories.md, "Contribute to + // Service or Functionality") - which the seven + // \ILIAS\Component\Activities\Activity::class entries below + // actually do, one per Activity this component offers. + + $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], + $internal[InstallLanguage::class], + $internal[UpdateLanguage::class], + $internal[InstalledLanguageDatabaseRepository::class] ); - $internal[\ilSetupLanguage::class] = static fn() => - new \ilSetupLanguage("en"); - - $internal[\ilLanguage::class] = static fn() => - new Language\LanguageLegacyInitialisationAdapter(); + // The seven Activities this component offers are both provided under + // their own concrete class (so components/ILIAS/Init/Init.php can + // pull each one specifically - AllModernComponents.php then + // re-exposes that same resolved instance under the matching legacy + // $DIC[::class] key, which is what every GUI class in + // this component reads) and contributed generically to + // \ILIAS\Component\Activities\Activity::class (so the + // cross-component Repository can discover them too) - see + // docs/development/components-and-directories.md, "Contribute to + // Service or Functionality". InstallLanguage/UpdateLanguage are + // additionally handed to Setup Objectives directly, via + // $contribute[\ILIAS\Setup\Agent::class] above, or via their own + // forSetup() factory for callers not wired through the component + // graph; because Init.php pulls these concrete classes (not an + // interface), none of the seven may become final - both + // ilObjLanguageFolderGUITest and ilObjLanguageExtGUITest build a + // PHPUnit mock of several of them directly (createMock(InstallLanguage::class) + // and siblings), which requires PHPUnit to generate a subclass at + // runtime - impossible for a final class. + // + // Kept as one combined loop rather than seven repeated + // $provide[...]/$contribute[...] pairs. Order matters for the + // $contribute side only (RenamingDIC assigns a shared, container-wide + // "_" suffix in call order to disambiguate the repeated + // \ILIAS\Component\Activities\Activity::class key - see + // LanguageComponentGraphTest::testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder()); + // this list's order must therefore match $activities' declaration + // order exactly, and this loop must stay exactly here: after the two + // preceding $contribute entries (LanguageFileDirectory, Setup\Agent) + // and before the following one (UserSettings). + $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..e8f1a7627c1a 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. New code that only needs to install or inspect languages should depend on these two +instead of on `ilSetupLanguage`, which still exists (delegating to both) as the concrete `ILIAS\Language\Language` +implementation used during Setup and as a stable entry point for callers not yet wired through `Language.php`. + + +## 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, contribute ordering) rather than relying on prose descriptions here. diff --git a/components/ILIAS/Language/ROADMAP.md b/components/ILIAS/Language/ROADMAP.md index 1e394d3d2505..c93696bb6a67 100755 --- a/components/ILIAS/Language/ROADMAP.md +++ b/components/ILIAS/Language/ROADMAP.md @@ -4,17 +4,29 @@ * 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 ## 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..16dd975a451b 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php @@ -19,17 +19,18 @@ declare(strict_types=1); use ILIAS\Setup; +use ILIAS\Language\Setup\InstalledLanguageRepository; class ilLanguageMetricsCollectedObjective extends Setup\Metrics\CollectedObjective { - protected \ilSetupLanguage $il_setup_language; + protected InstalledLanguageRepository $repository; public function __construct( Setup\Metrics\Storage $storage, - \ilSetupLanguage $il_setup_language + InstalledLanguageRepository $repository ) { parent::__construct($storage); - $this->il_setup_language = $il_setup_language; + $this->repository = $repository; } /** @@ -61,15 +62,16 @@ 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. + // InstalledLanguageRepository resolves its database lazily via + // $GLOBALS['ilDB'] ?? $DIC->database(), so it picks up the + // Setup-provided database resource as soon as it is assigned here - + // see components/ILIAS/Language/Language.php. $GLOBALS["ilDB"] = $db; $installed_languages = []; - $local_languages = $this->il_setup_language->getLocalLanguages(); - foreach ($this->il_setup_language->getInstalledLanguages() as $lang) { + $local_languages = $this->repository->getLocalLanguages(); + foreach ($this->repository->getInstalledLanguages() as $lang) { $local_file = new Setup\Metrics\Metric( Setup\Metrics\Metric::STABILITY_STABLE, Setup\Metrics\Metric::TYPE_BOOL, @@ -79,7 +81,7 @@ protected function collectFrom(Setup\Environment $environment, Setup\Metrics\Sto $local_changes = new Setup\Metrics\Metric( Setup\Metrics\Metric::STABILITY_STABLE, Setup\Metrics\Metric::TYPE_BOOL, - count($this->il_setup_language->getLocalChanges($lang)) > 0, + count($this->repository->getLocalChanges($lang)) > 0, "Are there local changes for the language?" ); $installed_languages[$lang] = new Setup\Metrics\Metric( diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php index a1e0c1030791..4d3e3d67100e 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php @@ -20,6 +20,13 @@ use ILIAS\Setup; +/** + * Not every Objective in this component installs/updates languages (e.g. + * ilDefaultLanguageSetObjective does not), so the "install languages via + * ilSetupLanguage/InstallLanguage" dependencies and helper are not on this + * common base - see ilLanguagesInstalledAndUpdatedObjective, the only + * Objective that needs them. + */ abstract class ilLanguageObjective implements Setup\Objective { public function __construct() diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php index fbda65bbce47..0d5d273ee162 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php @@ -21,6 +21,9 @@ use ILIAS\Setup; use ILIAS\Refinery; use ILIAS\UI; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; +use ILIAS\Language\Setup\InstalledLanguageRepository; class ilLanguageSetupAgent implements Setup\Agent { @@ -28,13 +31,22 @@ class ilLanguageSetupAgent implements Setup\Agent protected Refinery\Factory $refinery; protected \ilSetupLanguage $il_setup_language; + protected InstallLanguage $install_language; + protected UpdateLanguage $update_language; + protected InstalledLanguageRepository $repository; public function __construct( Refinery\Factory $refinery, - \ilSetupLanguage $il_setup_language + \ilSetupLanguage $il_setup_language, + InstallLanguage $install_language, + UpdateLanguage $update_language, + InstalledLanguageRepository $repository ) { $this->refinery = $refinery; $this->il_setup_language = $il_setup_language; + $this->install_language = $install_language; + $this->update_language = $update_language; + $this->repository = $repository; } /** @@ -61,7 +73,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 +90,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 + ), ); } @@ -91,7 +111,7 @@ public function getBuildObjective(): Setup\Objective */ public function getStatusObjective(Setup\Metrics\Storage $storage): Setup\Objective { - return new ilLanguageMetricsCollectedObjective($storage, $this->il_setup_language); + return new ilLanguageMetricsCollectedObjective($storage, $this->repository); } /** diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php index f85796340a43..ecb8a75fe499 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php @@ -19,16 +19,40 @@ 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; + /** + * @param InstallLanguage|null $install_language Callers wired through + * the component graph (see ilLanguageSetupAgent) pass the + * resolved instance. Callers that are themselves constructed + * deep inside the Setup Objective tree - + * ilComponentPluginAdminInitObjective and + * ilPluginLanguageUpdatedObjective, reached via + * ilPluginDefaultAgent, which only ever receives a plugin name - + * have nothing to inject and omit it; a Setup-only instance is + * then built here (InstallLanguage::forSetup(), which needs the + * concrete class, not just any Activity). This is the single + * place that knows how, instead of each of those call sites + * reaching into $GLOBALS['DIC'] for a key that Setup never + * registers. + * @param UpdateLanguage|null $update_language Same reasoning as + * $install_language above. + */ 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); } /** @@ -47,14 +71,6 @@ 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(); - } - /** * @inheritDoc */ @@ -86,20 +102,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; } @@ -111,4 +116,61 @@ public function isApplicable(Setup\Environment $environment): bool { 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 every given key that is not yet installed, and refreshes + * every given key that already is - unconditionally, this Objective's + * job (label "Install/Update languages") is to keep already-installed + * languages in sync with the current language files on every setup.php + * run (e.g. after a core update added or changed translations), not + * just to install missing ones. + * + * Both Activities are handed the same full $language_keys list and each + * decides for itself what applies to it: InstallLanguage::MODE_INSTALL + * installs a not-yet-installed key and is a no-op for an already + * installed one; UpdateLanguage refreshes an already installed key and + * is a no-op for one that is not installed. Neither call needs the + * caller to pre-filter by install status. Running UpdateLanguage on the + * keys InstallLanguage just installed is intentional, not wasted work + * avoided: it keeps this method's behaviour simple (one full list, two + * independent Activities) and the freshly installed keys are also + * freshly refreshed, which is harmless. + * + * 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..d9a9c886be44 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -18,6 +18,14 @@ 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; + /** * language handling for setup * @@ -32,29 +40,65 @@ * @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. + * This class has two responsibilities that used to be more entangled than + * they are now: + * - being a \ILIAS\Language\Language implementation (via ilLanguage), used + * as the txt()-lookup during Setup, before the full runtime DIC exists, + * - installing/managing languages in the database and validating language + * files on disk. + * The latter responsibility has been extracted into + * \ILIAS\Language\Setup\InstalledLanguageRepository (read access) and + * \ILIAS\Language\Setup\LanguageInstallationManager (write access), per + * docs/development/repository-pattern.md. This class now mostly delegates + * to both and is kept around as the concrete \ILIAS\Language\Language + * implementation for Setup and as a stable entry point for the many + * existing callers (Setup Objectives, ilObjLanguage, ...) that are not + * (yet) wired through components/ILIAS/Language/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; - - public function __construct(string $a_lang_key) - { + protected ?ilDBInterface $db = null; + private readonly InstalledLanguageRepository $repository; + private readonly LanguageInstallationManager $manager; + + public function __construct( + string $a_lang_key, + private ?LanguageFileDirectoryManager $language_file_directory_manager = null, + ) { $this->lang_key = $a_lang_key ?: $this->lang_default; - $il_absolute_path = realpath(__DIR__ . "/../../../../../"); - $this->lang_path = $il_absolute_path . "/lang"; - $this->cust_lang_path = $il_absolute_path . "/lang/customizing"; + $this->absolute_path = (string) realpath(__DIR__ . "/../../../../../"); + $this->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 lazily and re-read on every call, so a database handed in + // later via setDbHandler() actually takes effect - which is what lets + // Setup Objectives inject the Setup-provided database instead of + // temporarily overwriting $GLOBALS['ilDB'] around the call (see + // ilLanguagesInstalledAndUpdatedObjective::useSetupDatabase()). The + // global remains the fallback for the runtime, where + // ilInitialisation::initDatabase() populates it via initGlobal(). + $db_resolver = fn(): ilDBInterface => $this->db ?? $GLOBALS["ilDB"]; + $this->repository = new InstalledLanguageDatabaseRepository( + $db_resolver, + $this->language_file_directory_manager, + $this->absolute_path + ); + $this->manager = new LanguageInstallationManager( + $db_resolver, + $this->language_file_directory_manager, + $this->absolute_path, + $this->repository + ); } /** @@ -97,254 +141,111 @@ 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(); - } - - $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); - } - } - } + $a_lang_keys = []; } - return ($err_lang) ?: true; + return $this->manager->installLanguages($a_lang_keys, $a_local_keys); } /** - * get already installed languages (in db) + * Registers or updates the object_data bookkeeping row for a language + * that has just been flushed/(re-)inserted, deciding "installed" vs. + * "installed_local" and INSERTing a fresh row or UPDATEing the existing + * one accordingly. This is the single source of truth for that + * bookkeeping - both installLanguages() and + * \ILIAS\Language\Activities\InstallLanguage::perform() call this + * instead of duplicating the object_data INSERT/UPDATE. + * + * @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) + * Validates only the customizing/local language file for $lang_key + * (unlike checkLanguageForInstallation(), which treats a missing local + * file as acceptable). Used when a caller explicitly wants to validate + * a local customization file, e.g. before activating "installed_local" + * status for an already globally-installed language. */ - public function getInstalledLocalLanguages(): array + public function checkLocalLanguageFileForInstallation(string $lang_key): bool { - global $ilDB; + return $this->repository->checkLocalLanguageFile($lang_key); + } - $arr = []; - if ($ilDB instanceof ilDBInterface) { - $query = "SELECT * FROM object_data " . - "WHERE type = " . $ilDB->quote("lng", "text") . " " . - "AND description = " . $ilDB->quote("installed_local", "text"); - $r = $ilDB->query($query); + public function flushLanguageForInstallation(string $lang_key): void + { + $this->manager->flushLanguageForInstallation($lang_key); + } - while ($row = $ilDB->fetchObject($r)) { - $arr[] = $row->title; - } - } - return $arr; + public function flushLanguageForUninstallation(string $lang_key): void + { + $this->manager->flushLanguageForUninstallation($lang_key); + } + + public function insertLanguageForInstallation(string $lang_key): void + { + $this->manager->insertLanguageForInstallation($lang_key); } /** - * get already registered languages (in db) + * (Re-)apply only the customizing/local language file for an already + * installed language, without touching the base/global data - see + * LanguageInstallationManager::insertLanguageForApplyingLocalChanges() + * for why. */ - 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 +276,28 @@ 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 + * Returns local language files whose names contain a requested language key + * but do not match the expected 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 +305,21 @@ 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 + * Set the database this instance works on, taking precedence over + * $GLOBALS['ilDB']. Used by Setup Objectives to inject the + * Setup-provided database resource; the repository and manager pick it + * up on their next call, because they resolve the database lazily (see + * the constructor). + * * 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..65aff45b1d30 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,12 @@ public function getInstalledLanguages(): array /** * Get installed languages + * + * Deliberately separate from ilSetupLanguage::getInstalledLanguages(): + * this one relies on ilObject::_getObjectsByType(), which needs the full + * runtime object repository and is unavailable during Setup, whereas + * ilSetupLanguage's variant uses a raw object_data query that works + * before that machinery exists. Do not merge the two. */ public static function _getInstalledLanguages(): array { @@ -308,7 +314,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..f1a216eac4c6 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -18,6 +18,13 @@ 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; + /** * Class ilObjLanguage * @@ -40,6 +47,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 +58,24 @@ 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(); + // Fallback for when neither an explicit manager is injected nor the + // DIC provides one. The constructor's first argument is the + // local/customizing directory, not a global one - passing + // MainLanguageFileDirectory there (as before) mislabeled the global + // lang/ directory as "local" and left no global directory at all, + // which would make every entry in it look like a local override. + $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 +88,33 @@ 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 file lives at components/ILIAS/Language/classes/ - four + // levels below the ILIAS root, not five. The extra "../" here used + // to point one directory too high (e.g. /var/www instead of + // /var/www/html), so the "Main" language directory (lang/) was never + // found - check()/refresh()/removeLocalChanges() would then always + // fail with "file not valid", for every language, even though the + // file itself was fine. Compare Language.php's own (correct) + // 3-level "../../../ " from components/ILIAS/Language/. + $this->absolute_path = (string) realpath(__DIR__ . "/../../../../"); + + // Single source of truth for file-based language check/insert + // operations - see check()/insert() below. This avoids duplicating + // the file-parsing/DB-writing logic that used to live separately in + // this class and in ilObjLanguageDBAccess. Repository (read) and + // Manager (write) used to be bundled into ilSetupLanguage - see its + // class docblock and docs/development/repository-pattern.md. + $this->repository = new InstalledLanguageDatabaseRepository( + $DIC->database(), + $this->language_file_directory_manager, + $this->absolute_path + ); + $this->manager = new LanguageInstallationManager( + $DIC->database(), + $this->language_file_directory_manager, + $this->absolute_path, + $this->repository + ); } @@ -89,6 +140,12 @@ public static function getInstalledLanguages(): array /** * Return the language keys of the installed languages * + * KNOWN ISSUE (not fixed here, flagged for a follow-up): this checks for + * the exact status "installed" and therefore misses languages with the + * status "installed_local", unlike ilLanguage::_getInstalledLanguages() + * which matches on str_starts_with($desc, "installed") and thus includes + * both. This affects ilPluginLanguage's language selection. + * * @return array */ public static function getLangKeysOfInstalledLanguages(): array @@ -184,12 +241,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 +293,10 @@ public function uninstall(): string /** * refresh current language + * + * A single insert() call now covers both global and local/customizing + * content in one pass (see insert()'s doc comment), so the separate + * "refresh local on top" pass that used to follow is no longer needed. */ public function refresh(): bool { @@ -250,13 +307,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 +314,17 @@ public function refresh(): bool } /** - * Refresh all installed languages - */ + * Refresh all installed languages. + * + * @deprecated its only caller in this codebase, + * ilObjLanguageFolderGUI::refreshObject(), was removed as unreachable + * dead code (no link or ilCtrl command ever dispatched to it - verified + * repository-wide) once ilObjLanguageFolderGUI::refreshSelectedObject() + * was migrated to \ILIAS\Language\Activities\UpdateLanguage. Kept here, + * rather than removed outright, only because this is a public static + * method of a public API class that external/plugin code could still be + * calling directly. + */ public static function refreshAll(): void { $languages = ilObject::_getObjectsByType("lng"); @@ -358,7 +417,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 +471,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 +486,49 @@ public static function _getLocalChangesByModule(string $a_key, string $a_module) /** * insert language data from file into database * - * $scope empty (global) or "local" + * @deprecated $scope is accepted for backwards compatibility but no + * longer selects a separate write path: LanguageInstallationManager::insertLanguageForInstallation() + * always processes every directory the LanguageFileDirectoryManager + * knows about (global/component directories *and* the customizing/local + * one) in a single, idempotent pass, correctly preserving local + * overrides for global entries. This replaces the previous separate + * ilObjLanguageDBAccess-based write path (removed). No caller in this + * repository passes a non-empty $scope any more (verified repo-wide) - + * the parameter is kept only in case external/plugin code still calls + * this public method with one; remove it once that can be ruled out. */ 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; + /** + * Remove all local changes of this language - both entries edited + * directly via the "adjust language variables" table and any override + * coming from a customizing/local language file - and reinstall the + * language purely from the global/component language files. + * + * This must go through insertLanguageForRemovingLocalChanges() rather + * than insert()/insertLanguageForInstallation(): the latter always + * merges the customizing directory back in, which would immediately + * reinstate the very data this method is supposed to remove (see + * LanguageInstallationManager::insertLanguageForRemovingLocalChanges()). + * + * 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 +744,23 @@ 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, which already implements + * this validation, keyed off the LanguageFileDirectoryManager. This + * used to be a separate, largely duplicated implementation that also + * caused hard UI redirects from this model class - callers (e.g. + * ilObjLanguageFolderGUI) are now responsible for turning a `false` + * return into user-facing 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..3b7b95b155e0 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php @@ -22,6 +22,10 @@ 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\Activities\SafeToDisplayActivityError; +use ILIAS\Language\RendersActivityErrors; /** * Class ilObjLanguageExtGUI @@ -39,8 +43,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 +69,15 @@ 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']; + // Resolved once here, per the constructor-injection idiom already + // used by sibling GUI classes in this component (see + // ilObjLanguageFolderGUI::__construct()) - action methods must not + // reach into `global $DIC` themselves. + $this->add_language_entry = $DIC[AddLanguageEntry::class]; + $this->set_language_translation_enabled = $DIC[SetLanguageTranslationEnabled::class]; + // Used exclusively by activityErrorMessage() (see RendersActivityErrors) - + // resolved once here, per the same idiom as the Activities above. + $this->activity_error_logger = $DIC->logger()->lang(); // language maintenance strings are defined in administration $lng->loadLanguageModule("administration"); @@ -581,8 +598,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 +780,57 @@ public function settingsObject(): void } /** - * Set the language settings + * Set the language settings - see SetLanguageTranslationEnabled. + * + * An unchecked HTML checkbox is not submitted at all, so a missing/empty + * "translation" POST value means "disabled", exactly as the extracted + * code's `?? ""` default did. SetLanguageTranslationEnabled::perform() + * requires `enabled` as a strict bool and itself derives the setting's + * current state via `(bool) $this->settings->get($translate_key, '0')` - + * the same true/false convention the raw stored string already encoded - + * so this strict bool is equivalent to the raw value the form used to + * carry directly. */ 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); + // $this->user/$this->ctrl (set up by the parent ilObjectGUI + // constructor) are used here rather than reaching into global $DIC - + // see the comment on the constructor-injected Activities above for + // why action methods must not reach into it themselves. + $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 actually changed - exactly reproducing + // the extracted code's original behaviour: 'changed' comes from + // SetLanguageTranslationEnabled::perform()'s own strict bool + // comparison against the previously stored setting. $this->tpl->setOnScreenMessage('success', $this->lng->txt("settings_saved")); } + $form = $this->initNewSettingsForm(); + // Unlike installObject()/uninstallObject()/refreshSelectedObject(), + // which redirect after a persisted message, this preserves the + // extracted code's original behaviour: render the settings form + // directly with a non-persisted message, rather than an HTTP + // redirect. $this->tpl->setContent($form->getHTML()); } @@ -1015,45 +1067,68 @@ protected function initAddNewEntryForm(?string $a_id = null): ilPropertyFormGUI public function saveNewEntryObject(): void { - global $DIC; - $ilDB = $DIC->database(); - $ilCtrl = $DIC->ctrl(); - $ilUser = $DIC->user(); + // $this->ctrl/$this->user (set up by the parent ilObjectGUI + // constructor) are used here rather than reaching into global $DIC - + // see the comment on the constructor-injected Activities above for + // why action methods must not reach into it themselves. + $ilCtrl = $this->ctrl; + $ilUser = $this->user; $form = $this->initAddNewEntryForm(); if ($form->checkInput()) { $mod = $form->getInput("mod"); $id = $form->getInput("id"); - $lang = array(); + // One value per installed language - an installed language left + // blank by the user is skipped entirely by AddLanguageEntry, + // exactly as this loop used to skip it via `if ($trans) {...}`. + $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 ($error instanceof SafeToDisplayActivityError) { + // A genuine input rejection (e.g. a missing/blank "de"/ + // "en" translation - AddLanguageEntry::perform() rejects + // the whole request if either is blank, mirroring the + // legacy ilPropertyFormGUI's setRequired(true) on these + // two fields; other languages left blank are merely + // skipped) - re-render the same form with the values + // the user already entered still filled in, exactly like + // the $form->checkInput() === false branch below does, + // instead of redirecting to "view" and discarding them. + $this->tpl->setOnScreenMessage('failure', $error_message); + $form->setValuesByPost(); + $this->addNewEntryObject($form); + return; } + + // An unexpected internal failure (already logged by + // activityErrorMessage()) - redirecting away, losing the + // entered values, matches every other Activity error path in + // this component. + $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..f6a7d8b26365 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 @@ -33,20 +39,44 @@ */ class ilObjLanguageFolderGUI extends ilObjectGUI { + use RendersActivityErrors; + 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"); + + // Resolved once here, per the constructor-injection idiom already + // used by sibling GUI classes in this component (see + // ilObjLanguageExtGUI::__construct()) - action methods must not + // reach into `global $DIC` themselves. + $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 exclusively by activityErrorMessage() (see RendersActivityErrors) - + // resolved once here, per the same idiom as the Activities above. + $this->activity_error_logger = $DIC->logger()->lang(); $this->df = new ILIAS\Data\Factory(); $here_uri = $this->df->uri($this->request->getUri()->__toString()); @@ -147,6 +177,16 @@ protected function buildConfirmModal(array $ids, string $title, string $action, $items = []; if (!empty($ids)) { + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + // The request has already been aborted above (a failure + // message plus a redirect to "view") - this method's return + // type still requires a Modal value, but it is never + // actually rendered to the user: the redirect() response + // takes precedence in production. See + // abortIfAnyIdIsNotALanguageObject() for why. + return $f->modal()->interruptive($title, '', '')->withActionButtonLabel($this->lng->txt('ok')); + } + $message = $this->lng->txt($text); $some_changed = false; @@ -189,212 +229,439 @@ protected function buildConfirmModal(array $ids, string $title, string $action, } /** - * install languages + * Turn a list of language keys into a localized, comma-separated list + * (e.g. for "German, French installed." style messages). + * + * @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 + )); + } + /** + * Restores, for a batch of ids taken directly from request input (see + * getIdsFromQueryToken()), the implicit type guarantee + * `new ilObjLanguage((int) $obj_id)` used to give before every caller + * below was switched away from it towards `ilObject::_lookupTitle()`: the + * former threw ilObjectTypeMismatchException for a non-"lng" id, while + * the latter returns ANY object's title regardless of type (or "" for a + * non-existing id) without any type check at all. $ids is completely + * unvalidated request input, so every id must be confirmed to actually be + * a "lng" object BEFORE its title is trusted as a language key anywhere + * in this class. + * + * Aborts the WHOLE request - a generic, user-visible failure message + * followed by a redirect back to "view" - the moment a SINGLE id in + * $ids is not a "lng" object, rather than silently skipping just that + * one: a caller submitting an id that does not belong on this screen at + * all must never have any part of their request honoured. The message + * deliberately does not name the actual mismatch (e.g. which id, or what + * type it actually is), to avoid confirming to a caller probing this + * parameter which ids exist and of what type. + * + * `ilObject::_lookupType()` is backed by `ilObjDataCache` (see its own + * implementation), exactly like `ilObject::_lookupTitle()` already is - + * so checking every id here costs no additional, uncached database + * round trip compared to before. + * + * @param list $ids + * @return bool true if the request was aborted (the caller must return + * immediately without acting on $ids at all); false if every id + * in $ids is confirmed to be a "lng" object. + */ + 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. + * Install languages, or (re-)apply just their customizing/local file - + * see InstallLanguage::MODE_INSTALL / MODE_INSTALL_LOCAL. $mode is + * passed straight from the "install"/"install_local" command that was + * dispatched (see executeCommand()) - both are valid InstallLanguage + * modes as-is. */ - 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 never reach InstallLanguage itself: its + // own field-level validation would reject it with a technical, + // untranslated message (e.g. "language_keys: not_min_length") - + // exactly the same "nothing was selected" case buildConfirmModal() + // already handles for the confirmation step, so the same + // established message is shown here too. + 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); + + // activityErrorMessage() already returns a complete, self- + // contained message (see uninstallObject()) - a fixed + // "language_not_installed" prefix does not apply to every + // possible error here (e.g. a permission failure, or an invalid + // language file), so it must not be prepended unconditionally. + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); - $langObj = new ilObjLanguage((int) $obj_id); - $key = $langObj->install("local"); + $this->ctrl->redirect($this, 'view'); + return; + } + $value = $result->value(); + + // Both of these are "success" messages, but setOnScreenMessage() + // only keeps one message per type (a second call for the same type + // would silently overwrite the first) - so if both buckets are + // non-empty in the same request (e.g. installing several languages + // at once, some plain, some with a custom file), they are combined + // into a single message instead of two separate calls. + $success_messages = []; + + if (($lang_installed = $value['installed_language_keys']) !== []) { + $success_messages[] = $this->languageKeysToLocalizedList($lang_installed) + . " " . strtolower($this->lng->txt("installed")) . "."; + } - if ($key !== "") { - $local_installed[] = $key; - } + // A language gets a customizing/local file (re-)applied either as + // part of a fresh installation (mode "install") or as the result of + // mode "install_local" - either way, something did change for it, so + // this must be reported explicitly rather than disappearing into + // "already installed", which would wrongly 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") . "."; + } - unset($langObj); + if ($success_messages !== []) { + $this->tpl->setOnScreenMessage( + 'success', + implode('
', $success_messages), + true + ); } - 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")) . "."; - } + // Both of these are "info" messages about requests that changed + // nothing at all (mode "install" on an already installed language, + // or mode "install_local" on a language that is not installed) - + // combined into a single message for the same reason as the success + // messages above. + $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 (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 (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $info_messages[] = $this->languageKeysToLocalizedList($lang_not_installed) + . " " . $this->lng->txt("language_not_installed"); } - $this->out(); + 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 + * Uninstall languages - see UninstallLanguage. A language among $ids + * that is not installed, the system language, or the language currently + * in use is left completely 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); + + // activityErrorMessage() already returns a complete, self- + // contained message - a generic, localized "action_aborted" text + // for an unexpected internal failure, or the concrete, + // already-actionable message for a SafeToDisplayActivityError - + // appending "action_aborted" again would either duplicate it or + // wrongly imply every rejection is a generic abort. + $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 + ); + } + + // These three outcomes are all "nothing changed for this language, + // and here is why" - combined into a single info message for the + // same reason as installObject()/refreshSelectedObject() combine + // theirs: setOnScreenMessage() only keeps one message per type. + $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 + * Remove local changes of one or more already installed languages - see + * RemoveLocalLanguageChanges. A language among $ids that is not + * installed (or not a known language key at all) is left completely + * 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); + } - unset($langObj); + $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); + + // activityErrorMessage() already returns a complete, self- + // contained message - a generic, localized "action_aborted" text + // for an unexpected internal failure, or the concrete, + // already-actionable message for a SafeToDisplayActivityError - + // appending "action_aborted" again would either duplicate it or + // wrongly imply every rejection is a generic abort. + $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 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']); + + if (($lang_changed = $value['removed_local_changes_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'success', + $this->lng->txt("selected_languages_updated") . "
" + . $this->languageKeysToLocalizedList($lang_changed), + true + ); } - ilObjLanguage::refreshPlugins($refreshed); - $this->out(); - } - /** - * update all installed languages - */ - public function refreshObject(): void - { - $this->checkPermission("write"); + if (($lang_invalid = $value['invalid_language_file_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'failure', + $this->languageKeysToLocalizedList($lang_invalid) . ": " . $this->lng->txt("file_not_valid"), + true + ); + } - ilObjLanguage::refreshAll(); - $this->data = $this->lng->txt("languages_updated"); - $this->out(); + 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 + * Refresh (already installed) languages, re-seeding their base data + * (plus a customizing/local file if one exists) from the current + * language files - see UpdateLanguage. A language among $ids that is + * not installed is left completely 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() for why an empty selection must never reach + // the Activity itself. + 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); + + // activityErrorMessage() already returns a complete, self- + // contained message (see uninstallObject()) - a fixed + // "language_not_installed" prefix does not apply to every + // possible error here (e.g. a permission failure, or an invalid + // language file), so it must not be prepended unconditionally. + $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'); } /** @@ -596,19 +863,6 @@ public function executeCommand(): void $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') { @@ -618,7 +872,12 @@ public function executeCommand(): void $ids[] = (string) $lang["obj_id"]; } } - $this->installLocalObject($ids); + // $action is exactly 'install' or 'install_local' + // here (the two case labels above) - which are + // also InstallLanguage::MODE_INSTALL and + // MODE_INSTALL_LOCAL's values, so it can be + // passed straight through as the mode. + $this->installObject($ids, $action); break; case 'lang_uninstall_changes': $ids = $this->getIdsFromQueryToken(); @@ -714,6 +973,10 @@ public function confirmRefreshSelectedObject(array $a_ids = []): void $this->ctrl->redirect($this, "view"); } + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + $conf_screen = new ilConfirmationGUI(); $some_changed = false; foreach ($ids as $id) { @@ -743,10 +1006,16 @@ public function confirmUninstallObject(): void $this->checkPermission("write"); $this->lng->loadLanguageModule("meta"); + + $ids = $this->getIdsFromQueryToken(); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + $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) { + foreach ($ids as $id) { $lang_title = ilObject::_lookupTitle((int) $id); $conf_screen->addItem("id[]", $id, $this->lng->txt("meta_l_" . $lang_title)); } @@ -760,10 +1029,16 @@ public function confirmUninstallChangesObject(): void $this->checkPermission('write'); $this->lng->loadLanguageModule("meta"); + + $ids = $this->getIdsFromQueryToken(); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + $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) { + foreach ($ids as $id) { $lang_title = ilObject::_lookupTitle($id); $conf_screen->addItem("id[]", (string) $id, $this->lng->txt("meta_l_" . $lang_title)); } @@ -772,56 +1047,6 @@ public function confirmUninstallChangesObject(): void $this->tpl->setContent($conf_screen->getHTML()); } - /** - * 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 - ), - ]; - } - protected function editFolderObject(array $ids): void { $this->ctrl->setParameterByClass("ilobjlanguageextgui", "obj_id", current($ids)); @@ -829,21 +1054,60 @@ protected function editFolderObject(array $ids): void } /** - * Disable language detection + * Disable language detection - see SetLanguageDetectionEnabled. */ protected function disableLanguageDetectionObject(): void { - $this->settings->set("lang_detection", '0'); - $this->tpl->setOnScreenMessage('success', $this->lng->txt("saved_successfully")); - $this->viewObject(); + $this->setLanguageDetectionEnabledObject(false); } /** - * Enable language detection + * Enable language detection - see SetLanguageDetectionEnabled. */ protected function enableLanguageDetectionObject(): void { - $this->settings->set("lang_detection", '1'); + $this->setLanguageDetectionEnabledObject(true); + } + + /** + * Common implementation of enableLanguageDetectionObject()/ + * disableLanguageDetectionObject() - both flip the very same + * "lang_detection" system setting to a fixed value, so both are backed + * by the same SetLanguageDetectionEnabled Activity with a different + * boolean argument. + */ + private function setLanguageDetectionEnabledObject(bool $enabled): void + { + $result = $this->set_language_detection_enabled->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['enabled' => $enabled] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + // activityErrorMessage() already returns a complete, self- + // contained message - a generic, localized "action_aborted" text + // for an unexpected internal failure, or the concrete, + // already-actionable message for a SafeToDisplayActivityError - + // appending "action_aborted" again would either duplicate it or + // wrongly imply every rejection is a generic abort. + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); + + $this->ctrl->redirect($this, 'view'); + return; + } + + // Unlike installObject()/uninstallObject()/refreshSelectedObject(), + // which redirect after a persisted message, this preserves the + // extracted code's original behaviour: render the view directly with + // a non-persisted message, rather than an HTTP redirect. $this->tpl->setOnScreenMessage('success', $this->lng->txt("saved_successfully")); $this->viewObject(); } diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php new file mode 100644 index 000000000000..564c70a331a9 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -0,0 +1,319 @@ +replace_lang_entry = $replace_lang_entry + ?? static fn( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ): bool => \ilObjLanguage::replaceLangEntry($module, $identifier, $lang_key, $value, $local_change, $remarks); + $db_resolver = $db instanceof \Closure ? $db : static fn(): \ilDBInterface => $db; + $this->update_module_cache = $update_module_cache + ?? static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ) use ($db_resolver): void { + $db = $db_resolver(); + + $set = $db->query( + 'SELECT lang_array FROM lng_modules WHERE lang_key = ' + . $db->quote($lang_key, 'text') . ' AND module = ' . $db->quote($module, 'text') + ); + $row = $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); + }; + $this->user_login = $user_login + ?? static fn(int $usr_id): string => \ilObjUser::_lookupLogin($usr_id); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'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 + ); + } + + /** + * Must keep exactly `Activity::getInputDescription(FieldFactory $f): FormInput`'s signature. + * An earlier version added an optional `?array $installed_language_keys` parameter here to + * avoid a second repository round-trip; while PHP itself permits an optional extra parameter, + * a *subclass* overriding the method with the plain interface signature then has FEWER + * parameters than its own parent, which PHP rejects as a variance fatal error at class-load + * time (reproduced with a minimal subclass under PHP 8.5.4). Hence this resolves the + * installed-language snapshot itself, on every call, via the repository directly. + * + * Consequence: getInputDescription() and perform() can observe different installed-language + * snapshots if a language is installed/uninstalled between the two calls within one + * maybePerformAs() call. A newly installed optional language just 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. + */ + public function getInputDescription(FieldFactory $f): FormInput + { + $installed_language_keys = $this->installed_language_repository->getInstalledLanguages(); + + // Field labels use $this->lng->txt('meta_l_' . $lang_key), which requires the caller to + // have already loaded the "meta" language module. This method deliberately does not load + // it itself: loadLanguageModule() merges keys unnamespaced into the shared $lng instance, + // which could clobber another module's keys if this ran generically alongside other + // Activities. The GUI caller already loads it; a caller that doesn't gets the raw, + // untranslated placeholder (e.g. "-meta_l_de-") instead of a crash. + $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->group([ + 'module' => $module, + 'identifier' => $identifier, + 'translations' => $translations, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of adding the language entry.'), + [ + 'module' => $f->string($this->markdown('Language module the new entry was added to.')), + 'identifier' => $f->string($this->markdown('Identifier of the new language entry.')), + 'added_language_keys' => $f->list( + $this->markdown( + 'Installed languages for which a non-blank value was given - the entry was ' . + 'written for them.' + ), + $f->string($this->markdown('Language key of a language the entry was added for.')) + ), + 'skipped_empty_language_keys' => $f->list( + $this->markdown( + '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($this->markdown('Language key of a language with no given value.')) + ), + ] + ); + } + + /** + * @param mixed $parameters may additionally carry a `usr_id` (int) key, merged in by + * LanguageActivity::maybePerformAs() via additionalPerformParameters() (never part of + * getInputDescription(), so it can never be spoofed via form data): if given, it is + * recorded as the author of the local change made to every written entry. It is + * optional - a generic caller following the plain Activity contract never supplies it, + * and the entries are then written without an attributed author instead. + */ + 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->installed_language_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) . '.' + ); + } + + // $login is '' when usr_id is absent; replaceLangEntry() treats '' as "no remarks", so + // the entry is written without an attributed author rather than the request failing. + $login = is_int($usr_id) ? ($this->user_login)($usr_id) : ''; + $local_change = gmdate('Y-m-d H:i:s'); + + $added_language_keys = []; + $skipped_empty_language_keys = []; + + // Not transactional across multiple languages: a failure partway through (e.g. a + // database error) leaves languages processed so far written. + foreach ($installed_language_keys as $lang_key) { + $value = $translations[$lang_key] ?? ''; + $value = is_string($value) ? trim($value) : ''; + + // A value of exactly "0" is treated as blank too, matching the legacy behaviour. + if (!$value) { + $skipped_empty_language_keys[] = $lang_key; + continue; + } + + ($this->replace_lang_entry)($module, $identifier, $lang_key, $value, $local_change, $login); + ($this->update_module_cache)($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, + ]; + } + + /** + * Threads the trusted, server-resolved `usr_id` into perform()'s $parameters via + * LanguageActivity's template-method hook - see LanguageActivity::maybePerformAs()'s + * array_merge() for why this wins over a same-named key from form input. + * + * @return array{usr_id: int} + */ + protected function additionalPerformParameters(int $usr_id): array + { + return ['usr_id' => $usr_id]; + } + + /** + * @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) === '') { + // getInputDescription()'s withRequired(true) only rejects an entirely empty raw + // string, not a whitespace-only one - reject that here too. + 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..76d70726d0c7 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php @@ -0,0 +1,25 @@ +text( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->withRequired(true)->withDedicatedName('language_keys'); + + return $f->group([ + 'language_keys' => $language_keys, + ]); + } + + /** + * @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/GrindingInputAccess.php b/components/ILIAS/Language/src/Activities/GrindingInputAccess.php new file mode 100644 index 000000000000..9f5f09583b71 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/GrindingInputAccess.php @@ -0,0 +1,134 @@ +withNameFrom(new FormInputNameSource()); + } + + /** + * @param array $flat_raw_values keyed by the HTML input names assigned via + * named() above + */ + 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; + } + + /** + * Throws $exception_message as a \LogicException unless $input is nameable + * (i.e. implements InputInternal) - kept here so callers never need to name + * InputInternal themselves. + */ + public static function requireNameable(Input $input, string $exception_message): void + { + if (!$input instanceof InputInternal) { + throw new \LogicException($exception_message); + } + } + + /** + * The dedicated name (see `withDedicatedName()`) is 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..2ad7df3b0f1c --- /dev/null +++ b/components/ILIAS/Language/src/Activities/GrindsFormInput.php @@ -0,0 +1,222 @@ + $raw_parameters + */ + private function grind(FormInput $description, array $raw_parameters): Result + { + try { + $named = GrindingInputAccess::named($description, static::class); + + $flat_input = []; + // Unknown keys are tolerated at this top level (getInputDescription()'s outermost + // group is never itself validated against $raw_parameters), but rejected in every + // nested group - see the $enforce_known_keys parameter below. + $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) { + // Every \InvalidArgumentException reachable in this try block - from + // collectRawValues()/normalizeCheckboxRawValue() above, or from the UI framework + // applying $flat_input to an already-built field tree - describes a malformed request + // value, never an internal misconfiguration, and never leaks anything sensitive - safe + // to convert into an InvalidInputException shown to the end user. One thrown while + // BUILDING the FormInput itself happens inside getInputDescription(), in the caller's + // own try block, and never reaches here. + return new Result\Error(new InvalidInputException($e->getMessage(), 0, $e)); + } catch (\Throwable $e) { + return new Result\Error( + $e instanceof \Exception ? $e : new \RuntimeException($e->getMessage(), 0, $e) + ); + } + } + + /** + * @param array $flat + * @param bool $enforce_known_keys Defaults to false for the outermost group (grind() above), + * so an unrelated key in top-level $raw_parameters is silently ignored. Every nested + * group is always recursed into with true (below) instead - its keys come entirely + * from this Activity's own getInputDescription(), so an unknown one should fail loudly + * instead of vanishing. + */ + private function collectRawValues(Input $named, mixed $raw_value, array &$flat, bool $enforce_known_keys = false): void + { + if ($named instanceof GroupInput) { + // null means the group was omitted entirely (treated as an empty array, so every + // child gets null in turn); any other non-array value is a genuine type mismatch and + // must be rejected loudly instead. + 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 ?? '', + }; + } + + /** + * Returns $raw_value joined into a single comma-separated string if every one of its items + * is a string; otherwise returns $raw_value unchanged, in whatever shape the caller passed. + * + * @param array $raw_value + * @return array|string + */ + 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..e2c934a3fb4d --- /dev/null +++ b/components/ILIAS/Language/src/Activities/InstallLanguage.php @@ -0,0 +1,257 @@ + throw new \LogicException( + 'RBAC is not available during Setup; ' + . self::class . '::isAllowedToPerform() cannot be used here.' + ), + $setup_language + ); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'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->group([ + 'language_keys' => $language_keys, + 'mode' => $mode, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of the language installation.'), + [ + 'installed_language_keys' => $f->list( + $this->markdown('Newly installed languages without a custom language file.'), + $f->string($this->markdown('Language key of a newly installed language.')) + ), + 'installed_with_local_language_keys' => $f->list( + $this->markdown( + '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($this->markdown('Language key of a language with an installed custom language file.')) + ), + 'already_installed_language_keys' => $f->list( + $this->markdown( + '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($this->markdown('Language key of an already installed language.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + '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($this->markdown('Language key of a not-yet-installed language.')) + ), + 'invalid_local_language_files' => $f->list( + $this->markdown('Local language files with an invalid file name.'), + $f->string($this->markdown('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 no customizing/local file to apply for this language - + // the same outcome as "already installed, nothing changed". + $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..c4c476c24dde --- /dev/null +++ b/components/ILIAS/Language/src/Activities/InvalidInputException.php @@ -0,0 +1,25 @@ +refinery = $refinery; + $this->lng = $language; + $this->rbac_system = $rbac_system instanceof \Closure + ? $rbac_system + : static fn(): \ilRbacSystem => $rbac_system; + $this->language_folder_ref_id = $language_folder_ref_id instanceof \Closure + ? $language_folder_ref_id + : static fn(): int => $language_folder_ref_id; + } + + public function getType(): ActivityType + { + return ActivityType::Command; + } + + protected function markdown(string $raw): Text\SimpleDocumentMarkdown + { + return new Text\SimpleDocumentMarkdown( + new SimpleDocumentMarkdownShape( + $this->refinery->string()->markdown() + ), + $raw + ); + } + + public function isAllowedToPerform(int $usr_id, mixed $parameters): bool + { + return ($this->rbac_system)()->checkAccessOfUser( + $usr_id, + 'write', + ($this->language_folder_ref_id)() + ); + } + + public function maybePerformAs(InputFactory $input_factory, int $usr_id, array $raw_parameters): Result + { + try { + // getInputDescription()/$input_factory->field() might throw too (e.g. a concrete + // Activity building its FormInput eagerly) - kept inside the try so it is wrapped in + // a Result\Error like everything else, per Activity::maybePerformAs()'s contract, + // instead of escaping uncaught. + $grind_result = $this->grind($this->getInputDescription($input_factory->field()), $raw_parameters); + if ($grind_result->isError()) { + return new Result\Error($grind_result->error()); + } + + $parameters = $this->normalizeParameters($grind_result->value()); + if (!$this->isAllowedToPerform($usr_id, $parameters)) { + return new Result\Error($this->lng->txt('msg_no_perm_write')); + } + + // array_merge(), not `+`: `+` keeps the LEFT operand on a key collision, so a spoofed + // 'usr_id' from form input would win over the trusted value from + // additionalPerformParameters(). array_merge() keeps the RIGHT (last) operand instead + // - safe here since normalizeParameters()'s contract below is `array`, + // never a numeric(-string) key, which is the one case array_merge() would not + // overwrite by key. + + return new Result\Ok( + $this->perform(array_merge($parameters, $this->additionalPerformParameters($usr_id))) + ); + } catch (\Throwable $e) { + return new Result\Error( + $e instanceof \Exception ? $e : new \RuntimeException($e->getMessage(), 0, $e) + ); + } + } + + /** + * @return array + */ + abstract protected function normalizeParameters(array $grind_result): array; + + /** + * Template-method hook: lets a subclass thread trusted, server-resolved extras (e.g. the + * caller's `usr_id`) into perform()'s $parameters without overriding maybePerformAs() itself. + * Returned keys win over a same-named key from normalizeParameters() - see the array_merge() + * call above. + * + * @return array + */ + protected function additionalPerformParameters(int $usr_id): array + { + return []; + } +} diff --git a/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php new file mode 100644 index 000000000000..962addfb473d --- /dev/null +++ b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php @@ -0,0 +1,71 @@ +.lang` file naming convention. + 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..47dbce3f4e08 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php @@ -0,0 +1,160 @@ + + * @param \Closure|null $obj_language_factory (int $obj_id): \ilObjLanguage + */ + public function __construct( + RefineryFactory $refinery, + Language $language, + \ilRbacSystem|\Closure $rbac_system, + int|\Closure $language_folder_ref_id = 0, + ?\Closure $lng_objects = null, + ?\Closure $obj_language_factory = null, + ) { + parent::__construct($refinery, $language, $rbac_system, $language_folder_ref_id); + $this->lng_objects = $lng_objects + ?? static fn(): array => \ilObject::_getObjectsByType('lng'); + $this->obj_language_factory = $obj_language_factory + ?? static fn(int $obj_id): \ilObjLanguage => new \ilObjLanguage($obj_id, false); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'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 + ); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of removing local language changes.'), + [ + 'removed_local_changes_language_keys' => $f->list( + $this->markdown( + 'Already installed languages whose local changes were removed - they were ' . + 'reinstalled purely from the global/component language files.' + ), + $f->string($this->markdown('Language key of a language whose local changes were removed.')) + ), + 'invalid_language_file_keys' => $f->list( + $this->markdown( + 'Requested languages that were left untouched because their underlying ' . + 'language file failed validation.' + ), + $f->string($this->markdown('Language key of a language with an invalid language file.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + '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($this->markdown('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, for all requested keys at once, so a request naming both an + // unambiguous and an ambiguous key never changes the unambiguous one before + // rejecting the whole call. + $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 across multiple keys: a failure partway through (e.g. a database + // error) leaves 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; + } + + $language_object = ($this->obj_language_factory)($obj_id_by_language_key[$language_key]); + + if (!$language_object->isInstalled()) { + $not_installed_language_keys[] = $language_key; + continue; + } + + // removeLocalChanges() re-checks isInstalled() internally (and additionally + // validates the language file) and returns false if either fails - "not installed" + // is already excluded above, so false here can only mean validation failed. + if ($language_object->removeLocalChanges()) { + $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/ResolvesLanguageKeysToObjIds.php b/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php new file mode 100644 index 000000000000..75d652f132fe --- /dev/null +++ b/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php @@ -0,0 +1,59 @@ +) + // - left to each using class (rather than declared here) since its default value differs + // per Activity. + + /** + * Language keys (titles) shared by more than one "lng" object are excluded from the first + * array and returned as keys of the second instead. + * + * @return array{0: array, 1: array} + */ + private function resolveObjIdsByLanguageKey(): array + { + $lng_objects = ($this->lng_objects)(); + + $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/SafeToDisplayActivityError.php b/components/ILIAS/Language/src/Activities/SafeToDisplayActivityError.php new file mode 100644 index 000000000000..d99e0db265f8 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/SafeToDisplayActivityError.php @@ -0,0 +1,25 @@ +settings = $settings instanceof \Closure + ? $settings + : static fn(): Setting => $settings; + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'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->group([ + 'enabled' => $enabled, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of changing the language detection setting.'), + [ + 'enabled' => $f->bool( + $this->markdown('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->settings)()->set('lang_detection', $enabled ? '1' : '0'); + + 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..f31369e11d08 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php @@ -0,0 +1,154 @@ +settings = $settings instanceof \Closure + ? $settings + : static fn(): Setting => $settings; + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'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->group([ + 'language_key' => $language_key, + 'enabled' => $enabled, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of changing the page translation setting.'), + [ + 'language_key' => $f->string( + $this->markdown('Language key the page translation setting was changed for.') + ), + 'enabled' => $f->bool( + $this->markdown('The page translation setting for this language after this change.') + ), + 'changed' => $f->bool( + $this->markdown( + '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->installed_language_repository->getInstalledLanguages(), true)) { + throw new InvalidInputException( + 'Unknown language key "' . $language_key . '" - not an installed language.' + ); + } + + $translate_key = 'lang_translate_' . $language_key; + + $currently_enabled = (bool) ($this->settings)()->get($translate_key, '0'); + // Strict comparison of two proper booleans, not a loose `!=` on raw strings - see + // SetLanguageTranslationEnabledTest for the edge case this pins down. + $changed = $currently_enabled !== $enabled; + + if ($changed) { + ($this->settings)()->set($translate_key, $enabled ? '1' : '0'); + } + + 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..5900d8cad8cf --- /dev/null +++ b/components/ILIAS/Language/src/Activities/UninstallLanguage.php @@ -0,0 +1,172 @@ + + * @param \Closure|null $obj_language_factory (int $obj_id): \ilObjLanguage + */ + public function __construct( + RefineryFactory $refinery, + Language $language, + \ilRbacSystem|\Closure $rbac_system, + int|\Closure $language_folder_ref_id = 0, + ?\Closure $lng_objects = null, + ?\Closure $obj_language_factory = null, + ) { + parent::__construct($refinery, $language, $rbac_system, $language_folder_ref_id); + $this->lng_objects = $lng_objects + ?? static fn(): array => \ilObject::_getObjectsByType('lng'); + $this->obj_language_factory = $obj_language_factory + ?? static fn(int $obj_id): \ilObjLanguage => new \ilObjLanguage($obj_id); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'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 + ); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of the language uninstallation.'), + [ + 'uninstalled_language_keys' => $f->list( + $this->markdown('Languages that were uninstalled.'), + $f->string($this->markdown('Language key of an uninstalled language.')) + ), + 'system_language_keys' => $f->list( + $this->markdown( + 'Requested languages that were left untouched because they are the ' . + 'current system language - the system language can never be uninstalled.' + ), + $f->string($this->markdown('Language key of the system language.')) + ), + 'user_language_keys' => $f->list( + $this->markdown( + '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($this->markdown('Language key of the language currently in use.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + '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($this->markdown('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, for all requested keys at once, so a request naming both an + // unambiguous and an ambiguous key never uninstalls the unambiguous one before + // rejecting the whole call. + $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 across multiple keys: a failure partway through (e.g. a database + // error) leaves 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; + } + + $language_object = ($this->obj_language_factory)($obj_id_by_language_key[$language_key]); + + if ($language_object->isSystemLanguage()) { + $system_language_keys[] = $language_key; + } elseif ($language_object->isUserLanguage()) { + // Compares against the ambient Language service's lang_user (this request's + // session), not against $usr_id - a caller acting for another user's $usr_id + // (e.g. a webservice or background job) does not protect that user's language. + $user_language_keys[] = $language_key; + } elseif (!$language_object->isInstalled()) { + $not_installed_language_keys[] = $language_key; + } elseif ($language_object->uninstall() !== '') { + // uninstall() re-checks these same guards internally and returns "" if any + // still applies - the outcome is decided by this return value, not the checks + // above alone. + $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..937540e1792e --- /dev/null +++ b/components/ILIAS/Language/src/Activities/UpdateLanguage.php @@ -0,0 +1,141 @@ + throw new \LogicException( + 'RBAC is not available during Setup; ' + . self::class . '::isAllowedToPerform() cannot be used here.' + ), + $setup_language + ); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'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 + ); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of the language update.'), + [ + 'updated_language_keys' => $f->list( + $this->markdown( + 'Already installed languages that were refreshed from the current ' . + 'language files (base data, plus a customizing/local file if one exists).' + ), + $f->string($this->markdown('Language key of a refreshed language.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + 'Requested languages that are not installed - skipped entirely, since ' . + 'there is nothing installed yet to refresh. Use InstallLanguage to ' . + 'install them first.' + ), + $f->string($this->markdown('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..f1705d8a3e97 --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php @@ -0,0 +1,68 @@ + + */ +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 + { + // $this->component's file location never changes for the lifetime + // of this object, so the reflection/realpath work below only needs + // to happen once - cache it instead of redoing it on every call + // (this method is called repeatedly per directory-listing 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..06ec277b73ee --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php @@ -0,0 +1,30 @@ + + */ +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..0804931dde37 --- /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/RendersActivityErrors.php b/components/ILIAS/Language/src/RendersActivityErrors.php new file mode 100644 index 000000000000..94aca01660b9 --- /dev/null +++ b/components/ILIAS/Language/src/RendersActivityErrors.php @@ -0,0 +1,111 @@ +activity_error_logger` exactly once, in its own + * constructor - the same "resolved once in the constructor, never + * reached into via global $DIC from an action method" idiom already + * used for every Activity these GUI classes inject (see e.g. + * ilObjLanguageFolderGUI::__construct()); and + * - provide `$this->lng` (both ilObjLanguageFolderGUI/ilObjLanguageExtGUI + * already do, inherited from ilObjectGUI). + */ +trait RendersActivityErrors +{ + private readonly \ilLogger $activity_error_logger; + + /** + * Turns a maybePerformAs() Result\Error's error() into text safe to show + * to the user via setOnScreenMessage() (which renders it as HTML, + * unescaped - see every GUI caller's own literal "
" concatenations + * alongside $this->lng->txt() calls). + * - A plain string (e.g. `msg_no_perm_write`) is already a localized, + * trusted, HTML-safe message produced by the Activity itself (via + * $this->lng->txt(), the same source every GUI caller's own literal + * markup already trusts) and is returned as-is, unescaped. + * - A \Throwable implementing SafeToDisplayActivityError (e.g. + * InvalidInputException, AmbiguousLanguageTitleException - both marker + * implementations with no logic of their own) is a genuine, actionable + * problem already reduced to a concrete, non-sensitive message - but, + * unlike the plain-string case above, its text may embed raw, + * caller-controlled data (a language key, an unknown form field + * name, ...): the Activity/domain layer deliberately returns this + * unescaped (see e.g. UninstallLanguage::perform()), so THIS is the + * one place that escapes it for safe HTML display, rather than each + * Activity escaping it itself. + * - Any OTHER \Throwable is an unexpected failure (e.g. a database + * error) whose message is hardcoded English, not meant for end + * users, and in the worst case (\ilDatabaseException) may even + * carry raw SQL - so it is never shown directly: its full cause + * chain (class, message and stack trace of the exception itself, + * plus of every getPrevious() wrapped underneath it) is logged via + * this component's established "lang" logger channel, and a + * generic, localized message (again already trusted, see above) is + * shown instead. + */ + private function activityErrorMessage(\Throwable|string $error): string + { + if (is_string($error)) { + return $error; + } + + if ($error instanceof SafeToDisplayActivityError) { + // ENT_SUBSTITUTE: without it, invalid UTF-8 in getMessage() (e.g. + // caller-controlled data embedded by the domain layer, see the + // docblock above) makes htmlspecialchars() return an empty + // string instead of the message - the user would then see a + // blank error box with no indication anything went wrong, rather + // than the actual (partially substituted) message. + return htmlspecialchars($error->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE); + } + + $this->activity_error_logger->error($this->describeThrowableChain($error)); + + return $this->lng->txt('action_aborted'); + } + + /** + * Renders $error and, recursively, every exception it was caused by + * (getPrevious()) - so a wrapper exception (e.g. the + * \RuntimeException/InvalidInputException that GrindsFormInput::grind() + * wraps a lower-level \Throwable in) never hides the original failure's + * own class, message and trace from the log. + */ + 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/InstalledLanguageDatabaseRepository.php b/components/ILIAS/Language/src/Setup/InstalledLanguageDatabaseRepository.php new file mode 100644 index 000000000000..30c32119c2d6 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstalledLanguageDatabaseRepository.php @@ -0,0 +1,343 @@ +.lang...` file naming + * convention that getLocalLanguages()/getInstallableLanguages() below + * discover files by. Mirrors + * \ILIAS\Language\Activities\ParsesLanguageKeyList::LANGUAGE_KEY_FORMAT + * (exactly two lowercase ASCII letters) so that a file this repository + * lists as installable/local can never fail that later, stricter + * validation once an Activity actually tries to install/refresh it. + * Kept as a parallel definition, not a shared reference: PHP trait + * constants cannot be accessed via the trait's own name (only through a + * class that uses the trait), and this class has no reason to use + * ParsesLanguageKeyList itself, which exists to parse a + * comma-separated form/request value, not to discover files. + */ + private const LANGUAGE_KEY_PATTERN = '[a-z]{2}'; + + /** + * @param \ilDBInterface|\Closure():\ilDBInterface $db Accepted as a closure so this + * can be built once (e.g. in Language.php's DI wiring) before a database + * connection is available, and resolved lazily on first use - mirroring + * the $GLOBALS['ilDB'] ?? $DIC->database() convention used for + * \ILIAS\Language\Activities\InstallLanguage. + */ + public function __construct( + private readonly \ilDBInterface|\Closure $db, + private readonly LanguageFileDirectoryManager $language_file_directory_manager, + private readonly string $absolute_path, + ) { + } + + private function db(): \ilDBInterface + { + return $this->db instanceof \Closure ? ($this->db)() : $this->db; + } + + 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 = []; + // Unlike the two queries above, obj_id and description are both + // actually read below (title alone would not be enough here) - see + // the loop body. + $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(); + + // See LanguageInstallationManager::insertLanguage() for why every + // chdir() here must be undone even if something throws in between - + // a long-lived worker process would otherwise leak a broken cwd into + // unrelated later requests. + try { + foreach ($this->language_file_directory_manager->getCustomizingDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->absolute_path, $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(); + + // See LanguageInstallationManager::insertLanguage() for why every + // chdir() here must be undone even if something throws in between. + try { + foreach ($this->language_file_directory_manager->getDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->absolute_path, $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->absolute_path, $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 + { + // getCustomizingDirectories() yields exactly one directory today + // (see LanguageFileDirectoryManager), but is typed as a generator of + // possibly many - so check only the first one explicitly, instead of + // a foreach that returns on its first iteration (which reads like an + // accidental early return, not an intentional "only the first one"). + $directories = iterator_to_array( + $this->language_file_directory_manager->getCustomizingDirectories(), + false + ); + + if ($directories === []) { + return false; + } + + return $this->checkLanguageFile($lang_key, $directories[0], true); + } + + /** + * Validates a single directory's lang-file for $lang_key: the file + * itself has a header and each entry consists of exactly three elements + * (module, identifier, value). If $required is false, a missing + * directory/file is treated as acceptable (valid); if true, it fails + * the check. + */ + 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->absolute_path, $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..8eb7da7faa71 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php @@ -0,0 +1,116 @@ + + */ + public function getInstalledLanguages(): array; + + /** + * Language keys installed as "installed_local" (in the database). + * + * @return list + */ + public function getInstalledLocalLanguages(): array; + + /** + * All languages registered in object_data, keyed by language key. + * + * @return array + */ + public function getAvailableLanguages(): array; + + /** + * Locally changed language entries for a language key, in a given + * change-date range. + * + * @return array> [module][identifier] => value + */ + public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array; + + /** + * Every entry currently stored for a language key, regardless of + * local_change - unlike getLocalChanges(), which only returns entries + * with a non-null local_change in a given date range (i.e. previously + * recorded customizations), this also includes plain base/global data. + * Used to seed a re-seed of lng_data that only reads the customizing + * directory (see LanguageInstallationManager::insertLanguageForApplyingLocalChanges()), + * where nothing re-reads the base files to fill in what this doesn't + * provide. + * + * @return array> [module][identifier] => value + */ + public function getLanguageEntries(string $lang_key): array; + + /** + * Language keys for which a *.lang.local file exists. + * + * @return list + */ + public function getLocalLanguages(): array; + + /** + * Language keys for which an installable main *.lang file exists. + * + * @return list + */ + public function getInstallableLanguages(): array; + + /** + * Local language files whose names contain a requested language key but + * do not match the expected naming scheme. + * + * @param list $language_keys + * @return list + */ + public function getInvalidLocalLanguageFiles(array $language_keys): array; + + /** + * Validates the logical structure of all *.lang files (global, + * component and, if present, customizing) for a language key. + */ + public function checkLanguage(string $lang_key): bool; + + /** + * Validates only the customizing/local language file for a language + * key. Unlike checkLanguage(), a missing local file is NOT acceptable + * here - used when a caller explicitly wants to validate a local + * customization file, e.g. before activating "installed_local" status + * for an already globally-installed language. + */ + 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..b7ac303b3c54 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/LanguageFileParsing.php @@ -0,0 +1,53 @@ +getPath(), '/'); + } + + /** + * @param string[] $content expects an ILIAS lang-file + * @return bool|string[] the file's entries with the header stripped, or false if no header + * marker was found + */ + 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..8bd71aac6401 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php @@ -0,0 +1,432 @@ +db instanceof \Closure ? ($this->db)() : $this->db; + } + + private function utcTimestamp(?int $unix_timestamp = null): string + { + if ($unix_timestamp !== null) { + return gmdate("Y-m-d H:i:s", $unix_timestamp); + } + $now = $this->now !== null ? ($this->now)() : new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + return $now->format('Y-m-d H:i:s'); + } + + /** + * Install the given languages, uninstall/flush all others that were + * previously known. Mirrors ilSetupLanguage::installLanguages(). + * + * @param list $lang_keys + * @param list $local_keys unused, kept for backwards compatibility - see + * ilSetupLanguage::installLanguages(), which never used it either; + * local languages are always looked up via the repository instead. + * @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); + + // register language first time install; an already-known + // language's status is (re-)synced below instead. + 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 bookkeeping row for a language + * that has just been flushed/(re-)inserted, deciding "installed" vs. + * "installed_local" and INSERTing a fresh row or UPDATEing the existing + * one accordingly. This is the single source of truth for that + * bookkeeping - both installLanguages() and + * \ILIAS\Language\Activities\InstallLanguage::perform() call this + * instead of duplicating the object_data INSERT/UPDATE. + * + * @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")); + } + } + + 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(), + [] + ); + } + + /** + * (Re-)apply only the customizing/local directory's file on top of an + * already installed language, without touching the base/global data at + * all - the opposite split from insertLanguageForRemovingLocalChanges(). + * + * This is the write path behind the "Install local" GUI command applied + * to an already-installed language (see ilObjLanguageFolderGUI and + * \ILIAS\Language\Activities\InstallLanguage::perform()): unlike + * insertLanguageForInstallation(), the caller does not flush anything + * first, and the base/global directories are never read here either - + * so there is no re-parsing of the (usually much larger) base language + * files, and no risk of the base data being touched. + * + * Because nothing here re-reads the base directories to fill in what the + * customizing file does not cover, the seed passed to insertLanguage() + * must already contain every entry currently stored for the language - + * both base data and any previously recorded local changes - not just + * the local changes the way insertLanguageForInstallation()'s seed does. + * insertLanguage() rebuilds the lng_modules cache row for every module + * it sees an entry for, from scratch, using exactly the seed plus + * whatever it reads from the given directories; a narrower seed (e.g. + * only previously local entries) would make any base entry not + * overridden by the customizing file silently disappear from that + * module's cache row. + */ + 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 an + // even newer change for that entry. + $newer_db_changes = $this->repository->getLocalChanges( + $lang_key, + $this->utcTimestamp(filemtime($lang_file)) + ); + // One import timestamp for every entry of this 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 local changes already recorded in the + // database, and let an earlier directory win. + 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); + } finally { + chdir($working_dir); + } + } +} diff --git a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php new file mode 100644 index 000000000000..066be564331f --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php @@ -0,0 +1,97 @@ +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()); + } + + /** + * Reflection-level pin: getInputDescription() must declare exactly Activity::getInputDescription()'s + * own parameter count/types/nullability/return type for every concrete Activity - no extra + * parameter, even an optional one. A parent declaring MORE parameters than a subclass + * overriding it with the plain interface signature is a PHP variance fatal error at + * class-load time (reproduced for AddLanguageEntry under PHP 8.5.4 - see + * AddLanguageEntryTest's own process-isolated regression test). + */ + 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()); + } + } +} diff --git a/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php new file mode 100644 index 000000000000..de1d7ed85ff6 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php @@ -0,0 +1,57 @@ +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..e7d518c79adc --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -0,0 +1,1497 @@ +createActivity([]); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + 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'])); + } + + // ----------------------------------------------------------------- + // perform() + // ----------------------------------------------------------------- + + 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]); + } + } + + /** + * Regression test for the deliberate "not transactional" behaviour noted above perform()'s + * write loop: if replace_lang_entry() throws partway through (here: the 3rd of 3 languages), + * languages already written must remain written, and the exception must propagate rather than + * roll back. + */ + public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEarlierLanguagesWritten(): void + { + $calls = []; + $replace_lang_entry = static function ( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ) use (&$calls): bool { + $calls['replace'][] = [$module, $identifier, $lang_key, $value, $local_change, $remarks]; + if ($lang_key === 'fr') { + throw new \RuntimeException('simulated database error while writing "fr"'); + } + return true; + }; + + $activity = $this->createActivity( + ['de', 'en', 'fr'], + replace_lang_entry: $replace_lang_entry, + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => 'Hello', + 'fr' => 'Bonjour', + ], + 'usr_id' => 6, + ]); + $this->fail('Expected the simulated \RuntimeException to propagate out of perform().'); + } catch (\RuntimeException $e) { + $this->assertSame('simulated database error while writing "fr"', $e->getMessage()); + + // "de" and "en" - written before the failing "fr" - remain + // written: perform() gives no atomicity guarantee across the + // write loop (see perform()'s own "Not transactional..." + // comment above the 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]); + + // Exactly the 3 attempted calls - "fr" itself was attempted + // (and recorded before throwing) but never reached + // update_module_cache(). + $this->assertCount(3, $calls['replace']); + $this->assertCount(2, $calls['cache']); + } + } + + // ----------------------------------------------------------------- + // "de"/"en" mandatory, all-or-nothing (see getDescription()'s markdown) + // ----------------------------------------------------------------- + + 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 as the perform() test above, 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->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $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 (e.g. from a direct perform() caller bypassing + * normalizeParameters()) must be treated as empty, not coerced or crash. + */ + 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 explicitly allowed (see perform()'s own docblock): 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 explicitly ALLOWED (see + // testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure() + // and testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations() below) - it must + // NOT appear here as an "invalid" case. Only a non-null usr_id of the wrong TYPE is invalid. + 'non-int usr_id' => [['module' => 'common', 'identifier' => 'foo', 'translations' => [], 'usr_id' => '6']], + ]; + } + + #[DataProvider('invalidPerformParametersProvider')] + public function testPerformRejectsInvalidOrIncompleteParameters(array $parameters): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(['de'])->perform($parameters); + } + + /** + * A missing or null usr_id, given otherwise-valid translations, must succeed - not throw (see + * perform()'s own docblock: only a non-null usr_id of the wrong type is rejected). + */ + public static function validUsrIdMissingOrNullWithValidTranslationsProvider(): array + { + return [ + 'missing usr_id' => [ + ['module' => 'common', 'identifier' => 'foo', 'translations' => ['de' => 'Hallo', 'en' => 'Hello']], + ], + 'null usr_id' => [ + [ + 'module' => 'common', + 'identifier' => 'foo', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + 'usr_id' => null, + ], + ], + ]; + } + + #[DataProvider('validUsrIdMissingOrNullWithValidTranslationsProvider')] + public function testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations(array $parameters): void + { + $activity = $this->createActivity(['de', 'en']); + + $result = $activity->perform($parameters); + + $this->assertSame('common', $result['module']); + $this->assertSame('foo', $result['identifier']); + $this->assertSame(['de', 'en'], $result['added_language_keys']); + } + + public function testPerformRejectsNonArrayParameters(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(['de'])->perform('not-an-array'); + } + + // ----------------------------------------------------------------- + // Snapshot drift between getInputDescription() and perform() - see + // getInputDescription()'s own docblock. + // ----------------------------------------------------------------- + + /** + * The fake repository below returns different installed-language lists on its 1st + * (getInputDescription()) and 2nd (perform()) call, modeling this snapshot drift within one + * maybePerformAs() call. + */ + 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, so no + // field/value for it exists in the form. + // 2nd call: perform() - 'fr' has since been installed. + return $calls === 1 ? ['de', 'en'] : ['de', 'en', 'fr']; + }); + + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + // No 'fr' key at all - getInputDescription() (1st call) never + // asked for one, since it did not yet know '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 branch: "en" becomes mandatory only after getInputDescription() already built + // the form without asking for it - the whole request must be rejected, not partially skipped. + 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: getInputDescription() - only "de" installed, "en" not + // yet, so no field/value for "en" exists in the form. + // 2nd call: perform() - "en" has since been installed. + return $calls === 1 ? ['de'] : ['de', 'en']; + }); + + $calls_to_write_closures = []; + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository, + replace_lang_entry: $this->spyReplaceLangEntry($calls_to_write_closures), + update_module_cache: $this->spyUpdateModuleCache($calls_to_write_closures) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + // No 'en' key - getInputDescription() (1st call) never asked for + // one, since "en" was not yet installed when the form was built. + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + // Only "en" is reported missing - "de" has a valid value, so the mandatory-value check + // doesn't flag it too. + $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); + } + + /** + * The opposite direction from the two tests above: an OPTIONAL language uninstalled between + * the two calls has a submitted value that is neither written nor rejected - it is silently + * dropped, unlike installing "de"/"en" (fail-closed) or installing an optional language + * (skipped normally). "fr" isolates this from the separate "de"/"en" mandatory rule tested + * elsewhere. + */ + 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: getInputDescription() - "fr" still installed, so a + // field/value for it exists in the form. + // 2nd call: perform() - "fr" has since been 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 valid value IS given for "fr" - getInputDescription() (1st + // call) still asked for it, since "fr" was installed at the time. + '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); + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + public function testPermissionDeniedBeforePerformNeverCallsWriteClosuresAndReturnsResultErrorWithNoPermMessage(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + // getInputDescription() (built for real via RealFieldsUiFactory) calls + // txt('meta_l_de') to label the 'de' translation field before + // isAllowedToPerform() is ever reached (grinding happens first) - the + // mock must tolerate that call too, not just 'msg_no_perm_write'. + $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->assertSame('no write permission', $result->error()); + $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 testThrowableFromWithinPerformIsTurnedIntoAResultError(): 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->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('database write failed', $error->getMessage()); + } + + /** + * AddLanguageEntry inherits LanguageActivity::maybePerformAs()'s catch block unchanged; this + * exercises it with a real \TypeError (not an \Exception) from within perform() - `new + * Result\Error($e)` only accepts string|\Exception, so it must arrive wrapped in a + * \RuntimeException rather than crash maybePerformAs() itself. + */ + public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInARuntimeExceptionResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $replace_lang_entry = static function (): bool { + throw new \TypeError('simulated TypeError, not an \Exception'); + }; + + $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->assertInstanceOf(\RuntimeException::class, $error); + $this->assertNotInstanceOf(\TypeError::class, $error); + $this->assertSame('simulated TypeError, not an \Exception', $error->getMessage()); + $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); + } + + /** + * Same inherited try/catch (LanguageActivity::maybePerformAs()) as above, but triggered via + * AddLanguageEntry's real getInputDescription() - here failing inside + * `$this->lng->txt('meta_l_' . $lang_key)`. The rbac system must never be reached, since the + * failure happens before grinding/permission-checking. + */ + public function testExceptionWhileBuildingTheInputDescriptionItselfIsTurnedIntoAResultErrorNotPropagated(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $language = $this->createMock(Language::class); + $language->method('txt')->willThrowException( + new \RuntimeException('simulated failure while building the translation field label') + ); + + $activity = $this->createActivity(['de'], rbac: $rbac, language: $language); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('simulated failure while building the translation field label', $error->getMessage()); + } + + // A missing 'module' fails the required Text field inside grind() itself, before + // normalizeParameters()/isAllowedToPerform() run - 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 (unknown keys tolerated - see GrindsFormInput), a nested group like + // 'translations' rejects an unknown key outright, before isAllowedToPerform() runs. + 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', + // Not an installed language, i.e. not a field of the + // 'translations' group - must be rejected outright. + 'xx' => 'whatever', + ], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $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()); + } + + /** + * Both rejected by grind() itself, before normalizeParameters()/isAllowedToPerform() run, but + * for different reasons: a non-array 'translations' fails collectRawValues()'s type guard on + * the nested group; a non-string key can never match any of the group's string-keyed fields. + * Either way: an InvalidInputException, not an InvalidArgumentException. + */ + 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 [ + // 'de' is given a valid, non-blank value here on purpose, so + // that grind() itself succeeds and the blank module/identifier + // is instead caught by normalizeParameters()'s + // toNonEmptyString() - an InvalidInputException, reached + // before isAllowedToPerform(). + 'blank module' => [['module' => ' ', 'identifier' => 'foo', 'translations' => ['de' => 'Hallo']]], + 'blank identifier' => [['module' => 'common', 'identifier' => ' ', 'translations' => ['de' => 'Hallo']]], + // Outside the Text field's own type check - grind() itself + // converts the UI framework's own blank \InvalidArgumentException + // ("Display value does not match input type.") into an + // InvalidInputException (see grind()'s try/catch). + '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->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + 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 a legal input (e.g. a caller merely + * reserving the module/identifier pair without giving any value yet) - + * every installed language is then reported 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']); + } + + // ----------------------------------------------------------------- + // getInputDescription() + // ----------------------------------------------------------------- + + /** + * End-to-end companion to ActivityContractTestCase's reflection-based signature pin: actually + * defines a subclass overriding getInputDescription() with the plain interface signature, + * reproducing the PHP fatal error the old, buggy signature triggered (verified under PHP + * 8.5.4). A PHP variance fatal is a compile-time error, not a catchable \Throwable, hence + * #[RunInSeparateProcess] - so a regression fails this one test instead of crashing the suite. + */ + #[RunInSeparateProcess] + public function testSubclassOverridingGetInputDescriptionWithThePlainInterfaceSignatureDoesNotTriggerAFatalError(): void + { + // txt() must be stubbed (getInputDescription() calls it per translation field) - a bare + // mock would trigger PHPUnit's "no expectations configured" notice. createActivity() can't + // be reused here since the point of this test is the inline `extends AddLanguageEntry` + // class declaration. + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnCallback(static fn(string $k): string => $k); + + $activity = new class ( + refinery: $this->createMock(RefineryFactory::class), + language: $language, + rbac_system: $this->createMock(\ilRbacSystem::class), + installed_language_repository: new FakeInstalledLanguageRepository(static fn(): array => ['de']), + db: static fn(): \ilDBInterface => throw new \LogicException('db must not be resolved by this test'), + ) extends AddLanguageEntry { + // Declaring this override (the plain interface signature) is the point of the test - + // the old, buggy signature with an extra optional parameter would already fail to + // compile here. + 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->method('withRequired')->with(true)->willReturnSelf(); + $module_text->method('withDedicatedName')->with('module')->willReturnSelf(); + + $identifier_text = $this->createMock(Text::class); + $identifier_text->method('withRequired')->with(true)->willReturnSelf(); + $identifier_text->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->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->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->method('withDedicatedName')->with('translations')->willReturnSelf(); + + $outer_group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::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, + $outer_group + ) { + $group_calls[] = $fields; + if ($fields === ['de' => $de_text, 'fr' => $fr_text]) { + return $translations_group; + } + if ($fields === ['module' => $module_text, 'identifier' => $identifier_text, 'translations' => $translations_group]) { + return $outer_group; + } + throw new \LogicException('unexpected group() 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_group, $activity->getInputDescription($field)); + $this->assertSame(['Module', 'Identifier', 'meta_l_de', 'meta_l_fr'], $text_calls); + // Only 'de' (and 'en', not present here) is required among translations. + $this->assertTrue($de_required); + $this->assertFalse($fr_required); + } + + // ----------------------------------------------------------------- + // getOutputDescription() + // ----------------------------------------------------------------- + + public function testOutputDescriptionDeclaresTheExpectedFourFields(): void + { + $string_group = $this->createMock(StringGroup::class); + $string_group->method('markdown')->willReturn( + $this->createMock(MarkdownFormattingToHTML::class) + ); + $refinery = $this->createMock(RefineryFactory::class); + $refinery->method('string')->willReturn($string_group); + + $activity = $this->createActivity(['de'], refinery: $refinery); + + $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 + ); + } + + // ----------------------------------------------------------------- + // $db / default update_module_cache() guard clauses + // + // Every other test overrides update_module_cache with a no-op/spy, so the constructor's own + // $db-backed default (querying lng_modules) is otherwise never exercised. These tests inject a + // real \ilDBInterface mock to pin down the is_string() guard around unserialize(). Only "de" + // is installed, just enough to reach update_module_cache() once without ever reaching the real + // \ilObjLanguage::replaceLangModule() write. + // ----------------------------------------------------------------- + + private function createActivityWithRealUpdateModuleCacheDefault(\ilDBInterface $db): AddLanguageEntry + { + return new AddLanguageEntry( + refinery: $this->createMock(RefineryFactory::class), + language: $this->createMock(Language::class), + rbac_system: $this->createMock(\ilRbacSystem::class), + installed_language_repository: new FakeInstalledLanguageRepository(static fn(): array => ['de']), + language_folder_ref_id: 0, + replace_lang_entry: static fn( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ): bool => true, + // $update_module_cache deliberately left null - the whole point + // of these tests is exercising the constructor's own default. + update_module_cache: null, + user_login: static fn(int $usr_id): string => 'default-login', + db: $db, + ); + } + + private function mockDbFetchingRow(?array $row): \ilDBInterface + { + $statement = $this->createMock(\ilDBStatement::class); + + $db = $this->createMock(\ilDBInterface::class); + $db->method('quote')->willReturnCallback( + static fn(mixed $value, string $type): string => "'" . (string) $value . "'" + ); + $db->method('query')->willReturn($statement); + $db->method('fetchAssoc')->with($statement)->willReturn($row); + + return $db; + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenNoLngModulesRowIsFound(): void + { + $db = $this->mockDbFetchingRow(null); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + // A missing row is normal and silently ignored - no exception must surface. + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenLangArrayColumnIsNull(): void + { + // Without the is_string() guard, unserialize(null, ...) throws a \TypeError under + // strict_types - this pins down the guard that prevents that. + $db = $this->mockDbFetchingRow(['lang_array' => null]); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenLangArrayColumnIsNotAString(): void + { + $db = $this->mockDbFetchingRow(['lang_array' => 42]); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenLangArrayColumnDeserializesToANonArray(): void + { + // A syntactically valid serialized string that decodes to something + // other than an array (e.g. a plain scalar) leaves nothing to merge + // the new entry into - silently do nothing, same as a missing row. + $db = $this->mockDbFetchingRow(['lang_array' => serialize('not-an-array')]); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + // ----------------------------------------------------------------- + // Test helpers + // ----------------------------------------------------------------- + + /** + * @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, + \ilDBInterface|\Closure|null $db = null, + int $language_folder_ref_id = 0, + ?\Closure $replace_lang_entry = null, + ?\Closure $update_module_cache = null, + ?\Closure $user_login = null, + ?RefineryFactory $refinery = null, + ): AddLanguageEntry { + // Safe no-op defaults (never the real \ilObjLanguage-backed closures, which need global + // $DIC). user_login() runs unconditionally in perform(), so it always needs one too. + // Tests that must prove a closure is never called pass their own throwing closure instead. + $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'; + // $db defaults to a throwing stub: every test above overrides update_module_cache, so the + // constructor's real $db-based default should never be reached - a test that accidentally + // relies on it fails loudly instead of silently passing. + $db ??= static fn(): \ilDBInterface => throw new \LogicException( + 'db must not be resolved when update_module_cache is overridden' + ); + + return new AddLanguageEntry( + refinery: $refinery ?? $this->createMock(RefineryFactory::class), + language: $language ?? $this->createMock(Language::class), + rbac_system: $rbac ?? $this->createMock(\ilRbacSystem::class), + installed_language_repository: $installed_language_repository ?? new FakeInstalledLanguageRepository( + static fn(): array => $installed_languages + ), + language_folder_ref_id: $language_folder_ref_id, + replace_lang_entry: $replace_lang_entry, + update_module_cache: $update_module_cache, + user_login: $user_login, + db: $db, + ); + } +} + +/** + * Minimal in-memory stand-in for InstalledLanguageRepository: + * InstalledLanguageDatabaseRepository (the real implementation) needs an + * actual database connection, which unit tests must never construct. Only + * getInstalledLanguages() is exercised by AddLanguageEntry; every other + * method is unreachable from it and simply throws if a test ever calls it by + * mistake. + */ +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/GrindsFormInputTest.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php new file mode 100644 index 000000000000..3152db8ca176 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php @@ -0,0 +1,300 @@ +createMock(FormInput::class); + + $result = $this->host()->callGrind($description, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(\LogicException::class, $result->error()); + } + + 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], + ]; + } + + // Uses a REAL Checkbox field (not a mock) to also cover 'checked'/'on', the two raw values an + // actual 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']); + } + + /** + * Every field this trait grinds is nested inside a top-level Group in real Activities - + * collectRawValues() only narrows $raw_value to one key when recursing into a Group's + * children, so a bare top-level field would wrongly receive the whole $raw_parameters array + * as its own value. + */ + 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'], + ]; + } + + /** + * Anything outside the explicit whitelist must be rejected, not guessed at - grind()'s + * dedicated catch(\InvalidArgumentException) (before the generic catch(\Throwable)) must turn + * this into an InvalidInputException (a SafeToDisplayActivityError), which + * RendersActivityErrors relies on to show the rejection to the end user directly rather than + * logging it as an internal failure. + */ + #[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->assertInstanceOf(SafeToDisplayActivityError::class, $error); + } + + /** + * Same \InvalidArgumentException -> InvalidInputException conversion, but reached via the UI + * framework's own Text::isClientSideValueOk() type check instead of + * normalizeCheckboxRawValue(). + */ + 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 neither a string nor an array - collectRawValues() + // passes it straight through unmodified (only array/Checkbox raw + // values are special-cased), so it 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->assertInstanceOf(SafeToDisplayActivityError::class, $error); + } + + /** + * A validation failure must produce ": " (via describeInputError()/ + * collectFieldErrors()), identifying the field by its dedicated name - not the generic, + * uninformative "ui_error_in_group" text Group::withInput() would otherwise surface. + */ + 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, + ]); + + // Blank/missing raw value for a required field - fails its own + // hasMinLength(1) constraint inside grind() itself. + $result = $this->host()->callGrind($description, []); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $this->assertStringStartsWith('language_key: ', $error->getMessage()); + $this->assertNotSame('language_key: ', $error->getMessage()); + } + + /** + * A raw array-of-strings value (e.g. the shape class.ilObjLanguageFolderGUI.php sends for + * 'language_keys') must be joined into the same comma-separated string a real HTML text input + * would carry, before it reaches the field. + */ + 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']); + } + + /** + * Regression test for the outermost group's $enforce_known_keys=false default (see + * collectRawValues()): an unrelated key elsewhere in top-level $raw_parameters must be + * ignored, not rejected. + */ + 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', + // Not a field of the outermost group - must be tolerated, not rejected. + 'some_unrelated_form_field' => 'bar', + ]); + + $this->assertTrue($result->isOk()); + $this->assertSame('Foo', $result->value()['name']); + } + + /** + * The unknown-key message must embed the key RAW/unescaped - escaping is + * RendersActivityErrors::activityErrorMessage()'s job, the single rendering seam; escaping + * here too would double-escape it. + */ + 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->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $this->assertStringContainsString('Unknown key(s) for translations: ' . $dangerous_unknown_key, $error->getMessage()); + // Not escaped here - htmlspecialchars() would turn '<'/'>' into + // '<'/'>', which must NOT happen at this layer. + $this->assertStringContainsString('', $error->getMessage()); + } + + public static function nonArrayNonNullGroupRawValueProvider(): array + { + return [ + 'string' => ['not-an-array'], + 'int' => [42], + 'bool' => [true], + ]; + } + + /** + * A group's raw value that is neither null (omitted) nor an array is a genuine type mismatch + * and must be rejected - silently degrading it to an empty array would instead make every + * child field look merely "omitted", masking the real problem behind 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->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $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..7bdd3dc7ca12 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php @@ -0,0 +1,40 @@ +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..26fdb895fe18 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -0,0 +1,705 @@ +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 + { + // Even a language that was not installed before must be classified + // by "has a customizing/local file", not "was newly installed" - a + // fresh language that already ships a local file is still more + // accurately described as "installed with custom file" than as a + // plain install. + $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']); + } + + /** + * This is the central bugfix regression test: mode "install" used to + * unconditionally (re-)install and (re-)apply the customizing/local file + * of an already installed language on every call. It must now be a + * complete no-op instead - not even checkLanguageForInstallation may run + * for it, since (re-)applying a customizing file is exclusively mode + * "install_local"'s job now. + */ + 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'); + // The object_data bookkeeping itself is delegated to + // ilSetupLanguage::registerInstalledLanguage() (shared with + // installLanguages()) - see ilSetupLanguageTest for coverage of the + // actual INSERT/UPDATE SQL it builds. + $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" still runs its write methods for an already installed + * language even when no customizing/local file actually exists for it + * (there is simply nothing for them to write) - but the *result* must + * report this as "already installed", not as "installed with local + * file", since nothing observable actually 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 at all must be a + * complete no-op - there is nothing installed yet to apply local + * changes on top of. + */ + 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']); + } + + /** + * When every requested language turns out to be a no-op (given the + * chosen mode and each language's install status), perform() must not + * even read the available/local languages or scan for invalid local + * files - there is nothing left to do that would need them. + */ + 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']); + } + + /** + * A single request can carry languages in every possible state at once; + * each must land in its own bucket, and only the ones actually affected + * may trigger any write method - under 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') + // Only the two languages actually being (fully) installed are + // relevant - 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']); + } + + /** + * The same idea, but under mode "install_local": no key may ever trigger + * checkLanguageForInstallation/flushLanguageForInstallation/ + * insertLanguageForInstallation - only insertLanguageForApplyingLocalChanges, + * and only for the 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') + // Only the two already-installed languages are affected - 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(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_keys' => $text, 'mode' => $select]) + ->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], + ], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + '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); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see InstallLanguage::perform()): a non-array $parameters must now + * raise the concrete InvalidInputException - not just the more general + * \InvalidArgumentException it extends. + */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform('not-an-array'); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind() (via its + * private joinListOfStringsRawValue() helper - see that method's own + * docblock) must join that array into the same shape a real HTML text + * input would carry BEFORE + * it reaches the declared Text field, rather than rejecting it. This + * is exercised through the REAL getInputDescription()/grind() pipeline + * (createRealFieldsUiFactory(), not a mocked FormInput) via + * maybePerformAs() - the previously blocking regression this test + * guards against. + */ + 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'); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $setup_language, + $rbac, + $language + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de', 'mode' => InstallLanguage::MODE_INSTALL]); + + $this->assertTrue($result->isError()); + } + + /** + * Regression test for LanguageActivity::maybePerformAs()'s generic + * catch(\Throwable) block (InstallLanguage itself does not override + * maybePerformAs() - this exercises the base class implementation): + * a \Throwable that is NOT an \Exception (here a \TypeError, the other + * half of the \Throwable hierarchy) raised from within perform() must + * still come back as a Result\Error carrying an \Exception instance + * (here a \RuntimeException wrapping the original \TypeError as its + * "previous"), per the Activity::maybePerformAs() contract ("Wraps the + * result and possible errors in the Result type"). Before the fix, + * `new Result\Error($e)` itself raised an uncaught + * \InvalidArgumentException for such a $e (Result\Error::__construct() + * only accepts string|\Exception), so maybePerformAs() aborted instead + * of returning a Result at all. + */ + public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInARuntimeExceptionResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $setup_language = $this->createSetupLanguageMock([], [], []); + // checkLanguageForInstallation() is called from within perform()'s + // own loop, well after isAllowedToPerform() succeeded - a realistic + // place for an unexpected \TypeError to surface (e.g. a + // misconfigured/incompatible collaborator), simulated here directly. + $setup_language->method('checkLanguageForInstallation')->willReturnCallback( + static function (): bool { + throw new \TypeError('simulated TypeError, not an \Exception'); + } + ); + + $result = $this->createActivity($setup_language, $rbac)->maybePerformAs( + $this->createRealFieldsUiFactory()->input(), + 6, + ['language_keys' => ['de'], 'mode' => InstallLanguage::MODE_INSTALL] + ); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertNotInstanceOf(\TypeError::class, $error); + $this->assertSame('simulated TypeError, not an \Exception', $error->getMessage()); + $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); + } + + private function createActivity( + ilSetupLanguage $setup_language, + ?\ilRbacSystem $rbac = null, + ?Language $language = null + ): InstallLanguage { + return new InstallLanguage( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + $setup_language + ); + } + + 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/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php new file mode 100644 index 000000000000..f56dc2f121e3 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -0,0 +1,220 @@ +createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = new LanguageActivityThrowingInputDescriptionTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $rbac + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('simulated failure while building the input description', $error->getMessage()); + } + + /** + * Same scenario, but the \Throwable is not an \Exception (a \TypeError) - `new + * Result\Error($e)` only accepts string|\Exception, so it must arrive wrapped in a + * \RuntimeException instead of raising an uncaught \InvalidArgumentException. + */ + public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntimeExceptionResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = new LanguageActivityThrowingInputDescriptionTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $rbac, + throw_type_error: true, + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertNotInstanceOf(\TypeError::class, $error); + $this->assertSame('simulated TypeError while building the input description', $error->getMessage()); + $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); + } + + /** + * Regression test for maybePerformAs()'s array_merge() precedence: additionalPerformParameters() + * must win over a same-named key from normalizeParameters(). Uses a dedicated LanguageActivity + * double whose normalizeParameters() always returns a colliding 'usr_id', since + * AddLanguageEntry's own getInputDescription() never collects a 'usr_id' field and so never + * actually reaches this collision. + */ + public function testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = new LanguageActivityMergePrecedenceTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $rbac + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 42, []); + + $this->assertFalse($result->isError()); + $parameters = $result->value(); + // The trusted, server-resolved usr_id (42, from maybePerformAs()'s own + // argument via additionalPerformParameters()) must win over the + // spoofed value normalizeParameters() produced from (simulated) form + // input. + $this->assertSame(42, $parameters['usr_id']); + // A non-colliding key from normalizeParameters() must still survive + // the merge untouched. + $this->assertSame('form', $parameters['source']); + } +} + +/** + * Minimal concrete LanguageActivity whose getInputDescription() always + * throws, used exclusively to exercise LanguageActivity::maybePerformAs()'s + * generic try/catch around getInputDescription()/$input_factory->field() in + * isolation - every other abstract method is unreachable from + * maybePerformAs() once getInputDescription() throws, and simply throws + * itself if a test ever calls it by mistake. + */ +final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity +{ + public function __construct( + RefineryFactory $refinery, + Language $language, + \ilRbacSystem|\Closure $rbac_system, + private readonly bool $throw_type_error = false, + ) { + parent::__construct($refinery, $language, $rbac_system); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown('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.'); + } +} + +/** + * Minimal LanguageActivity used only by + * testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): + * normalizeParameters() always returns a colliding 'usr_id', simulating spoofed form input. + * getInputDescription() returns an empty group so grind() trivially succeeds; perform() returns + * $parameters unchanged so the test can inspect what survived the merge. + */ +final class LanguageActivityMergePrecedenceTestDouble extends LanguageActivity +{ + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown('Test double for maybePerformAs() array_merge() precedence.'); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + return $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 + { + // Simulates a spoofed 'usr_id' smuggled in through form input - must + // never win over additionalPerformParameters()'s trusted value below. + return ['usr_id' => 'SPOOFED-VALUE', 'source' => 'form']; + } + + /** + * @return array{usr_id: int} + */ + protected function additionalPerformParameters(int $usr_id): array + { + return ['usr_id' => $usr_id]; + } +} diff --git a/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php new file mode 100644 index 000000000000..589043c09d58 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php @@ -0,0 +1,94 @@ +input()` below) whose + * `->field()->text()/checkbox()/select()/group()` return REAL, functioning + * UI framework Field objects - i.e. one that GrindsFormInput::grind() (see + * the trait's own docblock) can actually run + * `withNameFrom()`/`withInput()`/`getContent()` against, instead of a plain + * PHPUnit mock, which would either return null or a broken mock object for + * these calls (and grind()'s own try/catch would silently turn every such + * call into a Result\Error). + * + * `maybePerformAs()` (see LanguageActivity/every concrete Activity) only + * ever needs `\ILIAS\UI\Component\Input\Factory` - it calls + * `$input_factory->field()` directly and never touches any other + * `\ILIAS\UI\Factory` method - so every call site here only ever chains + * `->input()` off the returned factory before using it. The helper still + * returns the full, enclosing `\ILIAS\UI\Factory` (rather than the narrower + * `\ILIAS\UI\Component\Input\Factory` directly) purely because that is the + * natural return type of the mocked `->input()` accessor below; only + * `\ILIAS\UI\Factory::input()` and `\ILIAS\UI\Component\Input\Factory::field()` + * are ever called by these tests, so those two collaborators are plain + * PHPUnit mocks stubbed to return the real, concrete field factory below - + * see CommonFieldRendering::getFieldFactory() in + * components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php + * for the same, established recipe. + */ +trait RealFieldsUiFactory +{ + /** + * Builds a `\ILIAS\UI\Factory` mock whose `input()` is a plain mock + * returning a REAL `\ILIAS\UI\Implementation\Component\Input\Field\Factory` + * from `field()` (the only two calls any Activity's `maybePerformAs()`/ + * `getInputDescription()` ever makes on it), wired against a real + * `\ILIAS\Data\Factory`/`\ILIAS\Refinery\Factory` pair and a + * `\ILIAS\Language\Language` mock whose `txt()` simply echoes back the + * key it was given. Callers invariably chain `->input()` on the result + * to obtain the `\ILIAS\UI\Component\Input\Factory` that `maybePerformAs()` + * actually declares as its parameter type. + */ + 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..60965d40f6aa --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -0,0 +1,763 @@ +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; each entry becomes a fake object with + * the given isInstalled() answer (default true) and + * removeLocalChanges() return value (default true) reachable + * from the corresponding obj_id (assigned in iteration order, + * starting at 1). + * @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]; + } + + // ----------------------------------------------------------------- + // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" + // objects sharing the same title must reject the whole request for + // that title rather than silently resolving to whichever one happened + // to be enumerated last - see UninstallLanguage's identical guard and + // UninstallLanguageTest's equivalent tests for the same reasoning. + // ----------------------------------------------------------------- + + 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); + } + } + + /** + * Regression test: the AmbiguousLanguageTitleException message embeds + * the ambiguous title RAW/unescaped - escaping HTML-significant + * characters for safe display is deliberately no longer this domain + * layer's job (see RemoveLocalLanguageChanges::perform()'s own comment + * above the throw), only \ILIAS\Language\RendersActivityErrors:: + * activityErrorMessage()'s. If this message were pre-escaped here, it + * would end up double-escaped once it reaches that single seam. + * + * NOTE (found while adding format validation coverage for + * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used + * an HTML/XSS payload ('&"quoted"') as BOTH the + * ambiguous objects' title AND the requested 'language_keys' value - + * $language_keys must equal an object's title verbatim for + * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted + * (perform() intersects the requested, already-parsed $language_keys + * against that map's keys). Since toLanguageKeyList() now rejects any + * value that is not exactly two lowercase ASCII letters BEFORE that + * intersection is even computed, such a value now throws + * InvalidInputException immediately and never reaches + * AmbiguousLanguageTitleException at all - this test would otherwise + * fail with that InvalidInputException instead of the expected + * AmbiguousLanguageTitleException. The dangerous-title scenario is + * therefore no longer reachable through perform()'s public parameter + * surface at all (a real, if incidental, hardening side effect of the + * new format check) - only a title that happens to already be a + * plausible two-letter language key can ever become "ambiguous" via a + * real request. The title/key below is changed accordingly; the + * "no HTML-escaping happens" assertions are kept for continuity, even + * though a two-letter title trivially satisfies them. + */ + 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 same ambiguous-title \RuntimeException must + * surface as a Result\Error, not propagate as an uncaught exception - + * and, again, the object factory must never be reached for it. + */ + 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 must not poison unrelated, unambiguous titles in + * the same $lng_objects() world/request - a genuinely unique language + * key coexisting with an ambiguous one must still resolve and have its + * local changes removed normally. + */ + 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()); + } + + /** + * Regression test for the ambiguity check being moved OUT of the + * per-key foreach loop and performed for ALL requested keys BEFORE any + * removeLocalChanges() call (see the comment directly above that check + * in perform(): "Checked upfront, for all requested keys at once, so a + * request naming both an unambiguous and an ambiguous key never + * changes the unambiguous one before rejecting the whole call."). A + * request naming an unambiguous key ('de') FIRST and an ambiguous one + * ('fr') SECOND must reject the whole request and must NOT have already + * changed 'de' by the time 'fr' is reached - before this fix, the + * ambiguity check ran inline inside the loop, so 'de' (processed first) + * would already have had its local changes removed for real before the + * loop reached the ambiguous 'fr' and threw. + */ + 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' listed BEFORE the ambiguous 'fr' on purpose - see the + // "Checked upfront, for all requested keys at once" comment + // above the ambiguity check in perform(). + $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 to a factory call, let alone changed - + // the ambiguity check ran BEFORE the per-key loop. + $this->assertSame([], $factory_calls); + $this->assertSame(0, $de->removeLocalChangesCallCount()); + } + } + + /** + * Same regression, exercised end-to-end through maybePerformAs(): the + * Result\Error must surface without 'de' having had its local changes + * removed. + */ + 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()); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + 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, + null, + 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() must be a pure permission check: it must never + * touch $lng_objects or $obj_language_factory (and therefore never call + * removeLocalChanges()) - unlike perform(), which is the only place + * allowed to have side effects. + */ + 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'])); + } + + // ----------------------------------------------------------------- + // perform() + // ----------------------------------------------------------------- + + 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()); + } + + /** + * Regression test: removeLocalChanges() itself is the authority on + * whether the language file was valid, not merely the outer + * isInstalled() guard. A language that passes isInstalled() (so + * perform() does call removeLocalChanges()) but whose + * removeLocalChanges() nonetheless returns false (invalid language + * file) must be reported as invalid_language_file_keys, never as + * removed_local_changes_language_keys or not_installed_language_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']); + // removeLocalChanges() must still have been attempted. + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + /** + * A language key with no corresponding "lng" object at all must be + * reported as not installed - and, crucially, $obj_language_factory + * must never even be invoked for it (there is no obj_id to construct it + * from in the first place). + */ + 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); + } + + /** + * A single request can carry languages in every possible state at once; + * each must land in its own bucket, and removeLocalChanges() must only + * ever be invoked for the two genuinely installed languages (regardless + * of whether their file turns out valid or not) - never for the + * not-installed or unknown 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([ + // 'xx' has no backing object at all. + '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]], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + '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); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see RemoveLocalLanguageChanges::perform(), ~line 143): this check was + * switched from a plain \InvalidArgumentException to the concrete + * InvalidInputException, to be consistent with AddLanguageEntry, + * SetLanguageDetectionEnabled and SetLanguageTranslationEnabled, which + * already used it. The docblock/comment that used to justify the + * opposite ("deliberately NOT an InvalidInputException") is stale and + * has been removed - this test now pins the CURRENT, more specific + * exception class, not just \InvalidArgumentException (which + * InvalidInputException extends, and against which this assertion would + * therefore have kept passing silently even after the behaviour change - + * see RendersActivityErrors, which treats InvalidInputException/ + * SafeToDisplayActivityError specially). + */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform('not-an-array'); + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + /** + * Regression test analogous to + * UninstallLanguageTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall(): + * a denied permission must short-circuit before perform() ever runs - + * $lng_objects/$obj_language_factory (and therefore removeLocalChanges()) + * must never be 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' => [], + ]); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac, + $language + )->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()); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind() (via its + * private joinListOfStringsRawValue() helper - see that method's own + * docblock) must join that array into the same shape a real HTML text + * input would carry BEFORE + * it reaches the declared Text field, rather than rejecting it. This + * is exercised through the REAL getInputDescription()/grind() pipeline + * (createRealFieldsUiFactory(), not a mocked FormInput) via + * maybePerformAs() - the previously blocking regression this test + * guards against. + */ + 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 raised from within perform() (e.g. a validation failure + * on the normalized parameters, or any other unexpected failure) must + * be turned into a Result\Error rather than propagating - this is the + * whole point of maybePerformAs() wrapping perform() in a try/catch. + */ + 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 normalizes to [] and throws inside + // perform() (via toLanguageKeyList()) - reachable only after the + // permission check has already passed, exercising the catch block. + $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->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * A completely missing 'language_keys' key is now caught by grind() + * itself (see GrindsFormInput): the required Text field receives a + * blank raw value and fails its own required-field constraint before + * normalizeParameters()/isAllowedToPerform() are ever reached - so the + * rejection surfaces as a Result\Error carrying the UI field's own + * (string) validation message, not an InvalidArgumentException thrown + * by normalizeParameters(). Either way, the rbac system must never be + * touched (validation happens first). + */ + 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, + ?Language $language = null, + int $language_folder_ref_id = 0 + ): RemoveLocalLanguageChanges { + return new RemoveLocalLanguageChanges( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + $language_folder_ref_id, + $lng_objects, + $obj_language_factory + ); + } +} + +/** + * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or + * meaningfully mocked in a unit test (its constructor requires the global + * $DIC), so tests substitute this fake via RemoveLocalLanguageChanges's + * $obj_language_factory closure instead. Counts removeLocalChanges() + * invocations so tests can assert it was never called for a bucket other + * than "removed"/"invalid". + */ +final class FakeRemoveLocalLanguageChangesObject +{ + private int $remove_local_changes_calls = 0; + + /** + * @param bool $remove_local_changes_return_value What removeLocalChanges() + * returns - true mimics a real, successful removal of local + * changes; false mimics \ilObjLanguage::removeLocalChanges() + * rejecting the call because the underlying language file failed + * check() (isInstalled() having already let it through). + */ + 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/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php new file mode 100644 index 000000000000..c2f4abfaca69 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -0,0 +1,445 @@ +createActivity(); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + 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() must be a pure permission check: it must never + * touch the Setting collaborator (and therefore never write + * "lang_detection") - unlike perform(), which is the only place + * allowed to have side effects. + */ + public function testIsAllowedToPerformNeverTouchesSettings(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, settings: $settings); + + $this->assertTrue($activity->isAllowedToPerform(6, ['enabled' => true])); + } + + // ----------------------------------------------------------------- + // perform() + // ----------------------------------------------------------------- + + public function testPerformWithEnabledTrueWritesOneIntoLangDetectionSettingAndReturnsEnabledTrue(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->once()) + ->method('set') + ->with('lang_detection', '1'); + + $result = $this->createActivity(settings: $settings)->perform(['enabled' => true]); + + $this->assertSame(['enabled' => true], $result); + } + + public function testPerformWithEnabledFalseWritesZeroIntoLangDetectionSettingAndReturnsEnabledFalse(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->once()) + ->method('set') + ->with('lang_detection', '0'); + + $result = $this->createActivity(settings: $settings)->perform(['enabled' => false]); + + $this->assertSame(['enabled' => false], $result); + } + + public function testPerformRejectsMissingParametersArray(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->perform([]); + } + + public function testPerformRejectsNonArrayParameters(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->perform('not-an-array'); + } + + /** + * Boundary/type-juggling regression: perform() must require a strict + * bool for 'enabled' and must never silently cast a truthy/falsy + * look-alike (string "1"/"0"/"true", int 1, etc.) - unlike PHP's own + * loose comparison rules, which would happily accept any of these. + */ + #[DataProvider('nonBooleanEnabledValuesProvider')] + public function testPerformRejectsNonStrictBooleanEnabledValues(mixed $value): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->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]], + ]; + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => true], $result->value()); + } + + /** + * Core security regression test: before this extraction, + * enableLanguageDetectionObject()/disableLanguageDetectionObject() + * performed no RBAC check of their own at all - a request forged + * directly against the corresponding ilCtrl + * commands by a user with only "read" access on the language folder + * would still have flipped "lang_detection". isAllowedToPerform() now + * enforces a "write" check, and this must actually cause + * maybePerformAs() to reject the request (Result\Error) *and* leave the + * Setting collaborator completely untouched - a permission check that + * merely rejected the outcome without preventing the side effect would + * not close the gap at all. + */ + public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWritesTheSetting(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings, language: $language) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); + + $this->assertTrue($result->isError()); + } + + /** + * A Throwable raised from within perform() (or normalizeParameters()) + * must be turned into a Result\Error rather than propagating - this is + * the whole point of maybePerformAs() wrapping perform() in a + * try/catch. + */ + public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoAResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => 'not-a-bool']); + + $this->assertTrue($result->isError()); + // 'not-a-bool' is outside normalizeCheckboxRawValue()'s explicit + // whitelist - grind()'s dedicated catch block converts the + // resulting \InvalidArgumentException into an InvalidInputException + // (a SafeToDisplayActivityError), not a raw \InvalidArgumentException. + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * The 'enabled' Checkbox field is not required, so - now that + * maybePerformAs() actually grinds $raw_parameters through + * getInputDescription() (see GrindsFormInput) - a completely missing + * 'enabled' key is no longer rejected: it defaults to false, exactly + * like an unchecked, and therefore never submitted, HTML checkbox + * would. This is a deliberate behavioural change from the old + * (pre-grinding) normalizeParameters(), which used to reject a missing + * key outright. Unlike before, the request now actually reaches + * isAllowedToPerform()/perform(). + */ + public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings)->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], + ]; + } + + /** + * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): + * unlike perform() itself (which still requires a strict bool, see + * testPerformRejectsNonStrictBooleanEnabledValues), maybePerformAs() + * now accepts the common primitive representations of "true" a generic + * (non-HTML) caller might reasonably send, and writes the setting + * accordingly. + */ + #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsAndWritesEnabledTrue(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => true], $result->value()); + } + + /** + * Same tolerance as above, but for the common primitive representations + * of "false". + */ + #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveFalsyRepresentationsAndWritesEnabledFalse(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->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]], + ]; + } + + /** + * Only values outside GrindsFormInput::normalizeCheckboxRawValue()'s + * explicit whitelist are still rejected via maybePerformAs() - "konservativ + * normalisieren", not "alles akzeptieren". + */ + #[DataProvider('rejectedEnabledValuesProvider')] + public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWithoutTouchingSettings(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + // ----------------------------------------------------------------- + // getInputDescription() / getOutputDescription() + // ----------------------------------------------------------------- + + 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(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['enabled' => $checkbox]) + ->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)); + } + + // ----------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------- + + private function createActivity( + ?\ilRbacSystem $rbac_system = null, + ?Setting $settings = null, + ?Language $language = null, + int $language_folder_ref_id = 0 + ): SetLanguageDetectionEnabled { + return new SetLanguageDetectionEnabled( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac_system ?? $this->createMock(\ilRbacSystem::class), + $settings ?? $this->createMock(Setting::class), + $language_folder_ref_id + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php new file mode 100644 index 000000000000..0ed340c0067f --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -0,0 +1,718 @@ +", not a single fixed key), and perform() itself + * (not just normalizeParameters()) validates its $parameters array and + * computes a `changed` flag by comparing the requested value against the + * *currently stored* one (read via a falsy (bool) cast, exactly mirroring + * `ilObjLanguageAccess::_checkTranslate()` - see `$currently_enabled = (bool) + * ($this->settings)()->get($translate_key, '0')` in perform()). That + * comparison, and the "write only if changed" behaviour it drives, is the + * most important regression surface in this file: a mutant that always + * writes (or that inverts the comparison) must be caught here. + * + * The second most important regression this file guards is the same one as + * SetLanguageDetectionEnabledTest: the enforced "write" RBAC check in + * isAllowedToPerform()/maybePerformAs() must actually prevent both the + * outcome *and* the side effect (no write to Settings) when denied. + */ +class SetLanguageTranslationEnabledTest extends ActivityContractTestCase +{ + protected function createDefaultActivity(): SetLanguageTranslationEnabled + { + return $this->createActivity(); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + 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() must be a pure permission check: it must never + * touch the Setting collaborator (and therefore never read/write + * "lang_translate_*") - unlike perform(), which is the only place + * allowed to have side effects. + */ + public function testIsAllowedToPerformNeverTouchesSettings(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, settings: $settings); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); + } + + // ----------------------------------------------------------------- + // perform() - the changed/unchanged decision + // ----------------------------------------------------------------- + + /** + * Core business rule: perform() must read the + * *current* value of "lang_translate_" via a falsy check - exactly + * the same interpretation ilObjLanguageAccess::_checkTranslate() applies + * - and only write when the requested value actually differs. Every + * combination of a realistic stored raw value ("0", "", "1") and a + * requested boolean is exercised so that both an accidental "always + * write" and an accidental "never write" mutant are caught, along with + * an inverted `!==` -> `===` comparison mutant. + */ + public static function performChangedDecisionProvider(): array + { + return [ + 'stored "0", request true -> changed, write "1"' => ['0', true, true, '1'], + 'stored "0", request false -> unchanged, no write' => ['0', false, false, null], + 'stored "", request true -> changed, write "1" (falsy like "0")' => ['', true, true, '1'], + 'stored "", request false -> unchanged, no write (falsy like "0")' => ['', false, false, null], + 'stored "1", request true -> unchanged, no write' => ['1', true, false, null], + 'stored "1", request false -> changed, write "0"' => ['1', false, true, '0'], + ]; + } + + #[DataProvider('performChangedDecisionProvider')] + public function testPerformComparesRequestedValueAgainstFalsyCastOfCurrentlyStoredValue( + string $stored_value, + bool $requested_enabled, + bool $expected_changed, + ?string $expected_written_value + ): void { + $settings = $this->createMock(Setting::class); + $settings->method('get') + ->with('lang_translate_de', '0') + ->willReturn($stored_value); + + if ($expected_written_value === null) { + $settings->expects($this->never())->method('set'); + } else { + $settings->expects($this->once()) + ->method('set') + ->with('lang_translate_de', $expected_written_value); + } + + $result = $this->createActivity(settings: $settings) + ->perform(['language_key' => 'de', 'enabled' => $requested_enabled]); + + $this->assertSame( + ['language_key' => 'de', 'enabled' => $requested_enabled, 'changed' => $expected_changed], + $result + ); + } + + /** + * get() must be called with the correct per-language key + * ("lang_translate_" . language_key) and the documented default '0' - + * a mutant that drops the default, or concatenates the key in the wrong + * order/without the separator, must be caught here. + */ + public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->once()) + ->method('get') + ->with('lang_translate_fr', '0') + ->willReturn('0'); + + $this->createActivity(settings: $settings)->perform(['language_key' => 'fr', 'enabled' => false]); + } + + /** + * language_key must be trimmed before being used to build the setting + * key and before being returned - mirroring toNonEmptyString()'s + * behaviour used by normalizeParameters(), but exercised here directly + * through perform() since perform() re-implements its own trim(). + */ + public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue(): void + { + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(settings: $settings) + ->perform(['language_key' => ' de ', 'enabled' => true]); + + $this->assertSame('de', $result['language_key']); + } + + // ----------------------------------------------------------------- + // perform() - parameter validation + // ----------------------------------------------------------------- + + 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 + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->perform($parameters); + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->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() + ); + } + + /** + * Permission check regression: a denied "write" access must both yield + * a Result\Error *and* leave the Setting collaborator completely + * untouched (no read, no write) - a permission check that merely + * rejected the outcome without preventing the read/write would not be a + * real gate. + */ + public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouchesSettings(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings, language: $language) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertSame('no write permission', $result->error()); + } + + /** + * A Throwable raised from within perform() (e.g. the Setting + * collaborator failing to write) must be turned into a Result\Error + * rather than propagating - this is the whole point of maybePerformAs() + * wrapping perform() in a try/catch. + */ + public function testThrowableFromWithinPerformIsTurnedIntoAResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->willReturn('0'); + $settings->method('set')->willThrowException(new \RuntimeException('database write failed')); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('database write failed', $error->getMessage()); + } + + /** + * A completely missing 'language_key' key is caught by grind() itself + * (see GrindsFormInput): the required Text field receives a blank raw + * value and fails its own required-field constraint before + * normalizeParameters()/isAllowedToPerform() are ever reached - so the + * rejection surfaces as a Result\Error carrying a field-attributed + * InvalidInputException (see GrindsFormInput::describeInputError()), not + * an InvalidArgumentException. The rbac system must never be touched + * either way (validation happens first). + */ + 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 [ + // The required 'language_key' Text field receives a blank raw + // value (missing entirely) and fails its own required-field + // constraint inside grind() itself - before normalizeParameters()/ + // isAllowedToPerform() are ever reached. + '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'); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->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 own + // isClientSideValueOk() check inside grind() itself, which + // throws an InvalidArgumentException ("Display value does not + // match input type."), caught and preserved by grind()'s + // try/catch. + 'non-string language_key' => [['language_key' => 42, 'enabled' => true]], + // Outside GrindsFormInput::normalizeCheckboxRawValue()'s + // explicit whitelist - "konservativ normalisieren", not "alles + // akzeptieren". + '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'); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * The 'enabled' Checkbox field is not required, so a completely + * missing 'enabled' key is no longer rejected by grind() - it defaults + * to false, exactly like an unchecked, and therefore never submitted, + * HTML checkbox would (same behaviour as + * SetLanguageDetectionEnabledTest's equivalent regression test). + * Unlike a rejection at grind level, this reaches isAllowedToPerform()/ + * perform() and can still write the setting if that changes the + * currently stored value. + */ + public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('1'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->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 value satisfies the Text field's own + * required-check (min length 1 on the raw string, before trimming), so + * grind() accepts it and the request DOES reach isAllowedToPerform() - + * unlike the fully-missing-key case above. It is perform() itself + * (via its own trim() check, see + * testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSettings) + * that ultimately rejects it. + */ + public function testMaybePerformAsRejectsWhitespaceOnlyLanguageKeyAfterThePermissionCheck(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => ' ', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + 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], + ]; + } + + /** + * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): + * unlike perform() itself (which still requires a strict bool), + * maybePerformAs() now accepts the common primitive representations of + * "true" a generic (non-HTML) caller might reasonably send. + */ + #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresentations(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->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 tolerance as above, but for the common primitive representations + * of "false". + */ + #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresentations(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('1'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->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() + ); + } + + // ----------------------------------------------------------------- + // language_key must be an installed language (Punkt 4) + // ----------------------------------------------------------------- + + /** + * Regression test for Punkt 4 of this component's grinding task: unlike + * before, an unknown/not-installed language_key is now rejected by + * perform() itself, for direct perform()/isAllowedToPerform() callers + * too - not only via maybePerformAs(). + */ + public function testPerformRejectsUnknownLanguageKeyWithoutTouchingSettings(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings) + ->perform(['language_key' => 'xx', 'enabled' => true]); + } + + /** + * Regression test: the "Unknown language key" InvalidInputException + * message embeds the offending language_key RAW/unescaped - escaping + * HTML-significant characters for safe display is deliberately no + * longer this domain layer's job (see perform()'s own comment above the + * throw), only \ILIAS\Language\RendersActivityErrors:: + * activityErrorMessage()'s. If this message were pre-escaped here, it + * would end up double-escaped once it reaches that single seam. + */ + public function testPerformRejectsUnknownLanguageKeyMessageContainsTheRawUnescapedKey(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $dangerous_key = '&"quoted"'; + + try { + $this->createActivity(settings: $settings) + ->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()); + } + } + + /** + * The same rejection, exercised end-to-end through maybePerformAs(): + * the unknown language_key passes grind() (any non-blank string + * satisfies the Text field's own required-check) and the permission + * check (isAllowedToPerform() does not know about installed languages + * either), so it is perform() itself that rejects it - and the Setting + * collaborator must never be touched. + */ + public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckAndNeverTouchesSettings(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'xx', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * normalizeParameters()'s toNonEmptyString() must trim language_key + * before it reaches perform() - exercised end-to-end through + * maybePerformAs() so a caller submitting e.g. a copy-pasted language + * key with surrounding whitespace still resolves to the same setting + * key as perform() itself would (see + * testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue). + */ + public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => ' de ', 'enabled' => true]); + + $this->assertFalse($result->isError()); + $this->assertSame('de', $result->value()['language_key']); + } + + // ----------------------------------------------------------------- + // getInputDescription() / getOutputDescription() / getDescription() + // ----------------------------------------------------------------- + + 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\Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_key' => $text, 'enabled' => $checkbox]) + ->willReturn($group); + + $activity = $this->createActivity(); + + $this->assertSame($group, $activity->getInputDescription($field)); + } + + /** + * Uses a real DescriptionFactory (like AddLanguageEntryTest's own + * output-description test) rather than a mocked one, so that + * getFields()'s *actual* iteration order is asserted - a mocked + * object()->with([...]) call would accept the three fields in any + * order (PHPUnit's array comparison is order-independent), silently + * missing a mutation that swapped e.g. 'enabled' and 'changed'. + */ + public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): void + { + $string_group = $this->createMock(StringGroup::class); + $string_group->method('markdown')->willReturn( + $this->createMock(MarkdownFormattingToHTML::class) + ); + $refinery = $this->createMock(RefineryFactory::class); + $refinery->method('string')->willReturn($string_group); + + $activity = $this->createActivity(refinery: $refinery); + + $description = $activity->getOutputDescription(new DescriptionFactory()); + + $field_names = []; + foreach ($description->getFields() as $field) { + $field_names[] = $field->getName(); + } + + $this->assertSame(['language_key', 'enabled', 'changed'], $field_names); + } + + // ----------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------- + + private function createActivity( + ?\ilRbacSystem $rbac_system = null, + ?Setting $settings = null, + ?Language $language = null, + int $language_folder_ref_id = 0, + ?RefineryFactory $refinery = null, + ?InstalledLanguageRepository $installed_language_repository = null, + ): SetLanguageTranslationEnabled { + return new SetLanguageTranslationEnabled( + $refinery ?? $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac_system ?? $this->createMock(\ilRbacSystem::class), + $settings ?? $this->createMock(Setting::class), + installed_language_repository: $installed_language_repository + ?? new FakeInstalledLanguageRepository(static fn(): array => ['de', 'fr']), + language_folder_ref_id: $language_folder_ref_id, + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php new file mode 100644 index 000000000000..2375f535ae23 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -0,0 +1,750 @@ +buildFakeLanguageWorld(['de' => []]); + + return $this->createActivity($lng_objects, $obj_language_factory); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de']; + } + + // ----------------------------------------------------------------- + // maybePerformAs() turning a \Throwable from perform() into a + // Result\Error (m5) - here, the new ambiguous-title \RuntimeException + // from resolveObjIdsByLanguageKey() (see below) doubles as the concrete + // \Throwable this class' perform() can actually raise. + // ----------------------------------------------------------------- + + 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()); + } + + // ----------------------------------------------------------------- + // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" + // objects sharing the same title must reject the whole request for + // that title rather than silently resolving to whichever one happened + // to be enumerated last. + // ----------------------------------------------------------------- + + 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); + } + } + + /** + * Regression test: the AmbiguousLanguageTitleException message embeds + * the ambiguous title RAW/unescaped - escaping HTML-significant + * characters for safe display is deliberately no longer this domain + * layer's job (see UninstallLanguage::perform()'s own docblock/comment + * above the throw), only \ILIAS\Language\RendersActivityErrors:: + * activityErrorMessage()'s. If this message were pre-escaped here, it + * would end up double-escaped once it reaches that single seam. + * + * NOTE (found while adding format validation coverage for + * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used + * an HTML/XSS payload ('&"quoted"') as BOTH the + * ambiguous objects' title AND the requested 'language_keys' value - + * $language_keys must equal an object's title verbatim for + * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted + * (perform() intersects the requested, already-parsed $language_keys + * against that map's keys). Since toLanguageKeyList() now rejects any + * value that is not exactly two lowercase ASCII letters BEFORE that + * intersection is even computed, such a value now throws + * InvalidInputException immediately and never reaches + * AmbiguousLanguageTitleException at all - this test would otherwise + * fail with that InvalidInputException instead of the expected + * AmbiguousLanguageTitleException. The dangerous-title scenario is + * therefore no longer reachable through perform()'s public parameter + * surface at all (a real, if incidental, hardening side effect of the + * new format check) - only a title that happens to already be a + * plausible two-letter language key can ever become "ambiguous" via a + * real request. The title/key below is changed accordingly; the + * "no HTML-escaping happens" assertions are kept for continuity, even + * though a two-letter title trivially satisfies them. + */ + 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 same ambiguous-title \RuntimeException must + * surface as a Result\Error, not propagate as an uncaught exception - + * and, again, the object factory must never be reached for it. + */ + 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 must not poison unrelated, unambiguous titles in + * the same $lng_objects() world/request - a genuinely unique language + * key coexisting with an ambiguous one must still resolve and uninstall + * normally. + */ + 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()); + } + + /** + * Regression test for the ambiguity check being moved OUT of the + * per-key foreach loop and performed for ALL requested keys BEFORE any + * uninstall() call (see the comment directly above the + * AmbiguousLanguageTitleException throw in UninstallLanguage::perform(): + * "Checked upfront, for all requested keys at once, so a request naming + * both an unambiguous and an ambiguous key never uninstalls the + * unambiguous one before rejecting the whole call"). A request naming + * an unambiguous key ('de') FIRST and an ambiguous one ('fr') SECOND + * must reject the whole request and must NOT have already uninstalled + * 'de' by the time 'fr' is reached - before this fix, the ambiguity + * check ran inline inside the loop, so 'de' (processed first) would + * already have been uninstalled for real before the loop reached the + * ambiguous 'fr' and threw. + */ + 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' listed BEFORE the ambiguous 'fr' on purpose - this is the + // exact ordering the regression above (see this test's own + // docblock) requires: were the ambiguity check still inline in + // the per-key loop, 'de' being processed first would already + // have been uninstalled before 'fr' was ever reached. + $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 to a factory call, let alone + // uninstalled - the ambiguity check ran BEFORE the per-key loop. + $this->assertSame([], $factory_calls); + $this->assertSame(0, $de->uninstallCallCount()); + } + } + + /** + * Same regression, exercised end-to-end through maybePerformAs(): the + * Result\Error must surface without 'de' having been uninstalled. + */ + 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; each entry becomes a fake object with + * the given isSystemLanguage()/isUserLanguage()/isInstalled() + * answers (default false/false/true) and uninstall() return + * value (default 'uninstalled') reachable from the + * corresponding obj_id (assigned in iteration order, starting at 1). + * @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: a language that is somehow flagged + * as both the system language and the language currently in use must be + * reported as the system language, not as "in use" - the two branches + * are checked in that order in perform(). + */ + 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()); + } + + /** + * Regression test: uninstall() itself is the authority on whether the + * uninstallation actually happened, not merely the three outer guards + * (isSystemLanguage()/isUserLanguage()/isInstalled()). A language that + * passes all three guards (so perform() does call uninstall()) but + * whose uninstall() nonetheless returns '' (e.g. because a future, + * additional internal guard inside \ilObjLanguage::uninstall() itself + * rejects it) must be reported as not_installed_language_keys, never + * as uninstalled_language_keys - anything else would misreport a + * rejected uninstallation as a success. + */ + 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 - unlike the guarded + // cases (system/user/not-installed), where it is never called. + $this->assertSame(1, $fakes[1]->uninstallCallCount()); + } + + /** + * A language key with no corresponding "lng" object at all must be + * reported as not installed - and, crucially, $obj_language_factory + * must never even be invoked for it (there is no obj_id to construct it + * from in the first place). + */ + 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); + } + + /** + * A single request can carry languages in every possible state at once; + * each must land in its own bucket, and only the genuinely uninstalled + * one may ever call 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([ + // 'xx' has no backing object at all. + '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']]]], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + '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); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see UninstallLanguage::perform()): a non-array $parameters must now + * raise the concrete InvalidInputException - not just the more general + * \InvalidArgumentException it extends - since perform() was switched + * from a plain \InvalidArgumentException to InvalidInputException for + * this check, matching every other Activity in this component. + */ + 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); + // UninstallLanguage has no mode parameter - the select field must + // never be built. + $field->expects($this->never())->method('select'); + + $group = $this->createMock(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_keys' => $text]) + ->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' => [], + ]); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac, + $language + )->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()); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind(), via + * collectRawValues()'s call to joinListOfStringsRawValue() (see that + * method's own docblock: "Returns $raw_value joined into a single + * comma-separated string if every one of its items is a string; + * otherwise returns $raw_value unchanged, in whatever shape the caller + * passed"), must join that array into the same shape a real HTML text + * input would carry BEFORE it reaches the declared Text field, rather + * than rejecting it. This is exercised through the REAL + * getInputDescription()/grind() pipeline (createRealFieldsUiFactory(), + * not a mocked FormInput) via maybePerformAs() - the previously + * blocking regression this test guards against. + */ + 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, + ?Language $language = null + ): UninstallLanguage { + return new UninstallLanguage( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + 0, + $lng_objects, + $obj_language_factory + ); + } +} + +/** + * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or + * meaningfully mocked in a unit test (its constructor requires the global + * $DIC), so tests substitute this fake via UninstallLanguage's + * $obj_language_factory closure instead. Counts uninstall() invocations so + * tests can assert it was never called for a bucket other than + * "uninstalled". + */ +final class FakeLanguageObject +{ + private int $uninstall_calls = 0; + + /** + * @param string $uninstall_return_value What uninstall() returns - a + * non-empty string mimics a real, successful uninstall; '' + * mimics \ilObjLanguage::uninstall() internally rejecting the + * call despite the three outer guards (isSystemLanguage()/ + * isUserLanguage()/isInstalled()) having let it through - e.g. a + * future additional guard added inside uninstall() itself. + */ + 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..abed413ed7d6 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -0,0 +1,409 @@ +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 at all is a complete no-op - not even + * checkLanguageForInstallation may run for it, since there is nothing + * installed yet to refresh. Use InstallLanguage to install it first. + */ + 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']); + } + + /** + * A single request can carry both already-installed and not-installed + * languages at once; each must land in its own bucket, and only the + * installed one may trigger any write method. + */ + 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']); + } + + /** + * getAvailableLanguagesForInstallation() (the $known_languages/$db_languages + * argument) and getLocalLanguages() ($local_language_keys) must be passed + * to registerInstalledLanguage() in exactly this order - both empty (or + * equal) values would make a swap of the two arguments invisible, so + * this test deliberately uses distinguishable shapes for each. + */ + 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 ones actually being refreshed must be + * collected into a single error, not just the first one found - and the + * not-installed key must never even be validated, since it is a no-op. + */ + 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 + ]); + } + + /** + * When every requested language turns out to be not installed (a + * complete no-op), perform() must not even read the available/local + * languages - there is nothing left to do that would need them. + */ + 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 never deals with customizing/local files as a distinct + * concept the way InstallLanguage does (mode "install_local") - it must + * never even look at whether a local file is invalid. + */ + 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']]]], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + '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); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see UpdateLanguage::perform()): a non-array $parameters must now + * raise the concrete InvalidInputException - not just the more general + * \InvalidArgumentException it extends. + */ + 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); + // UpdateLanguage has no mode parameter - the select field must never + // be built, unlike InstallLanguage's input description. + $field->expects($this->never())->method('select'); + + $group = $this->createMock(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_keys' => $text]) + ->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'); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $setup_language, + $rbac, + $language + )->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']); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind() (via its + * private joinListOfStringsRawValue() helper - see that method's own + * docblock) must join that array into the same shape a real HTML text + * input would carry BEFORE + * it reaches the declared Text field, rather than rejecting it. This + * is exercised through the REAL getInputDescription()/grind() pipeline + * (createRealFieldsUiFactory(), not a mocked FormInput) via + * maybePerformAs() - the previously blocking regression this test + * guards against. + */ + 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, + ?Language $language = null + ): UpdateLanguage { + return new UpdateLanguage( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + $setup_language + ); + } + + 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..c79b842026d1 --- /dev/null +++ b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php @@ -0,0 +1,98 @@ +component, which + * never changes for the object's lifetime. It is now cached in + * $this->base_directory on first access. This test does not attempt to + * observe the caching mechanism itself (e.g. by counting Reflection calls) - + * per the task, the point is a non-regression guard: for an unchanged + * object, repeated getPath() calls must keep returning the exact same + * value, and that value must still be the one the original (uncached) + * computation would have produced. + */ +class ComponentLanguageFileDirectoryTest extends TestCase +{ + public function testGetPathReturnsTheSameValueOnRepeatedCallsAndMatchesTheUncachedComputation(): void + { + // A real (non-mock) class defined right here, so + // ReflectionClass::getFileName() resolves to *this* test file's real + // path on disk - deterministic, and independently computable below + // without reusing ComponentLanguageFileDirectory's own internals. + $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', 'lang/'); + + // Independently reproduce what getPath() is documented to compute, + // from __DIR__ of this test file rather than from the production + // class's internals. + $ilias_root = (string) realpath(__DIR__ . '/../../../../../'); + self::assertNotSame('', $ilias_root, 'Could not resolve the ILIAS root from this test file - did it move?'); + $expected_base_directory = str_replace($ilias_root . '/', '', __DIR__); + $expected_path = $expected_base_directory . '/lang/'; + + $first_call = $directory->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/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php new file mode 100644 index 000000000000..e907253fe424 --- /dev/null +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -0,0 +1,284 @@ + $this->createMock(\ILIAS\Language\Language::class), + ]; + $seek = [ + LanguageFileDirectory::class => [], + ]; + $pull = [ + RefineryFactory::class => $this->createMock(RefineryFactory::class), + ]; + + // $implement and $contribute are wrapped in RenamingDIC in the real + // generated bootstrap (see Renderer::renderComponent()) so that + // several assignments to the very same key don't collide - a plain + // Pimple\Container would silently let the second assignment + // overwrite the first. + $implement = new RenamingDIC(new PimpleContainer()); + $contribute = new RenamingDIC(new PimpleContainer()); + $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]; + } + + 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::assertInstanceOf(InstalledLanguageDatabaseRepository::class, $provide[InstalledLanguageRepository::class]); + self::assertInstanceOf(LanguageInstallationManager::class, $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[...] is only a thin re-export of $internal[...]; both + // must resolve to the exact same object (Pimple caches the closure + // result), not merely to objects of the same class. + self::assertSame($internal[LanguageFileDirectoryManager::class], $provide[LanguageFileDirectoryManager::class]); + self::assertSame($internal[InstalledLanguageDatabaseRepository::class], $provide[InstalledLanguageRepository::class]); + self::assertSame($internal[LanguageInstallationManager::class], $provide[LanguageInstallationManager::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 testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder(): void + { + [, , , $contribute] = $this->initComponent(); + + // RenamingDIC assigns a single, container-wide "_" suffix + // to every offsetSet call, in call order. Reading these exact keys + // back pins the relative order of the ten $contribute entries, + // which the reorganisation was explicitly required to preserve - + // including the seven \ILIAS\Component\Activities\Activity::class + // entries (InstallLanguage, then UpdateLanguage, then + // UninstallLanguage, then RemoveLocalLanguageChanges, then + // AddLanguageEntry, then SetLanguageDetectionEnabled, then + // SetLanguageTranslationEnabled), one per Activity this component + // offers. The UserSettings contribution's counter shifted from _4 + // to _5 when UninstallLanguage's own Activity contribution was + // added ahead of it, then from _5 to _6 when + // RemoveLocalLanguageChanges' Activity contribution was added ahead + // of it in turn, then from _6 to _7 with AddLanguageEntry's Activity + // contribution added ahead of it, then from _7 to _8 with + // SetLanguageDetectionEnabled's Activity contribution added ahead of + // it, and has now shifted again from _8 to _9 with + // SetLanguageTranslationEnabled's Activity contribution added ahead + // of it - a hardcoded '_8' for UserSettingsSettings would otherwise + // silently start resolving to the wrong contribute entry (or, as + // happened before, to no entry at all). + self::assertInstanceOf(MainLanguageFileDirectory::class, $contribute[LanguageFileDirectory::class . '_0']); + self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); + self::assertInstanceOf(InstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_2']); + self::assertInstanceOf(UpdateLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_3']); + self::assertInstanceOf(UninstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_4']); + self::assertInstanceOf(RemoveLocalLanguageChanges::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_5']); + self::assertInstanceOf(AddLanguageEntry::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_6']); + self::assertInstanceOf(SetLanguageDetectionEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_7']); + self::assertInstanceOf(SetLanguageTranslationEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_8']); + self::assertInstanceOf(UserSettingsSettings::class, $contribute[\ILIAS\User\Settings\UserSettings::class . '_9']); + } + + public function testContributedActivitiesAreTheSameSingletonsAsTheProvidedInstances(): void + { + [, , , $contribute, , $provide] = $this->initComponent(); + + // ilLanguageSetupAgent (Setup path) and ilObjLanguageFolderGUI + // (via $provide[InstallLanguage::class]) must operate on the exact + // same InstallLanguage instance, not merely equal ones - and the + // same holds for UpdateLanguage, UninstallLanguage, + // RemoveLocalLanguageChanges, AddLanguageEntry, + // SetLanguageDetectionEnabled and SetLanguageTranslationEnabled. + self::assertSame( + $provide[InstallLanguage::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_2'] + ); + self::assertSame( + $provide[UpdateLanguage::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_3'] + ); + self::assertSame( + $provide[UninstallLanguage::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_4'] + ); + self::assertSame( + $provide[RemoveLocalLanguageChanges::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_5'] + ); + self::assertSame( + $provide[AddLanguageEntry::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_6'] + ); + self::assertSame( + $provide[SetLanguageDetectionEnabled::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_7'] + ); + self::assertSame( + $provide[SetLanguageTranslationEnabled::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_8'] + ); + } + + public function testImplementCandidatesAreOfferedInDeclarationOrder(): void + { + [, $implement] = $this->initComponent(); + + // The Setup-path candidate (\ilSetupLanguage) must be registered + // before the runtime/Init-path candidate + // (LanguageLegacyInitialisationAdapter) - consumers disambiguate + // by position (see cli/build_bootstrap.php / + // Renderer::renderUse()), so a swap here would silently flip which + // candidate every $use[\ILIAS\Language\Language::class] consumer + // gets wired to. + self::assertInstanceOf(\ilSetupLanguage::class, $implement[\ILIAS\Language\Language::class . '_0']); + self::assertInstanceOf( + LanguageLegacyInitialisationAdapter::class, + $implement[\ILIAS\Language\Language::class . '_1'] + ); + } + + /** + * Regression guard for the $ilias_root / $resolve_db deduplication: + * both InstalledLanguageDatabaseRepository and LanguageInstallationManager + * must still end up with the exact same, correctly computed ILIAS root + * path (previously two textually-identical `realpath()` calls, now one + * shared local variable read twice). + */ + public function testDeduplicatedIliasRootIsCorrectAndIdenticalForBothConsumers(): void + { + [, , , , , $provide] = $this->initComponent(); + + $expected_root = (string) realpath(__DIR__ . '/../../../../'); + + self::assertNotSame('', $expected_root, 'sanity check: the ILIAS root must resolve to a real path'); + + $repository_root = self::readPrivateProperty($provide[InstalledLanguageRepository::class], 'absolute_path'); + $manager_root = self::readPrivateProperty($provide[LanguageInstallationManager::class], 'absolute_path'); + + self::assertSame($expected_root, $repository_root); + self::assertSame($expected_root, $manager_root); + } + + private static function readPrivateProperty(object $object, string $property): mixed + { + return (new \ReflectionProperty($object, $property))->getValue($object); + } +} diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php new file mode 100644 index 000000000000..f669fddf5e2f --- /dev/null +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -0,0 +1,276 @@ +createMock(\ilLanguage::class); + $lng->method('txt')->willReturnArgument(0); + + return $lng; + } + + public function testAPlainStringErrorIsReturnedAsIsAndNeverLogged(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage('msg_no_perm_write'); + + $this->assertSame('msg_no_perm_write', $message); + } + + public function testASafeToDisplayActivityErrorMessageIsReturnedAsIsAndNeverLogged(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $error = new InvalidInputException('language_keys: not_min_length'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + $this->assertSame('language_keys: not_min_length', $message); + } + + public function testAnAmbiguousLanguageTitleExceptionIsAlsoTreatedAsSafeToDisplayAndNeverLogged(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $error = new AmbiguousLanguageTitleException('Multiple language objects share the title(s) "fr".'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + // The domain layer (UninstallLanguage/RemoveLocalLanguageChanges etc.) + // deliberately embeds the raw, unescaped title in the exception + // message - activityErrorMessage() is the one seam that HTML-escapes + // it for safe display, so the '"' characters must come back as + // '"' here. + $this->assertSame('Multiple language objects share the title(s) "fr".', $message); + } + + /** + * Regression test for the escaping seam itself: a SafeToDisplayActivityError + * message containing genuinely dangerous markup (not just a stray quote) + * must come back HTML-escaped - proving activityErrorMessage() is not + * merely quote-escaping by coincidence but actually protects against + * XSS-shaped content that a caller-controlled language key/title/field + * name could carry. + */ + public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlEscaped(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $error = new InvalidInputException('Unknown key(s) for & "quoted" \'single\'.'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + $this->assertSame( + 'Unknown key(s) for <script>alert(1)</script> & "quoted" 'single'.', + $message + ); + $this->assertStringNotContainsString('', $error->getMessage()); } @@ -268,10 +244,8 @@ public static function nonArrayNonNullGroupRawValueProvider(): array } /** - * A group's raw value that is neither null (omitted) nor an array is a genuine type mismatch - * and must be rejected - silently degrading it to an empty array would instead make every - * child field look merely "omitted", masking the real problem behind a misleading - * required-field error. + * A group value that is neither null nor an array must be rejected; degrading it to an empty array would mask + * it as a misleading required-field error. */ #[DataProvider('nonArrayNonNullGroupRawValueProvider')] public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOfSilentlyDegradingToEmptyArray( diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php index 7bdd3dc7ca12..8a60ae15a3d8 100644 --- a/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php @@ -25,9 +25,7 @@ use ILIAS\UI\Component\Input\Container\Form\FormInput; /** - * Minimal host for GrindsFormInput: only mixes in the trait and exposes its - * private grind() through a public wrapper, so the trait's own contract can - * be tested in isolation from any concrete Activity - see GrindsFormInputTest. + * Exposes the private grind() of GrindsFormInput through a public wrapper. */ final class GrindsFormInputTestHost { diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index 7fc2bd8da82c..62819ad8a63c 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -530,8 +530,7 @@ public static function invalidLanguageKeysProvider(): array 'nested array value' => [ ['language_keys' => ['de', ['fr']], 'mode' => InstallLanguage::MODE_INSTALL], ], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). + // format validation of ParsesLanguageKeyList::toLanguageKeyList() 'three letters' => [ ['language_keys' => 'deu', 'mode' => InstallLanguage::MODE_INSTALL], ], @@ -651,10 +650,7 @@ public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInAR $rbac->method('checkAccessOfUser')->willReturn(true); $setup_language = $this->createSetupLanguageMock([], [], []); - // checkLanguageForInstallation() is called from within perform()'s - // own loop, well after isAllowedToPerform() succeeded - a realistic - // place for an unexpected \TypeError to surface (e.g. a - // misconfigured/incompatible collaborator), simulated here directly. + // raised inside perform()'s loop, after isAllowedToPerform() succeeded $setup_language->method('checkLanguageForInstallation')->willReturnCallback( static function (): bool { throw new \TypeError('simulated TypeError, not an \Exception'); diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php index 15b84b773a35..376d8d4c5055 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php @@ -16,7 +16,6 @@ * *********************************************************************/ - declare(strict_types=1); namespace ILIAS\Language\Tests\Activities; @@ -27,8 +26,7 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * The RBAC-free Setup variants and the lazy RBAC access of - * LanguageActivity. + * The RBAC-free Setup variants and the lazy RBAC access of LanguageActivity. */ class LanguageActivitySetupVariantTest extends \ilLanguageBaseTestCase { diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index f2a68796e232..4fc3897a4334 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -32,18 +32,15 @@ use ilLanguageBaseTestCase; /** - * LanguageActivity is abstract, exercised indirectly by every concrete Activity's own test suite; - * this file targets LanguageActivity::maybePerformAs() directly via minimal throwing test doubles, - * for behaviour no concrete Activity can trigger without an artificial collaborator failure. + * Targets LanguageActivity::maybePerformAs() directly via throwing test doubles, for behaviour no concrete + * Activity can trigger without an artificial collaborator failure. */ class LanguageActivityTest extends ilLanguageBaseTestCase { use RealFieldsUiFactory; /** - * A \Throwable raised while BUILDING the input description (not grinding, not perform()) must - * still come back as a Result\Error, per Activity::maybePerformAs()'s contract - rbac must - * never be reached either, since the failure happens before permission-checking. + * A \Throwable while building the input description must come back as a Result\Error, without reaching rbac. */ public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResultErrorNotPropagated(): void { @@ -66,9 +63,8 @@ public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResult } /** - * Same scenario, but the \Throwable is not an \Exception (a \TypeError) - `new - * Result\Error($e)` only accepts string|\Exception, so it must arrive wrapped in a - * \RuntimeException instead of raising an uncaught \InvalidArgumentException. + * A non-\Exception \Throwable (\TypeError) must arrive wrapped in a \RuntimeException, since Result\Error + * accepts only string|\Exception. */ public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntimeExceptionResultError(): void { @@ -94,11 +90,8 @@ public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntim } /** - * Regression test for maybePerformAs()'s array_merge() precedence: additionalPerformParameters() - * must win over a same-named key from normalizeParameters(). Uses a dedicated LanguageActivity - * double whose normalizeParameters() always returns a colliding 'usr_id', since - * AddLanguageEntry's own getInputDescription() never collects a 'usr_id' field and so never - * actually reaches this collision. + * additionalPerformParameters() must win over a same-named key from normalizeParameters() in maybePerformAs()'s + * array_merge(). Needs a dedicated double, as AddLanguageEntry never collects a 'usr_id' field. */ public function testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): void { @@ -116,24 +109,15 @@ public function testAdditionalPerformParametersWinsOverANormalizeParametersKeyCo $this->assertFalse($result->isError()); $parameters = $result->value(); - // The trusted, server-resolved usr_id (42, from maybePerformAs()'s own - // argument via additionalPerformParameters()) must win over the - // spoofed value normalizeParameters() produced from (simulated) form - // input. + // the trusted usr_id (42) wins over the spoofed one from normalizeParameters() $this->assertSame(42, $parameters['usr_id']); - // A non-colliding key from normalizeParameters() must still survive - // the merge untouched. + // a non-colliding key survives the merge $this->assertSame('form', $parameters['source']); } } /** - * Minimal concrete LanguageActivity whose getInputDescription() always - * throws, used exclusively to exercise LanguageActivity::maybePerformAs()'s - * generic try/catch around getInputDescription()/$input_factory->field() in - * isolation - every other abstract method is unreachable from - * maybePerformAs() once getInputDescription() throws, and simply throws - * itself if a test ever calls it by mistake. + * LanguageActivity whose getInputDescription() always throws; every other abstract method throws too. */ final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity { @@ -178,11 +162,8 @@ protected function normalizeParameters(array $grind_result): array } /** - * Minimal LanguageActivity used only by - * testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): - * normalizeParameters() always returns a colliding 'usr_id', simulating spoofed form input. - * getInputDescription() returns an empty group so grind() trivially succeeds; perform() returns - * $parameters unchanged so the test can inspect what survived the merge. + * normalizeParameters() always returns a colliding 'usr_id' (spoofed form input); perform() returns $parameters + * unchanged so the test can inspect the merge. */ final class LanguageActivityMergePrecedenceTestDouble extends LanguageActivity { @@ -211,8 +192,7 @@ public function perform(mixed $parameters): mixed */ protected function normalizeParameters(array $grind_result): array { - // Simulates a spoofed 'usr_id' smuggled in through form input - must - // never win over additionalPerformParameters()'s trusted value below. + // spoofed 'usr_id' that must lose against additionalPerformParameters() return ['usr_id' => 'SPOOFED-VALUE', 'source' => 'form']; } diff --git a/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php index 589043c09d58..fba29a700c6a 100644 --- a/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php +++ b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php @@ -29,43 +29,15 @@ use ILIAS\UI\Implementation\Component\SignalGenerator; /** - * Reusable helper mixed into every Activities test case that needs a - * `\ILIAS\UI\Component\Input\Factory` (reached via `->input()` below) whose - * `->field()->text()/checkbox()/select()/group()` return REAL, functioning - * UI framework Field objects - i.e. one that GrindsFormInput::grind() (see - * the trait's own docblock) can actually run - * `withNameFrom()`/`withInput()`/`getContent()` against, instead of a plain - * PHPUnit mock, which would either return null or a broken mock object for - * these calls (and grind()'s own try/catch would silently turn every such - * call into a Result\Error). - * - * `maybePerformAs()` (see LanguageActivity/every concrete Activity) only - * ever needs `\ILIAS\UI\Component\Input\Factory` - it calls - * `$input_factory->field()` directly and never touches any other - * `\ILIAS\UI\Factory` method - so every call site here only ever chains - * `->input()` off the returned factory before using it. The helper still - * returns the full, enclosing `\ILIAS\UI\Factory` (rather than the narrower - * `\ILIAS\UI\Component\Input\Factory` directly) purely because that is the - * natural return type of the mocked `->input()` accessor below; only - * `\ILIAS\UI\Factory::input()` and `\ILIAS\UI\Component\Input\Factory::field()` - * are ever called by these tests, so those two collaborators are plain - * PHPUnit mocks stubbed to return the real, concrete field factory below - - * see CommonFieldRendering::getFieldFactory() in - * components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php - * for the same, established recipe. + * Gives Activity tests a UI factory whose `->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 { /** - * Builds a `\ILIAS\UI\Factory` mock whose `input()` is a plain mock - * returning a REAL `\ILIAS\UI\Implementation\Component\Input\Field\Factory` - * from `field()` (the only two calls any Activity's `maybePerformAs()`/ - * `getInputDescription()` ever makes on it), wired against a real - * `\ILIAS\Data\Factory`/`\ILIAS\Refinery\Factory` pair and a - * `\ILIAS\Language\Language` mock whose `txt()` simply echoes back the - * key it was given. Callers invariably chain `->input()` on the result - * to obtain the `\ILIAS\UI\Component\Input\Factory` that `maybePerformAs()` - * actually declares as its parameter type. + * `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 { diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index febb726e5152..84137def9653 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -425,7 +425,6 @@ public function testInstalledLanguageWhereRemoveLocalChangesFailsIsReportedAsInv $this->assertSame([], $result['removed_local_changes_language_keys']); $this->assertSame(['de'], $result['invalid_language_file_keys']); $this->assertSame([], $result['not_installed_language_keys']); - // removeLocalChanges() must still have been attempted. $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); } @@ -470,7 +469,6 @@ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): voi ]); $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ - // 'xx' has no backing object at all. 'language_keys' => ['de', 'fr', 'it', 'xx'], ]); @@ -519,8 +517,6 @@ public static function invalidLanguageKeysProvider(): array 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], 'nested array value' => [['language_keys' => ['de', ['fr']]]], 'non-array, non-string value' => [['language_keys' => 42]], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). 'three letters' => [['language_keys' => 'deu']], 'one letter' => [['language_keys' => 'd']], 'uppercase' => [['language_keys' => 'DE']], @@ -661,9 +657,7 @@ public function testThrowableFromPerformIsTurnedIntoAResultError(): void $lng_objects = static fn(): array => []; $obj_language_factory = static fn(int $id) => null; - // An empty language_keys value normalizes to [] and throws inside - // perform() (via toLanguageKeyList()) - reachable only after the - // permission check has already passed, exercising the catch block. + // an empty language_keys value throws inside perform(), after the permission check passed $result = $this->createActivity( $lng_objects, $obj_language_factory, diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index edb0e18f3217..09b154b43a26 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -29,20 +29,9 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * SetLanguageDetectionEnabled has no ilSetupLanguage collaborator and no - * $lng_objects/$obj_language_factory closures (unlike UninstallLanguage/ - * RemoveLocalLanguageChanges) - instead it wraps a single - * \ILIAS\Administration\Setting collaborator ("lang_detection") which is - * a plain interface and can therefore be mocked directly, unlike - * \ilObjLanguage on the sibling Activities. - * - * The most important regression this file guards is the *new* enforced - * "write" RBAC check in isAllowedToPerform()/maybePerformAs(): before this - * extraction, neither enableLanguageDetectionObject() nor - * disableLanguageDetectionObject() performed any permission check of their - * own. A test here must prove that a denied permission both yields a - * Result\Error *and* leaves the Setting collaborator untouched (no side - * effect on denial). + * SetLanguageDetectionEnabled wraps a single \ILIAS\Administration\Setting ("lang_detection"), which is a plain + * interface and can be mocked directly, unlike \ilObjLanguage on the sibling Activities. A denied permission must + * yield a Result\Error and leave the Setting untouched. */ class SetLanguageDetectionEnabledTest extends ActivityContractTestCase { @@ -51,10 +40,6 @@ protected function createDefaultActivity(): SetLanguageDetectionEnabled return $this->createActivity(); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -97,10 +82,6 @@ public function testIsAllowedToPerformNeverTouchesSettings(): void $this->assertTrue($activity->isAllowedToPerform(6, ['enabled' => true])); } - // ----------------------------------------------------------------- - // perform() - // ----------------------------------------------------------------- - public function testPerformWithEnabledTrueWritesOneIntoLangDetectionSettingAndReturnsEnabledTrue(): void { $settings = $this->createMock(Setting::class); @@ -178,10 +159,6 @@ public static function nonBooleanEnabledValuesProvider(): array ]; } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -248,10 +225,7 @@ public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoARes ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => 'not-a-bool']); $this->assertTrue($result->isError()); - // 'not-a-bool' is outside normalizeCheckboxRawValue()'s explicit - // whitelist - grind()'s dedicated catch block converts the - // resulting \InvalidArgumentException into an InvalidInputException - // (a SafeToDisplayActivityError), not a raw \InvalidArgumentException. + // outside the whitelist of normalizeCheckboxRawValue(): grind() turns it into an InvalidInputException $this->assertInstanceOf(InvalidInputException::class, $result->error()); $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); } @@ -328,8 +302,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsA } /** - * Same tolerance as above, but for the common primitive representations - * of "false". + * Same tolerance for the common representations of "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyRepresentationsAndWritesEnabledFalse(mixed $value): void @@ -356,9 +329,7 @@ public static function rejectedEnabledValuesProvider(): array } /** - * Only values outside GrindsFormInput::normalizeCheckboxRawValue()'s - * explicit whitelist are still rejected via maybePerformAs() - "konservativ - * normalisieren", not "alles akzeptieren". + * Values outside the whitelist of GrindsFormInput::normalizeCheckboxRawValue() are still rejected. */ #[DataProvider('rejectedEnabledValuesProvider')] public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWithoutTouchingSettings(mixed $value): void @@ -377,10 +348,6 @@ public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWi $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); } - // ----------------------------------------------------------------- - // getInputDescription() / getOutputDescription() - // ----------------------------------------------------------------- - public function testInputDescriptionBuildsAGroupWithASingleEnabledCheckboxField(): void { $checkbox = $this->createMock(Checkbox::class); @@ -424,10 +391,6 @@ public function testOutputDescriptionDescribesASingleBooleanEnabledField(): void $this->assertSame($object_description, $this->createActivity()->getOutputDescription($f)); } - // ----------------------------------------------------------------- - // helpers - // ----------------------------------------------------------------- - private function createActivity( ?\ilRbacSystem $rbac_system = null, ?Setting $settings = null, diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index c52378709ebd..261090a99157 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -32,25 +32,10 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * SetLanguageTranslationEnabled is, like its sibling - * SetLanguageDetectionEnabled, backed by a single \ILIAS\Administration\Setting - * collaborator - a plain interface, mockable directly, never a real - * \ilSetting/database connection. Unlike SetLanguageDetectionEnabled, this - * Activity is parameterised by a language_key (the setting written/read is - * "lang_translate_", not a single fixed key), and perform() itself - * (not just normalizeParameters()) validates its $parameters array and - * computes a `changed` flag by comparing the requested value against the - * *currently stored* one (read via a falsy (bool) cast, exactly mirroring - * `ilObjLanguageAccess::_checkTranslate()` - see `$currently_enabled = (bool) - * ($this->settings)()->get($translate_key, '0')` in perform()). That - * comparison, and the "write only if changed" behaviour it drives, is the - * most important regression surface in this file: a mutant that always - * writes (or that inverts the comparison) must be caught here. - * - * The second most important regression this file guards is the same one as - * SetLanguageDetectionEnabledTest: the enforced "write" RBAC check in - * isAllowedToPerform()/maybePerformAs() must actually prevent both the - * outcome *and* the side effect (no write to Settings) when denied. + * Backed by a single mockable \ILIAS\Administration\Setting. Unlike SetLanguageDetectionEnabled it is parameterised by + * a language_key ("lang_translate_"), and perform() itself validates $parameters and writes only if the value + * changed, comparing against the stored one with a falsy (bool) cast like ilObjLanguageAccess::_checkTranslate(). + * A denied "write" permission must prevent both the outcome and the Settings access. */ class SetLanguageTranslationEnabledTest extends ActivityContractTestCase { @@ -59,10 +44,6 @@ protected function createDefaultActivity(): SetLanguageTranslationEnabled return $this->createActivity(); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -106,10 +87,6 @@ public function testIsAllowedToPerformNeverTouchesSettings(): void $this->assertTrue($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); } - // ----------------------------------------------------------------- - // perform() - the changed/unchanged decision - // ----------------------------------------------------------------- - /** * Core business rule: perform() must read the * *current* value of "lang_translate_" via a falsy check - exactly @@ -196,10 +173,6 @@ public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedVal $this->assertSame('de', $result['language_key']); } - // ----------------------------------------------------------------- - // perform() - parameter validation - // ----------------------------------------------------------------- - public static function invalidPerformParametersProvider(): array { return [ @@ -233,10 +206,6 @@ public function testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSe $this->createActivity(settings: $settings)->perform($parameters); } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -334,10 +303,6 @@ public function testMaybePerformAsWithMissingRawParametersKeysIsAResultErrorAndN public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array { return [ - // The required 'language_key' Text field receives a blank raw - // value (missing entirely) and fails its own required-field - // constraint inside grind() itself - before normalizeParameters()/ - // isAllowedToPerform() are ever reached. 'missing language_key' => [['enabled' => true]], ]; } @@ -364,15 +329,9 @@ public function testMaybePerformAsRejectsRawParametersAtGrindLevelWithStringErro public static function invalidRawParametersProvider(): array { return [ - // A non-string 'language_key' fails the Text field's own - // isClientSideValueOk() check inside grind() itself, which - // throws an InvalidArgumentException ("Display value does not - // match input type."), caught and preserved by grind()'s - // try/catch. + // a non-string 'language_key' fails the Text field's isClientSideValueOk() inside grind() 'non-string language_key' => [['language_key' => 42, 'enabled' => true]], - // Outside GrindsFormInput::normalizeCheckboxRawValue()'s - // explicit whitelist - "konservativ normalisieren", not "alles - // akzeptieren". + // 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]], ]; @@ -499,8 +458,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresent } /** - * Same tolerance as above, but for the common primitive representations - * of "false". + * Same tolerance for the common representations of "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresentations(mixed $value): void @@ -522,10 +480,6 @@ public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresenta ); } - // ----------------------------------------------------------------- - // language_key must be an installed language (Punkt 4) - // ----------------------------------------------------------------- - /** * Regression test for Punkt 4 of this component's grinding task: unlike * before, an unknown/not-installed language_key is now rejected by @@ -621,10 +575,6 @@ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): $this->assertSame('de', $result->value()['language_key']); } - // ----------------------------------------------------------------- - // getInputDescription() / getOutputDescription() / getDescription() - // ----------------------------------------------------------------- - public function testInputDescriptionBuildsAGroupWithARequiredLanguageKeyTextFieldAndAnEnabledCheckbox(): void { $text = $this->createMock(Text::class); @@ -693,10 +643,6 @@ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): $this->assertSame(['language_key', 'enabled', 'changed'], $field_names); } - // ----------------------------------------------------------------- - // helpers - // ----------------------------------------------------------------- - private function createActivity( ?\ilRbacSystem $rbac_system = null, ?Setting $settings = null, diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index d5e0ddaac42c..735f784ece14 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -451,8 +451,7 @@ public function testLanguageWhereUninstallItselfRejectsIsReportedAsNotInstalled( $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 - unlike the guarded - // cases (system/user/not-installed), where it is never called. + // uninstall() must still have been attempted $this->assertSame(1, $fakes[1]->uninstallCallCount()); } @@ -483,9 +482,7 @@ public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingThe } /** - * A single request can carry languages in every possible state at once; - * each must land in its own bucket, and only the genuinely uninstalled - * one may ever call uninstall(). + * Languages in every state at once land in their own bucket; only the uninstalled one calls uninstall(). */ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): void { @@ -497,7 +494,6 @@ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): voi ]); $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ - // 'xx' has no backing object at all. 'language_keys' => ['de', 'en', 'fr', 'it', 'xx'], ]); @@ -532,8 +528,6 @@ public static function invalidLanguageKeysProvider(): array 'missing language_keys key' => [[]], 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], 'nested array value' => [['language_keys' => ['de', ['fr']]]], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). 'three letters' => [['language_keys' => 'deu']], 'one letter' => [['language_keys' => 'd']], 'uppercase' => [['language_keys' => 'DE']], @@ -579,8 +573,7 @@ public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField( 'Language keys', 'Comma-separated list of language keys, e.g. de, fr, it.' )->willReturn($text); - // UninstallLanguage has no mode parameter - the select field must - // never be built. + // no mode parameter: the select field must never be built $field->expects($this->never())->method('select'); $group = $this->createMock(Group::class); diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php index e871f6b79fec..a2ad6b96c6f6 100644 --- a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -66,9 +66,8 @@ public function testSingleAlreadyInstalledLanguageIsRefreshed(): void } /** - * A language that is not installed at all is a complete no-op - not even - * checkLanguageForInstallation may run for it, since there is nothing - * installed yet to refresh. Use InstallLanguage to install it first. + * A language that is not installed is a complete no-op, not even checkLanguageForInstallation may run; + * InstallLanguage installs it. */ public function testSingleNotInstalledLanguageIsCompleteNoOp(): void { @@ -87,9 +86,7 @@ public function testSingleNotInstalledLanguageIsCompleteNoOp(): void } /** - * A single request can carry both already-installed and not-installed - * languages at once; each must land in its own bucket, and only the - * installed one may trigger any write method. + * Installed and not installed languages at once land in their own bucket; only the installed one triggers a write. */ public function testMixedListPartitionsEachKeyIntoTheCorrectBucket(): void { @@ -158,9 +155,7 @@ public function testInvalidLanguageFileOfAnAlreadyInstalledLanguagePreventsRefre } /** - * All invalid keys among the ones actually being refreshed must be - * collected into a single error, not just the first one found - and the - * not-installed key must never even be validated, since it is a no-op. + * All invalid keys among the refreshed ones are collected into one error; the not-installed key is never validated. */ public function testMultipleInvalidLanguageFilesAreAllReportedTogether(): void { @@ -184,9 +179,7 @@ public function testMultipleInvalidLanguageFilesAreAllReportedTogether(): void } /** - * When every requested language turns out to be not installed (a - * complete no-op), perform() must not even read the available/local - * languages - there is nothing left to do that would need them. + * If every requested language is not installed, perform() must not read the available/local languages. */ public function testAllRequestedLanguagesNotInstalledSkipsLanguageLookupsEntirely(): void { @@ -208,9 +201,7 @@ public function testAllRequestedLanguagesNotInstalledSkipsLanguageLookupsEntirel } /** - * UpdateLanguage never deals with customizing/local files as a distinct - * concept the way InstallLanguage does (mode "install_local") - it must - * never even look at whether a local file is invalid. + * UpdateLanguage must never look at whether a local file is invalid (InstallLanguage mode "install_local" does). */ public function testNeverInspectsInvalidLocalLanguageFiles(): void { @@ -247,8 +238,6 @@ public static function invalidLanguageKeysProvider(): array 'missing language_keys key' => [[]], 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], 'nested array value' => [['language_keys' => ['de', ['fr']]]], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). 'three letters' => [['language_keys' => 'deu']], 'one letter' => [['language_keys' => 'd']], 'uppercase' => [['language_keys' => 'DE']], @@ -292,8 +281,7 @@ public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField( 'Language keys', 'Comma-separated list of language keys, e.g. de, fr, it.' )->willReturn($text); - // UpdateLanguage has no mode parameter - the select field must never - // be built, unlike InstallLanguage's input description. + // no mode parameter: the select field must never be built $field->expects($this->never())->method('select'); $group = $this->createMock(Group::class); diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php index c79b842026d1..74162e12c26b 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php @@ -24,24 +24,15 @@ use PHPUnit\Framework\TestCase; /** - * getPath() used to recompute its ReflectionClass/realpath lookup on every - * call even though the result only ever depends on $this->component, which - * never changes for the object's lifetime. It is now cached in - * $this->base_directory on first access. This test does not attempt to - * observe the caching mechanism itself (e.g. by counting Reflection calls) - - * per the task, the point is a non-regression guard: for an unchanged - * object, repeated getPath() calls must keep returning the exact same - * value, and that value must still be the one the original (uncached) - * computation would have produced. + * Non-regression guard for the base directory getPath() caches: repeated calls on an unchanged object return the same + * value, and it is the one an uncached computation would produce. The caching itself is not observed. */ class ComponentLanguageFileDirectoryTest extends TestCase { public function testGetPathReturnsTheSameValueOnRepeatedCallsAndMatchesTheUncachedComputation(): void { - // A real (non-mock) class defined right here, so - // ReflectionClass::getFileName() resolves to *this* test file's real - // path on disk - deterministic, and independently computable below - // without reusing ComponentLanguageFileDirectory's own internals. + // a real (non-mock) class defined here, so ReflectionClass::getFileName() resolves to this file - deterministic + // and computable below without ComponentLanguageFileDirectory's internals $component = new class () implements Component { public function init( array|\ArrayAccess &$define, @@ -58,9 +49,7 @@ public function init( $directory = new ComponentLanguageFileDirectory($component, 'tst', 'lang/'); - // Independently reproduce what getPath() is documented to compute, - // from __DIR__ of this test file rather than from the production - // class's internals. + // independently reproduces getPath()'s computation from __DIR__ of this file $ilias_root = (string) realpath(__DIR__ . '/../../../../../'); self::assertNotSame('', $ilias_root, 'Could not resolve the ILIAS root from this test file - did it move?'); $expected_base_directory = str_replace($ilias_root . '/', '', __DIR__); diff --git a/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php index 3f89e909ebe6..09e40e3c49ad 100644 --- a/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php @@ -31,9 +31,7 @@ */ class LanguageComponentDependenciesTest extends TestCase { - /** - * @return list - */ + /** @return list */ private function names(InType $type): array { $result = (new Reader(new EvalLightMockBuilder()))->read(new ILIAS\Language()); diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index a07bb4ea6c15..14da7e83a905 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -43,33 +43,10 @@ use PHPUnit\Framework\TestCase; /** - * Regression coverage for the wiring performed by - * components/ILIAS/Language/Language.php::init(). + * Wiring of Language.php::init() through container types that mirror the generated bootstrap (Pimple\Container, + * RenamingDIC for $implement/$contribute), so regressions that only show with a real lazily-evaluated container are caught. * - * Language.php was reorganised so that $internal entries are declared in - * dependency order. - * Nothing about *which* class ends up behind which $implement / $contribute - * / $provide key was meant to change - the container types involved - * (Pimple\Container, wrapped in RenamingDIC for $implement/$contribute) are - * exactly what makes the reordering safe: closures created early still see - * later mutations of the same container object, because a PHP arrow - * function captures the *variable* by value, and for an object-typed - * variable ("by value" copies only the handle) that value is a reference to - * the very same underlying container. - * - * These tests exercise Language.php::init() through container types that - * mirror what artifacts/bootstrap_*.php actually builds at runtime (see - * ILIAS\Component\Dependencies\Renderer::renderComponent()), so a - * regression that only breaks when a real, mutable, lazily-evaluated - * container is used (as opposed to a plain PHP array test double) would - * still be caught here. - * - * $use only seeds \ILIAS\Language\Language::class, the RBAC service the - * Activities need and Directories: Language.php::init() does - * not (any longer) read a \ILIAS\UI\Factory entry from $use - seeding one - * here regardless would silently keep passing even if init() started reading - * it again incorrectly (e.g. the old $ui_factory dependency reappearing by - * accident), since a mock nobody asserts against never fails a test. + * $use is seeded only with what init() reads: a mock nobody asserts against would hide a reappearing $ui_factory dependency. */ class LanguageComponentGraphTest extends TestCase { @@ -95,11 +72,7 @@ private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\ \ILIAS\Database\Connection::class => $connection ?? $this->createStub(\ILIAS\Database\Connection::class), ]; - // $implement and $contribute are wrapped in RenamingDIC in the real - // generated bootstrap (see Renderer::renderComponent()) so that - // several assignments to the very same key don't collide - a plain - // Pimple\Container would silently let the second assignment - // overwrite the first. + // RenamingDIC (as in the generated bootstrap) keeps several assignments to one key apart; a plain Pimple\Container overwrites $implement = new RenamingDIC(new PimpleContainer()); $contribute = new RenamingDIC(new PimpleContainer()); $provide = new PimpleContainer(); @@ -146,9 +119,7 @@ public function testProvidedServicesShareTheSameInternalSingletonInstances(): vo { [, , , , , $provide, , $internal] = $this->initComponent(); - // $provide[...] is only a thin re-export of $internal[...]; both - // must resolve to the exact same object (Pimple caches the closure - // result), not merely to objects of the same class. + // $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]); @@ -169,27 +140,8 @@ public function testContributeEntriesResolveToExpectedConcreteClassesInPreserved { [, , , $contribute] = $this->initComponent(); - // RenamingDIC assigns a single, container-wide "_" suffix - // to every offsetSet call, in call order. Reading these exact keys - // back pins the relative order of the ten $contribute entries, - // which the reorganisation was explicitly required to preserve - - // including the seven \ILIAS\Component\Activities\Activity::class - // entries (InstallLanguage, then UpdateLanguage, then - // UninstallLanguage, then RemoveLocalLanguageChanges, then - // AddLanguageEntry, then SetLanguageDetectionEnabled, then - // SetLanguageTranslationEnabled), one per Activity this component - // offers. The UserSettings contribution's counter shifted from _4 - // to _5 when UninstallLanguage's own Activity contribution was - // added ahead of it, then from _5 to _6 when - // RemoveLocalLanguageChanges' Activity contribution was added ahead - // of it in turn, then from _6 to _7 with AddLanguageEntry's Activity - // contribution added ahead of it, then from _7 to _8 with - // SetLanguageDetectionEnabled's Activity contribution added ahead of - // it, and has now shifted again from _8 to _9 with - // SetLanguageTranslationEnabled's Activity contribution added ahead - // of it - a hardcoded '_8' for UserSettingsSettings would otherwise - // silently start resolving to the wrong contribute entry (or, as - // happened before, to no entry at all). + // RenamingDIC suffixes every offsetSet with a container-wide "_" in call order, so these keys pin the order of + // the $contribute entries: a hardcoded counter shifts whenever a contribution is added ahead of it self::assertInstanceOf(MainLanguageFileDirectory::class, $contribute[LanguageFileDirectory::class . '_0']); self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); self::assertInstanceOf(InstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_2']); @@ -206,12 +158,7 @@ public function testContributedActivitiesAreTheSameSingletonsAsTheProvidedInstan { [, , , $contribute, , $provide] = $this->initComponent(); - // ilLanguageSetupAgent (Setup path) and ilObjLanguageFolderGUI - // (via $provide[InstallLanguage::class]) must operate on the exact - // same InstallLanguage instance, not merely equal ones - and the - // same holds for UpdateLanguage, UninstallLanguage, - // RemoveLocalLanguageChanges, AddLanguageEntry, - // SetLanguageDetectionEnabled and SetLanguageTranslationEnabled. + // the Setup path and the GUI must share the very same Activity instances, not merely equal ones self::assertSame( $provide[InstallLanguage::class], $contribute[\ILIAS\Component\Activities\Activity::class . '_2'] @@ -246,13 +193,7 @@ public function testImplementCandidatesAreOfferedInDeclarationOrder(): void { [, $implement] = $this->initComponent(); - // The Setup-path candidate (\ilSetupLanguage) must be registered - // before the runtime/Init-path candidate - // (LanguageLegacyInitialisationAdapter) - consumers disambiguate - // by position (see cli/build_bootstrap.php / - // Renderer::renderUse()), so a swap here would silently flip which - // candidate every $use[\ILIAS\Language\Language::class] consumer - // gets wired to. + // 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, @@ -303,10 +244,7 @@ private function databaseExpectingQueries(string $class, int $queries): \ilDBInt return $db; } - /** - * Repository and AddLanguageEntry query the pulled connection, never the - * database in $GLOBALS['ilDB']. - */ + /** Repository and AddLanguageEntry query the pulled connection, never $GLOBALS['ilDB']. */ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void { $dic = new \ILIAS\DI\Container(); @@ -318,15 +256,11 @@ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void $this->resolveEverything($provide, $contribute); self::assertSame([], $internal[InstalledLanguageDatabaseRepository::class]->getInstalledLanguages()); - // The default of AddLanguageEntry's module cache update; the public path - // to it also calls the legacy static ilObjLanguage::replaceLangEntry(). + // default of AddLanguageEntry's module cache update, whose public path also calls the static ilObjLanguage::replaceLangEntry() self::readPrivateProperty($internal[AddLanguageEntry::class], 'update_module_cache')('de', 'common', 'x', 'y'); self::assertSame($dic, $GLOBALS['DIC']); } - /** - * Language must not call RBAC->system() while the services are resolved. - */ public function testWiringNeverCallsRbacSystemWhileResolving(): void { $rbac = $this->createMock(\ILIAS\AccessControl\PublicInterface\RBAC::class); @@ -358,9 +292,7 @@ public function offsetGet(mixed $key): mixed self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); } - /** - * @return array}> - */ + /** @return array}> */ public static function setupLanguageActivities(): array { return [ @@ -369,9 +301,6 @@ public static function setupLanguageActivities(): array ]; } - /** - * Not installed, so perform() only reads the installed languages. - */ private function performReadOnly(InstallLanguage|UpdateLanguage $activity): void { $activity->perform( @@ -381,9 +310,7 @@ private function performReadOnly(InstallLanguage|UpdateLanguage $activity): void ); } - /** - * @param class-string $class - */ + /** @param class-string $class */ #[DataProvider('setupLanguageActivities')] public function testWebActivitiesReadTheInstalledLanguagesFromThePulledConnection(string $class): void { diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php index f669fddf5e2f..9aa178d55cbf 100644 --- a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -25,10 +25,7 @@ use PHPUnit\Framework\TestCase; /** - * Unit tests for the RendersActivityErrors trait itself (see its own class - * docblock), in isolation from any concrete GUI class - via - * RendersActivityErrorsTestHost, a minimal class that only mixes in the - * trait and exposes activityErrorMessage() through a public wrapper. + * The RendersActivityErrors trait in isolation, via RendersActivityErrorsTestHost. */ class RendersActivityErrorsTest extends TestCase { @@ -79,22 +76,11 @@ public function testAnAmbiguousLanguageTitleExceptionIsAlsoTreatedAsSafeToDispla $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) ->callActivityErrorMessage($error); - // The domain layer (UninstallLanguage/RemoveLocalLanguageChanges etc.) - // deliberately embeds the raw, unescaped title in the exception - // message - activityErrorMessage() is the one seam that HTML-escapes - // it for safe display, so the '"' characters must come back as - // '"' here. + // the domain layer embeds the raw title in the message: activityErrorMessage() is the one seam that escapes it $this->assertSame('Multiple language objects share the title(s) "fr".', $message); } - /** - * Regression test for the escaping seam itself: a SafeToDisplayActivityError - * message containing genuinely dangerous markup (not just a stray quote) - * must come back HTML-escaped - proving activityErrorMessage() is not - * merely quote-escaping by coincidence but actually protects against - * XSS-shaped content that a caller-controlled language key/title/field - * name could carry. - */ + /** Dangerous markup (not just a stray quote) must come back HTML-escaped. */ public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlEscaped(): void { $logger = $this->createMock(\ilLogger::class); @@ -112,24 +98,12 @@ public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlE $this->assertStringNotContainsString('', $error->getMessage()); } @@ -265,7 +258,7 @@ public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOf $this->assertTrue($result->isError()); $error = $result->error(); $this->assertInstanceOf(InvalidInputException::class, $error); - $this->assertInstanceOf(SafeToDisplayActivityError::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/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index 62819ad8a63c..b5b0f8b3ea1a 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -629,48 +629,6 @@ public function testPermissionDeniedBeforePerform(): void $this->assertTrue($result->isError()); } - /** - * Regression test for LanguageActivity::maybePerformAs()'s generic - * catch(\Throwable) block (InstallLanguage itself does not override - * maybePerformAs() - this exercises the base class implementation): - * a \Throwable that is NOT an \Exception (here a \TypeError, the other - * half of the \Throwable hierarchy) raised from within perform() must - * still come back as a Result\Error carrying an \Exception instance - * (here a \RuntimeException wrapping the original \TypeError as its - * "previous"), per the Activity::maybePerformAs() contract ("Wraps the - * result and possible errors in the Result type"). Before the fix, - * `new Result\Error($e)` itself raised an uncaught - * \InvalidArgumentException for such a $e (Result\Error::__construct() - * only accepts string|\Exception), so maybePerformAs() aborted instead - * of returning a Result at all. - */ - public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInARuntimeExceptionResultError(): void - { - $rbac = $this->createMock(\ilRbacSystem::class); - $rbac->method('checkAccessOfUser')->willReturn(true); - - $setup_language = $this->createSetupLanguageMock([], [], []); - // raised inside perform()'s loop, after isAllowedToPerform() succeeded - $setup_language->method('checkLanguageForInstallation')->willReturnCallback( - static function (): bool { - throw new \TypeError('simulated TypeError, not an \Exception'); - } - ); - - $result = $this->createActivity($setup_language, $rbac)->maybePerformAs( - $this->createRealFieldsUiFactory()->input(), - 6, - ['language_keys' => ['de'], 'mode' => InstallLanguage::MODE_INSTALL] - ); - - $this->assertTrue($result->isError()); - $error = $result->error(); - $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertNotInstanceOf(\TypeError::class, $error); - $this->assertSame('simulated TypeError, not an \Exception', $error->getMessage()); - $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); - } - private function createActivity( ilSetupLanguage $setup_language, ?\ilRbacSystem $rbac = null, diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 4fc3897a4334..d129e9bfe365 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -40,9 +40,10 @@ class LanguageActivityTest extends ilLanguageBaseTestCase use RealFieldsUiFactory; /** - * A \Throwable while building the input description must come back as a Result\Error, without reaching rbac. + * A defect while building the input description comes back masked as 500 (original only as previous), + * without reaching rbac. */ - public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResultErrorNotPropagated(): void + public function testExceptionWhileBuildingTheInputDescriptionIsMaskedAsA500ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); $rbac->expects($this->never())->method('checkAccessOfUser'); @@ -59,36 +60,48 @@ public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResult $this->assertTrue($result->isError()); $error = $result->error(); $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertSame('simulated failure while building the input description', $error->getMessage()); + $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) must arrive wrapped in a \RuntimeException, since Result\Error - * accepts only string|\Exception. - */ - public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntimeExceptionResultError(): void + /** A non-\Exception \Throwable (\TypeError) is masked as well. */ + public function testATypeErrorWhileBuildingTheInputDescriptionIsMaskedAsA500ResultError(): void { - $rbac = $this->createMock(\ilRbacSystem::class); - $rbac->expects($this->never())->method('checkAccessOfUser'); - $activity = new LanguageActivityThrowingInputDescriptionTestDouble( $this->createMock(RefineryFactory::class), $this->createMock(Language::class), - $this->rbacOf($rbac), + $this->rbacOf($this->createMock(\ilRbacSystem::class)), $this->languageFolder(), throw_type_error: true, ); - $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + $error = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [])->error(); - $this->assertTrue($result->isError()); - $error = $result->error(); $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertNotInstanceOf(\TypeError::class, $error); - $this->assertSame('simulated TypeError while building the input description', $error->getMessage()); + $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 LanguageActivityMergePrecedenceTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $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()); + } + /** * additionalPerformParameters() must win over a same-named key from normalizeParameters() in maybePerformAs()'s * array_merge(). Needs a dedicated double, as AddLanguageEntry never collects a 'usr_id' field. @@ -167,6 +180,16 @@ protected function normalizeParameters(array $grind_result): array */ final class LanguageActivityMergePrecedenceTestDouble extends LanguageActivity { + public function __construct( + RefineryFactory $refinery, + Language $language, + RBAC $rbac, + LanguageFolder $language_folder, + private readonly ?FormInput $description = null, + ) { + parent::__construct($refinery, $language, $rbac, $language_folder); + } + public function getDescription(): Text\SimpleDocumentMarkdown { return $this->markdown('Test double for maybePerformAs() array_merge() precedence.'); @@ -174,7 +197,7 @@ public function getDescription(): Text\SimpleDocumentMarkdown public function getInputDescription(FieldFactory $f): FormInput { - return $f->group([]); + return $this->description ?? $f->group([]); } public function getOutputDescription(Description\Factory $f): Description\Description diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index 84137def9653..b2c37d257179 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -19,7 +19,6 @@ use ILIAS\Language\Activities\AmbiguousLanguageTitleException; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\RemoveLocalLanguageChanges; -use ILIAS\Language\Activities\SafeToDisplayActivityError; use ILIAS\Language\Language; use ILIAS\Refinery\Factory as RefineryFactory; use ILIAS\UI\Component\Input\Factory as InputFactory; @@ -545,8 +544,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void * exception class, not just \InvalidArgumentException (which * InvalidInputException extends, and against which this assertion would * therefore have kept passing silently even after the behaviour change - - * see RendersActivityErrors, which treats InvalidInputException/ - * SafeToDisplayActivityError specially). + * see RendersActivityErrors, which treats 4xx specially). */ public function testNonArrayParametersAreRejected(): void { @@ -666,7 +664,7 @@ public function testThrowableFromPerformIsTurnedIntoAResultError(): void $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index 09b154b43a26..e9622073a67a 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -17,7 +17,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; -use ILIAS\Language\Activities\SafeToDisplayActivityError; use ILIAS\Language\Activities\SetLanguageDetectionEnabled; use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Checkbox; @@ -207,13 +206,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWrites $this->assertTrue($result->isError()); } - /** - * A Throwable raised from within perform() (or normalizeParameters()) - * must be turned into a Result\Error rather than propagating - this is - * the whole point of maybePerformAs() wrapping perform() in a - * try/catch. - */ - public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoAResultError(): void + public function testMaybePerformAsRejectsAnEnabledValueOutsideTheCheckboxWhitelistAsA400WithoutTouchingSettings(): void { $rbac = $this->createMock(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); @@ -227,7 +220,7 @@ public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoARes $this->assertTrue($result->isError()); // outside the whitelist of normalizeCheckboxRawValue(): grind() turns it into an InvalidInputException $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** @@ -345,7 +338,7 @@ public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWi $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } public function testInputDescriptionBuildsAGroupWithASingleEnabledCheckboxField(): void diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index 261090a99157..4a6b59d9ac12 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -17,7 +17,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; -use ILIAS\Language\Activities\SafeToDisplayActivityError; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Text; @@ -251,7 +250,8 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); $this->assertTrue($result->isError()); - $this->assertSame('no write permission', $result->error()); + $this->assertInstanceOf(\DomainException::class, $result->error()); + $this->assertSame(403, $result->error()->getCode()); } /** @@ -260,7 +260,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche * rather than propagating - this is the whole point of maybePerformAs() * wrapping perform() in a try/catch. */ - public function testThrowableFromWithinPerformIsTurnedIntoAResultError(): void + public function testDefectFromWithinPerformIsMaskedAsA500ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); @@ -274,8 +274,8 @@ public function testThrowableFromWithinPerformIsTurnedIntoAResultError(): void $this->assertTrue($result->isError()); $error = $result->error(); - $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertSame('database write failed', $error->getMessage()); + $this->assertSame(500, $error->getCode()); + $this->assertSame('database write failed', $error->getPrevious()?->getMessage()); } /** @@ -353,7 +353,7 @@ public function testMaybePerformAsRejectsInvalidRawParametersAsResultErrorWithou $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** @@ -408,7 +408,7 @@ public function testMaybePerformAsRejectsWhitespaceOnlyLanguageKeyAfterThePermis $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } public static function tolerantlyAcceptedTruthyEnabledValuesProvider(): array @@ -548,7 +548,7 @@ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckA $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php index 9aa178d55cbf..e5d3504d874f 100644 --- a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -21,7 +21,9 @@ namespace ILIAS\Language; use ILIAS\Language\Activities\AmbiguousLanguageTitleException; +use ILIAS\Language\Activities\Failure; use ILIAS\Language\Activities\InvalidInputException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @@ -42,64 +44,52 @@ private function languageMockReturningTopicAsIs(): \ilLanguage return $lng; } - public function testAPlainStringErrorIsReturnedAsIsAndNeverLogged(): void + public function testCode403ShowsTheNoPermissionMessageAndIsNeverLogged(): void { $logger = $this->createMock(\ilLogger::class); $logger->expects($this->never())->method('error'); $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) - ->callActivityErrorMessage('msg_no_perm_write'); + ->callActivityErrorMessage(new \DomainException('internal wording ', 403)); $this->assertSame('msg_no_perm_write', $message); } - public function testASafeToDisplayActivityErrorMessageIsReturnedAsIsAndNeverLogged(): void + /** @return array>|list> */ + public static function clientErrorProvider(): array { - $logger = $this->createMock(\ilLogger::class); - $logger->expects($this->never())->method('error'); - - $error = new InvalidInputException('language_keys: not_min_length'); - - $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) - ->callActivityErrorMessage($error); - - $this->assertSame('language_keys: not_min_length', $message); + 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>'], + ]; } - public function testAnAmbiguousLanguageTitleExceptionIsAlsoTreatedAsSafeToDisplayAndNeverLogged(): void + /** 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'); - $error = new AmbiguousLanguageTitleException('Multiple language objects share the title(s) "fr".'); - $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) ->callActivityErrorMessage($error); - // the domain layer embeds the raw title in the message: activityErrorMessage() is the one seam that escapes it - $this->assertSame('Multiple language objects share the title(s) "fr".', $message); - } - - /** Dangerous markup (not just a stray quote) must come back HTML-escaped. */ - public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlEscaped(): void - { - $logger = $this->createMock(\ilLogger::class); - $logger->expects($this->never())->method('error'); - - $error = new InvalidInputException('Unknown key(s) for & "quoted" \'single\'.'); - - $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) - ->callActivityErrorMessage($error); - - $this->assertSame( - 'Unknown key(s) for <script>alert(1)</script> & "quoted" 'single'.', - $message - ); - $this->assertStringNotContainsString('&"quoted"'; try { - $this->createActivity(settings: $settings) + $this->createActivity(repository: $repository) ->perform(['language_key' => $dangerous_key, 'enabled' => true]); $this->fail('Expected an InvalidInputException to be thrown.'); } catch (InvalidInputException $e) { @@ -536,11 +521,11 @@ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckA $rbac = $this->createMock(\ilRbacSystem::class); $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); - $settings = $this->createMock(Setting::class); - $settings->expects($this->never())->method('get'); - $settings->expects($this->never())->method('set'); + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('isPageTranslationEnabled'); + $repository->expects($this->never())->method('setPageTranslationEnabled'); - $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'xx', 'enabled' => true]); $this->assertTrue($result->isError()); @@ -561,11 +546,11 @@ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): $rbac = $this->createMock(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); - $settings = $this->createMock(Setting::class); - $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); - $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + $repository = $this->createMock(RepositoryProvider::class); + $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true); - $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => ' de ', 'enabled' => true]); $this->assertFalse($result->isError()); @@ -635,18 +620,19 @@ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): private function createActivity( ?\ilRbacSystem $rbac_system = null, - ?Setting $settings = null, + ?RepositoryProvider $repository = null, ?Language $language = null, int $language_folder_ref_id = 0, - ?InstalledLanguageRepository $installed_language_repository = null, + array $installed_languages = ['de', 'fr'], ): SetLanguageTranslationEnabled { + $repository ??= $this->createMock(RepositoryProvider::class); + $repository->method('getInstalledLanguages')->willReturn($installed_languages); + return new SetLanguageTranslationEnabled( $language ?? $this->createMock(Language::class), $this->rbacOf($rbac_system ?? $this->createMock(\ilRbacSystem::class)), - $settings ?? $this->createMock(Setting::class), - installed_language_repository: $installed_language_repository - ?? new FakeInstalledLanguageRepository(static fn(): array => ['de', 'fr']), - language_folder: $this->languageFolder($language_folder_ref_id), + $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 index 3a69d2e05264..0437c35c6d3e 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -36,6 +36,8 @@ */ class UninstallLanguageTest extends ActivityWithPerformResultContractTestCase { + use LanguageObjectRepositoryProvider; + protected function createDefaultActivity(): UninstallLanguage { [, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld(['de' => []]); @@ -677,8 +679,7 @@ private function createActivity( $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder(), - $lng_objects, - $obj_language_factory + $this->languageObjectProvider($lng_objects, $obj_language_factory) ); } } diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index 14da7e83a905..84a884e66bc8 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -24,6 +24,7 @@ use ILIAS\Language\Activities\UninstallLanguage; use ILIAS\Language\Activities\RemoveLocalLanguageChanges; use ILIAS\Language\Activities\AddLanguageEntry; +use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Activities\SetLanguageDetectionEnabled; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; @@ -244,7 +245,7 @@ private function databaseExpectingQueries(string $class, int $queries): \ilDBInt return $db; } - /** Repository and AddLanguageEntry query the pulled connection, never $GLOBALS['ilDB']. */ + /** Repository and RepositoryProvider query the pulled connection, never $GLOBALS['ilDB']. */ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void { $dic = new \ILIAS\DI\Container(); @@ -256,8 +257,8 @@ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void $this->resolveEverything($provide, $contribute); self::assertSame([], $internal[InstalledLanguageDatabaseRepository::class]->getInstalledLanguages()); - // default of AddLanguageEntry's module cache update, whose public path also calls the static ilObjLanguage::replaceLangEntry() - self::readPrivateProperty($internal[AddLanguageEntry::class], 'update_module_cache')('de', 'common', 'x', 'y'); + // 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']); } From d18dc7c474e48a1d3da226984e5cc7dbafb0eed0 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 19 Oct 2026 11:50:55 +0200 Subject: [PATCH 015/103] Language: Mask database errors whose code is a SQLSTATE string A `PDOException` carries its SQLSTATE as string code, and `'42S02'` compares as a 4xx code, so `Failure::of()` and `RendersActivityErrors` showed the database message as input error. Both now require an integer code. The test doubles no longer call the removed `markdown()` helper. --- .../ILIAS/Language/src/Activities/Failure.php | 4 +++- .../Language/src/RendersActivityErrors.php | 4 +++- .../Language/tests/Activities/FailureTest.php | 14 +++++++++++++ .../tests/Activities/LanguageActivityTest.php | 14 ++++++------- .../Activities/UninstallLanguageTest.php | 10 ++-------- .../tests/RendersActivityErrorsTest.php | 20 +++++++++++++++++++ 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/components/ILIAS/Language/src/Activities/Failure.php b/components/ILIAS/Language/src/Activities/Failure.php index 630365328cf3..ab8c80a0b893 100644 --- a/components/ILIAS/Language/src/Activities/Failure.php +++ b/components/ILIAS/Language/src/Activities/Failure.php @@ -28,7 +28,9 @@ final class Failure { public static function of(\Throwable $e): \Exception { - if ($e instanceof \Exception && $e->getCode() >= 400 && $e->getCode() < 500) { + // is_int(): a PDOException carries its SQLSTATE as string, and '42S02' compares as 4xx + $code = $e->getCode(); + if ($e instanceof \Exception && is_int($code) && $code >= 400 && $code < 500) { return $e; } diff --git a/components/ILIAS/Language/src/RendersActivityErrors.php b/components/ILIAS/Language/src/RendersActivityErrors.php index 5dba8d4ad283..a7a7e1c58fa6 100644 --- a/components/ILIAS/Language/src/RendersActivityErrors.php +++ b/components/ILIAS/Language/src/RendersActivityErrors.php @@ -53,7 +53,9 @@ private function activityErrorMessage(\Throwable $error): string /** A 4xx other than 403: the call itself was rejected, its message is meant for the caller. */ private function isActivityInputError(\Throwable $error): bool { - return $error->getCode() >= 400 && $error->getCode() < 500 && $error->getCode() !== 403; + $code = $error->getCode(); + + return is_int($code) && $code >= 400 && $code < 500 && $code !== 403; } /** diff --git a/components/ILIAS/Language/tests/Activities/FailureTest.php b/components/ILIAS/Language/tests/Activities/FailureTest.php index 532cd0a3c56b..43a75f656087 100644 --- a/components/ILIAS/Language/tests/Activities/FailureTest.php +++ b/components/ILIAS/Language/tests/Activities/FailureTest.php @@ -56,9 +56,23 @@ public static function defectProvider(): array '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 { diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 2c277e36c470..7bf7bf7bb6f2 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -99,20 +99,20 @@ public function testADefectThrownByGrindIsMaskedAsA500ResultError(): void $this->assertInstanceOf(\LogicException::class, $error->getPrevious()); } - /** @return array */ + /** @return array */ public static function unknownUserIdProvider(): array { - return ['zero' => [0], 'negative' => [-1], 'anonymous' => [13]]; + return ['zero' => [0], 'negative' => [-1], 'anonymous' => [null]]; } - /** No RBAC lookup for a non-user: the anonymous user (13 in ILIAS) must not pass on its roles. */ + /** 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 + public function testIsAllowedToPerformRefusesNonUsersWithoutAskingRbac(?int $usr_id): void { if (!defined('ANONYMOUS_USER_ID')) { define('ANONYMOUS_USER_ID', 13); } - $this->assertSame(13, ANONYMOUS_USER_ID); + $usr_id ??= ANONYMOUS_USER_ID; $rbac_system = $this->createMock(\ilRbacSystem::class); $rbac_system->expects($this->never())->method('checkAccessOfUser'); @@ -184,7 +184,7 @@ public function __construct( public function getDescription(): Text\SimpleDocumentMarkdown { - return $this->markdown('Test double for LanguageActivity::maybePerformAs().'); + return $this->data->text()->markdown()->simpleDocument('Test double for LanguageActivity::maybePerformAs().'); } public function getInputDescription(FieldFactory $f): FormInput @@ -229,7 +229,7 @@ public function __construct( public function getDescription(): Text\SimpleDocumentMarkdown { - return $this->markdown('Test double for the usr_id precedence in maybePerformAs().'); + return $this->data->text()->markdown()->simpleDocument('Test double for the usr_id precedence in maybePerformAs().'); } public function getInputDescription(FieldFactory $f): FormInput diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index 0437c35c6d3e..612a964ed652 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -25,14 +25,8 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * UninstallLanguage has no ilSetupLanguage collaborator (unlike - * InstallLanguage/UpdateLanguage) - instead it resolves language keys to - * objects via two closures, $lng_objects and $obj_language_factory. The - * object returned by $obj_language_factory must behave like \ilObjLanguage - * (isSystemLanguage(), isUserLanguage(), isInstalled(), uninstall()), but - * \ilObjLanguage itself cannot be constructed or meaningfully mocked in a - * unit test (its constructor needs the global $DIC) - so tests use a small - * fake object instead, injected through $obj_language_factory. + * UninstallLanguage works on language objects behind a mocked RepositoryProvider, answering from small fake + * objects (\ilObjLanguage needs the global $DIC) via LanguageObjectRepositoryProvider. */ class UninstallLanguageTest extends ActivityWithPerformResultContractTestCase { diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php index e5d3504d874f..854f062b5c9a 100644 --- a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -105,6 +105,17 @@ public function testA4xxErrorMessageWithInvalidUtf8IsNotSilentlyDroppedToAnEmpty $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 { @@ -112,6 +123,8 @@ public static function nonClientErrorProvider(): array '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')], ]; } @@ -153,6 +166,13 @@ 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 { From f614be7b88e926d2819a83d0e0bda8a7ca7514dc Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 19 Oct 2026 11:55:30 +0200 Subject: [PATCH 016/103] Language: Shorten the comments of the Activities, their wiring and tests Keeps only what the code does not say itself, as in the File component: the reasons in `Language.php` shrink to the two real traps (no `final` Activities, the order of the contributions), and the tests lose their history notes. --- components/ILIAS/Language/Language.php | 76 +-- .../src/Activities/AddLanguageEntry.php | 9 +- .../Language/src/RendersActivityErrors.php | 4 +- .../Activities/ActivityContractTestCase.php | 10 +- .../tests/Activities/AddLanguageEntryTest.php | 146 +---- .../tests/Activities/GrindsFormInputTest.php | 30 +- .../tests/Activities/InstallLanguageTest.php | 81 +-- .../tests/Activities/LanguageActivityTest.php | 3 - .../RemoveLocalLanguageChangesTest.php | 209 +------ .../SetLanguageDetectionEnabledTest.php | 53 +- .../SetLanguageTranslationEnabledTest.php | 123 +--- .../Activities/UninstallLanguageTest.php | 161 +----- .../tests/Activities/UpdateLanguageTest.php | 29 +- .../tests/ilLanguageSetupAgentTest.php | 3 - .../tests/ilObjLanguageExtGUITest.php | 155 +---- .../tests/ilObjLanguageFolderGUITest.php | 530 ++---------------- 16 files changed, 194 insertions(+), 1428 deletions(-) diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 7d54748dcbd3..cc467bdec4de 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -49,9 +49,7 @@ public function init( ): void { $define[] = \ILIAS\Language\Language::class; - // --- $internal: wiring local to this component ----------------- - // Ordered so each entry's dependencies are declared above it. - + // each entry's dependencies are declared above it $internal[Language\LanguageFolder::class] = static fn() => new Language\LanguageFolderLegacyProxy(); @@ -61,9 +59,6 @@ public function init( ...$seek[LanguageFileDirectory::class] ); - // Read access to the language installation domain, extracted from - // ilSetupLanguage per docs/development/repository-pattern.md - see that - // class' docblock. $internal[InstalledLanguageDatabaseRepository::class] = static fn() => new InstalledLanguageDatabaseRepository( $pull[\ILIAS\Database\Connection::class], @@ -71,8 +66,7 @@ public function init( $use[Directories::class]->getRoot() ); - // Setup instance: Setup hands in its own database via setDbHandler() - // instead of the Connection (see components/ILIAS/Database/ROADMAP.md). + // Setup hands in its own database via setDbHandler() instead of the Connection $internal[\ilSetupLanguage::class] = static fn() => new \ilSetupLanguage( "en", @@ -102,7 +96,7 @@ public function init( $internal[Language\LanguageFolder::class] ); - // runtime-only Activities (no Setup counterpart): all their legacy access goes through this provider + // runtime-only Activities route all legacy access through this provider $internal[RepositoryProvider::class] = static fn() => new RepositoryProvider( $internal[InstalledLanguageDatabaseRepository::class], @@ -149,48 +143,22 @@ public function init( $internal[RepositoryProvider::class], ); - // LanguageLegacyInitialisationAdapter has no constructor of its own, - // so it never needs the LanguageFileDirectoryManager argument - - // it purely proxies to $DIC->language() at call time. This slot used - // to be misleadingly named $internal[\ilLanguage::class] even though - // it never held an \ilLanguage instance. + // only proxies to $DIC->language() at call time, hence no constructor arguments $internal[Language\LanguageLegacyInitialisationAdapter::class] = static fn() => new Language\LanguageLegacyInitialisationAdapter(); - // --- $implement -------------------------------------------------- - - // This component registers TWO candidate implementations for - // \ILIAS\Language\Language. Both assignments below are intentional - - // ILIAS\Component\Dependencies\Reader::cacheImplement() collects every - // $implement[...] assignment into a list rather than overwriting a - // plain array key, and the generated RenamingDIC keeps each candidate - // under its own offset, so neither line is dead code. - // Consumers that $use[\ILIAS\Language\Language::class] (e.g. UI.php, - // Setup.php, Refinery.php) are disambiguated per bootstrap entry point: - // - components/ILIAS/Setup/resources/dependency_resolution.php -> ilSetupLanguage - // - components/ILIAS/Init/resources/dependency_resolution.php -> LanguageLegacyInitialisationAdapter - // See docs/development/components-and-directories.md and cli/build_bootstrap.php. + // 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[Language\LanguageLegacyInitialisationAdapter::class]; - // --- $provide: services made available to the rest of the system - - - // Make the resolved language services available outside this component. $provide[LanguageFileDirectoryManager::class] = static fn() => $internal[LanguageFileDirectoryManager::class]; - // --- $contribute: contributions to other components' collection - // points. Relative order preserved from before this file's - // reorganisation, since components may in general add multiple - // contributions to the same collection point sequentially (see - // docs/development/components-and-directories.md, "Contribute to - // Service or Functionality") - which the seven - // \ILIAS\Component\Activities\Activity::class entries below - // actually do, one per Activity this component offers. - $contribute[LanguageFileDirectory::class] = static fn() => new MainLanguageFileDirectory(); $contribute[\ILIAS\Setup\Agent::class] = static fn() => @@ -202,32 +170,10 @@ public function init( UpdateLanguage::forSetup($internal[\ilSetupLanguage::class]) ); - // The seven Activities this component offers are both provided under - // their own concrete class (so components/ILIAS/Init/Init.php can - // pull each one specifically - AllModernComponents.php then - // re-exposes that same resolved instance under the matching legacy - // $DIC[::class] key, which is what every GUI class in - // this component reads) and contributed generically to - // \ILIAS\Component\Activities\Activity::class (so the - // cross-component Repository can discover them too) - see - // docs/development/components-and-directories.md, "Contribute to - // Service or Functionality". Because Init.php pulls these concrete - // classes (not an interface), none of the seven may become final - both - // ilObjLanguageFolderGUITest and ilObjLanguageExtGUITest build a - // PHPUnit mock of several of them directly (createMock(InstallLanguage::class) - // and siblings), which requires PHPUnit to generate a subclass at - // runtime - impossible for a final class. - // - // Kept as one combined loop rather than seven repeated - // $provide[...]/$contribute[...] pairs. Order matters for the - // $contribute side only (RenamingDIC assigns a shared, container-wide - // "_" suffix in call order to disambiguate the repeated - // \ILIAS\Component\Activities\Activity::class key - see - // LanguageComponentGraphTest::testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder()); - // this list's order must therefore match $activities' declaration - // order exactly, and this loop must stay exactly here: after the two - // preceding $contribute entries (LanguageFileDirectory, Setup\Agent) - // and before the following one (UserSettings). + // Provided under the concrete class because Init.php pulls it, so none may become final + // (the GUI tests mock them). The $contribute order is the declaration order below and must + // stay between the Setup\Agent and the UserSettings entry: RenamingDIC numbers the repeated + // Activity key in call order (see LanguageComponentGraphTest). $activities = [ InstallLanguage::class, UpdateLanguage::class, diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php index c53e9b85d909..74e602fd14f8 100644 --- a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -56,17 +56,14 @@ public function getDescription(): Text\SimpleDocumentMarkdown } /** - * Must keep exactly `Activity::getInputDescription(FieldFactory $f): FormInput`'s signature: an - * optional extra parameter here would make a subclass using the plain signature a variance fatal. - * So the installed languages are resolved here, and may differ from perform()'s snapshot if a - * language is (un)installed in between; perform() fails closed for "de"/"en", no partial data. + * 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 (not loaded here: it would clobber keys - // of other modules in the shared $lng); otherwise the placeholder "-meta_l_de-" shows + // 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.' diff --git a/components/ILIAS/Language/src/RendersActivityErrors.php b/components/ILIAS/Language/src/RendersActivityErrors.php index a7a7e1c58fa6..489537cbefaf 100644 --- a/components/ILIAS/Language/src/RendersActivityErrors.php +++ b/components/ILIAS/Language/src/RendersActivityErrors.php @@ -58,9 +58,7 @@ private function isActivityInputError(\Throwable $error): bool return is_int($code) && $code >= 400 && $code < 500 && $code !== 403; } - /** - * Renders $error and every exception it was caused by (getPrevious()), so a wrapper never hides the original. - */ + /** Includes every getPrevious(), so a wrapper never hides the original. */ private function describeThrowableChain(\Throwable $error): string { $descriptions = []; diff --git a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php index 5c8fe85acd5c..91f79a0a1aa8 100644 --- a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php +++ b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php @@ -59,10 +59,7 @@ public function testGetDescriptionReturnsANonEmptyMarkdownDocument(): void $this->assertNotSame('', $this->createDefaultActivity()->getDescription()->getRawRepresentation()); } - /** - * No extra parameter, even an optional one: a parent declaring more parameters than a subclass with the - * plain interface signature is a PHP variance fatal error at class-load time. - */ + /** An extra parameter, even optional, is a variance fatal error at class-load time. */ public function testGetInputDescriptionSignatureExactlyMatchesTheActivityInterfaceSignature(): void { $activity = $this->createDefaultActivity(); @@ -91,9 +88,8 @@ public function testGetInputDescriptionSignatureExactlyMatchesTheActivityInterfa } /** - * A RepositoryProvider mock answering per obj_id from small fake language objects (the legacy \ilObjLanguage - * cannot be built without $DIC). The factory is only asked when the Activity actually touches an object, so - * a factory that throws proves an object was never reached. + * 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 { diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php index 6f5d29d6caa0..f19784e3a3e3 100644 --- a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -35,9 +35,7 @@ use PHPUnit\Framework\Attributes\RunInSeparateProcess; /** - * AddLanguageEntry reads installed languages, writes entries and resolves the acting user's login (audit trail) - * through the RepositoryProvider, mocked here and answering from the closures of createActivity(). No test touches - * a real database or lang file. + * Mocks the RepositoryProvider, answering from the closures of createActivity(); no real database or lang file. */ class AddLanguageEntryTest extends ActivityContractTestCase { @@ -46,10 +44,6 @@ protected function createDefaultActivity(): AddLanguageEntry return $this->createActivity([]); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -100,10 +94,6 @@ public function testIsAllowedToPerformNeverTouchesRepositoryOrWriteClosures(): v $this->assertTrue($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); } - // ----------------------------------------------------------------- - // perform() - // ----------------------------------------------------------------- - public function testAddsEntryForEachInstalledLanguageWithANonEmptyValueAndSkipsEmptyOnes(): void { $calls = []; @@ -170,12 +160,7 @@ public function testAddsEntryForEachInstalledLanguageWithANonEmptyValueAndSkipsE } } - /** - * Regression test for the deliberate "not transactional" behaviour noted above perform()'s - * write loop: if replace_lang_entry() throws partway through (here: the 3rd of 3 languages), - * languages already written must remain written, and the exception must propagate rather than - * roll back. - */ + /** "Not transactional" is deliberate: languages written before the failing one stay written, the exception propagates. */ public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEarlierLanguagesWritten(): void { $calls = []; @@ -215,10 +200,7 @@ public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEar } catch (\RuntimeException $e) { $this->assertSame('simulated database error while writing "fr"', $e->getMessage()); - // "de" and "en" - written before the failing "fr" - remain - // written: perform() gives no atomicity guarantee across the - // write loop (see perform()'s own "Not transactional..." - // comment above the loop). + // "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] @@ -230,18 +212,12 @@ public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEar $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]); $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]); - // Exactly the 3 attempted calls - "fr" itself was attempted - // (and recorded before throwing) but never reached - // update_module_cache(). + // "fr" was attempted but never reached update_module_cache() $this->assertCount(3, $calls['replace']); $this->assertCount(2, $calls['cache']); } } - // ----------------------------------------------------------------- - // "de"/"en" mandatory, all-or-nothing (see getDescription()'s markdown) - // ----------------------------------------------------------------- - public function testDeAndEnBothInstalledOneBlankRejectsWholeRequestAndWritesNothingForAnyLanguage(): void { $calls = []; @@ -269,8 +245,7 @@ public function testDeAndEnBothInstalledOneBlankRejectsWholeRequestAndWritesNoth } } - // Same de/en rule as the perform() test above, exercised via maybePerformAs() with a real UI - // Factory. + // Same de/en rule, exercised via maybePerformAs() with a real UI Factory. public function testMaybePerformAsRejectsWholeRequestAndWritesNothingWhenEnIsBlankWhileDeIsValid(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -388,10 +363,7 @@ public function testValueSurroundedByWhitespaceIsTrimmedBeforeBeingWritten(): vo $this->assertSame('Hallo Welt', $calls['cache'][0][3]); } - /** - * A non-string translation value (e.g. from a direct perform() caller bypassing - * normalizeParameters()) must be treated as empty, not coerced or crash. - */ + /** 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. @@ -456,8 +428,7 @@ public function testUserLoginClosureIsCalledExactlyOnceWithTheGivenUsrIdRegardle $this->assertSame([99], $login_calls); } - // A missing usr_id is explicitly allowed (see perform()'s own docblock): a generic caller - // following the plain Activity contract never supplies one. + // a missing usr_id is allowed: a generic caller following the plain Activity contract never supplies one public function testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure(): void { $login_calls = []; @@ -498,10 +469,7 @@ public static function invalidPerformParametersProvider(): array '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 explicitly ALLOWED (see - // testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure() - // and testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations() below) - it must - // NOT appear here as an "invalid" case. Only a non-null usr_id of the wrong TYPE is invalid. + // 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']], ]; } @@ -514,10 +482,7 @@ public function testPerformRejectsInvalidOrIncompleteParameters(array $parameter $this->createActivity(['de'])->perform($parameters); } - /** - * A missing or null usr_id, given otherwise-valid translations, must succeed - not throw (see - * perform()'s own docblock: only a non-null usr_id of the wrong type is rejected). - */ + /** A missing or null usr_id with otherwise valid translations succeeds. */ public static function validUsrIdMissingOrNullWithValidTranslationsProvider(): array { return [ @@ -554,16 +519,7 @@ public function testPerformRejectsNonArrayParameters(): void $this->createActivity(['de'])->perform('not-an-array'); } - // ----------------------------------------------------------------- - // Snapshot drift between getInputDescription() and perform() - see - // getInputDescription()'s own docblock. - // ----------------------------------------------------------------- - - /** - * The fake repository below returns different installed-language lists on its 1st - * (getInputDescription()) and 2nd (perform()) call, modeling this snapshot drift within one - * maybePerformAs() call. - */ + /** The fake repository returns different installed-language lists for getInputDescription() and perform(). */ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerformIsSkippedNotRejected(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -572,9 +528,7 @@ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerfor $calls = 0; $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { $calls++; - // 1st call: getInputDescription() - 'fr' not installed yet, so no - // field/value for it exists in the form. - // 2nd call: perform() - 'fr' has since been installed. + // 1st call (getInputDescription()): 'fr' not installed yet; 2nd call (perform()): it is return $calls === 1 ? ['de', 'en'] : ['de', 'en', 'fr']; }); @@ -587,8 +541,7 @@ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerfor $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ 'module' => 'common', 'identifier' => 'new_topic', - // No 'fr' key at all - getInputDescription() (1st call) never - // asked for one, since it did not yet know 'fr' was installed. + // no 'fr' key: the form was built before 'fr' was installed 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], ]); @@ -600,8 +553,7 @@ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerfor $this->assertSame(2, $calls); } - // Fail-closed branch: "en" becomes mandatory only after getInputDescription() already built - // the form without asking for it - the whole request must be rejected, not partially skipped. + // fail-closed: "en" becomes mandatory only after the form was built without it public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWholeRequestRatherThanSkippingEn(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -610,9 +562,7 @@ public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWho $calls = 0; $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { $calls++; - // 1st call: getInputDescription() - only "de" installed, "en" not - // yet, so no field/value for "en" exists in the form. - // 2nd call: perform() - "en" has since been installed. + // 1st call: only "de" installed; 2nd call: "en" too return $calls === 1 ? ['de'] : ['de', 'en']; }); @@ -628,16 +578,14 @@ public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWho $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ 'module' => 'common', 'identifier' => 'new_topic', - // No 'en' key - getInputDescription() (1st call) never asked for - // one, since "en" was not yet installed when the form was built. + // 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 - "de" has a valid value, so the mandatory-value check - // doesn't flag it too. + // 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); @@ -646,13 +594,7 @@ public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWho $this->assertSame(2, $calls); } - /** - * The opposite direction from the two tests above: an OPTIONAL language uninstalled between - * the two calls has a submitted value that is neither written nor rejected - it is silently - * dropped, unlike installing "de"/"en" (fail-closed) or installing an optional language - * (skipped normally). "fr" isolates this from the separate "de"/"en" mandatory rule tested - * elsewhere. - */ + /** An optional language uninstalled between the two calls: its submitted value is silently dropped. */ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerformIsSilentlyDroppedNotWritten(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -661,9 +603,7 @@ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerf $calls = 0; $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { $calls++; - // 1st call: getInputDescription() - "fr" still installed, so a - // field/value for it exists in the form. - // 2nd call: perform() - "fr" has since been uninstalled. + // 1st call: "fr" installed; 2nd call: uninstalled return $calls === 1 ? ['de', 'en', 'fr'] : ['de', 'en']; }); @@ -679,8 +619,7 @@ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerf $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ 'module' => 'common', 'identifier' => 'new_topic', - // A valid value IS given for "fr" - getInputDescription() (1st - // call) still asked for it, since "fr" was installed at the time. + // a value for "fr" is given: the form still asked for it 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => 'Bonjour'], ]); @@ -700,10 +639,6 @@ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerf $this->assertSame(2, $calls); } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - public function testPermissionDeniedBeforePerformNeverCallsWriteClosuresAndReturnsA403ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -877,12 +812,7 @@ public function testMissingRawParametersKeyIsAResultError(): void $this->assertNotSame('', $result->error()->getMessage()); } - /** - * Both rejected by grind() itself, before normalizeParameters()/isAllowedToPerform() run, but - * for different reasons: a non-array 'translations' fails collectRawValues()'s type guard on - * the nested group; a non-string key can never match any of the group's string-keyed fields. - * Either way: an InvalidInputException, not an InvalidArgumentException. - */ + /** Rejected by grind() itself (non-array 'translations', non-string key) as InvalidInputException. */ public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array { return [ @@ -959,11 +889,7 @@ public function testNormalizeParametersTrimsModuleAndIdentifierButKeepsTranslati $this->assertSame('new_topic', $calls['replace'][0][1]); } - /** - * An empty translations array is a legal input (e.g. a caller merely - * reserving the module/identifier pair without giving any value yet) - - * every installed language is then reported skipped, not rejected. - */ + /** 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. @@ -983,16 +909,9 @@ public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLangua $this->assertSame(['fr', 'it'], $result->value()['skipped_empty_language_keys']); } - // ----------------------------------------------------------------- - // getInputDescription() - // ----------------------------------------------------------------- - /** - * End-to-end companion to ActivityContractTestCase's reflection-based signature pin: actually - * defines a subclass overriding getInputDescription() with the plain interface signature, - * reproducing the PHP fatal error the old, buggy signature triggered (verified under PHP - * 8.5.4). A PHP variance fatal is a compile-time error, not a catchable \Throwable, hence - * #[RunInSeparateProcess] - so a regression fails this one test instead of crashing the suite. + * 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 @@ -1113,10 +1032,6 @@ function (array $fields, string $label) use (&$section_calls, $module_text, $ide $this->assertFalse($fr_required); } - // ----------------------------------------------------------------- - // getOutputDescription() - // ----------------------------------------------------------------- - public function testOutputDescriptionDeclaresTheExpectedFourFields(): void { $activity = $this->createActivity(['de']); @@ -1134,10 +1049,6 @@ public function testOutputDescriptionDeclaresTheExpectedFourFields(): void ); } - // ----------------------------------------------------------------- - // Test helpers - // ----------------------------------------------------------------- - /** * @param array $calls */ @@ -1184,9 +1095,7 @@ private function createActivity( ?\Closure $update_module_cache = null, ?\Closure $user_login = null, ): AddLanguageEntry { - // Safe no-op defaults (never the real \ilObjLanguage-backed closures, which need global - // $DIC). user_login() runs unconditionally in perform(), so it always needs one too. - // Tests that must prove a closure is never called pass their own throwing closure instead. + // 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, @@ -1223,12 +1132,7 @@ private function createActivity( } /** - * Minimal in-memory stand-in for InstalledLanguageRepository: - * InstalledLanguageDatabaseRepository (the real implementation) needs an - * actual database connection, which unit tests must never construct. Only - * getInstalledLanguages() is exercised by AddLanguageEntry; every other - * method is unreachable from it and simply throws if a test ever calls it by - * mistake. + * In-memory stand-in for InstalledLanguageRepository; only getInstalledLanguages() is reachable, the rest throws. */ final class FakeInstalledLanguageRepository implements InstalledLanguageRepository { diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php index 71cfddbc0717..0b4f3ce59d81 100644 --- a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php @@ -27,9 +27,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -/** - * GrindsFormInput in isolation from any concrete Activity, via GrindsFormInputTestHost. - */ class GrindsFormInputTest extends TestCase { use RealFieldsUiFactory; @@ -100,10 +97,7 @@ public static function checkboxWhitelistRejectsRawValueProvider(): array ]; } - /** - * Values outside the whitelist must become an InvalidInputException (code 400), which - * RendersActivityErrors shows to the user instead of logging an internal failure. - */ + /** Becomes an InvalidInputException (400), shown to the user instead of logged. */ #[DataProvider('checkboxWhitelistRejectsRawValueProvider')] public function testGrindRejectsRawValueOutsideTheCheckboxWhitelist(mixed $raw_value): void { @@ -115,9 +109,7 @@ public function testGrindRejectsRawValueOutsideTheCheckboxWhitelist(mixed $raw_v $this->assertSame(400, $error->getCode()); } - /** - * Same conversion, reached via Text::isClientSideValueOk() instead of normalizeCheckboxRawValue(). - */ + /** Same conversion, reached via Text::isClientSideValueOk(). */ public function testGrindConvertsAUiFrameworkInvalidArgumentExceptionFromANonStringTextValueIntoInvalidInputException(): void { $text = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '') @@ -135,9 +127,7 @@ public function testGrindConvertsAUiFrameworkInvalidArgumentExceptionFromANonStr $this->assertSame(400, $error->getCode()); } - /** - * A validation failure must read ": ", not Group::withInput()'s generic "ui_error_in_group". - */ + /** Must read ": ", not Group::withInput()'s generic "ui_error_in_group". */ public function testGrindReportsAFieldAttributedInvalidInputExceptionOnValidationFailure(): void { $language_key = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '') @@ -157,10 +147,7 @@ public function testGrindReportsAFieldAttributedInvalidInputExceptionOnValidatio $this->assertNotSame('language_key: ', $error->getMessage()); } - /** - * A raw array of strings (as sent by class.ilObjLanguageFolderGUI.php for 'language_keys') is joined into one - * comma-separated string. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings: joined with commas. */ public function testGrindJoinsAnArrayOfStringsRawValueForATextFieldIntoACommaSeparatedString(): void { $language_keys = $this->createRealFieldsUiFactory()->input()->field()->text('Language keys', '') @@ -175,9 +162,7 @@ public function testGrindJoinsAnArrayOfStringsRawValueForATextFieldIntoACommaSep $this->assertSame('de,fr', $result->value()['language_keys']); } - /** - * The outermost group does not enforce known keys: an unrelated top-level key is ignored. - */ + /** The outermost group does not enforce known keys. */ public function testGrindSilentlyIgnoresAnUnknownKeyAtTheTopLevelOfRawParameters(): void { $name = $this->createRealFieldsUiFactory()->input()->field()->text('Name', '') @@ -236,10 +221,7 @@ public static function nonArrayNonNullGroupRawValueProvider(): array ]; } - /** - * A group value that is neither null nor an array must be rejected; degrading it to an empty array would mask - * it as a misleading required-field error. - */ + /** Degrading it to an empty array would mask it as a misleading required-field error. */ #[DataProvider('nonArrayNonNullGroupRawValueProvider')] public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOfSilentlyDegradingToEmptyArray( mixed $raw_value diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index b60fe59259ad..f8368676d331 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -98,11 +98,7 @@ public function testMultipleLanguagesSeparatesNotInstalledFromAlreadyInstalledUn public function testNewLanguageWithCustomFileIsReportedAsInstalledWithLocalFile(): void { - // Even a language that was not installed before must be classified - // by "has a customizing/local file", not "was newly installed" - a - // fresh language that already ships a local file is still more - // accurately described as "installed with custom file" than as a - // plain install. + // classified by "has a customizing/local file", not "was newly installed" $setup_language = $this->createSetupLanguageMock( [], ['de'], @@ -126,14 +122,7 @@ public function testNewLanguageWithCustomFileIsReportedAsInstalledWithLocalFile( $this->assertSame([], $result['not_installed_language_keys']); } - /** - * This is the central bugfix regression test: mode "install" used to - * unconditionally (re-)install and (re-)apply the customizing/local file - * of an already installed language on every call. It must now be a - * complete no-op instead - not even checkLanguageForInstallation may run - * for it, since (re-)applying a customizing file is exclusively mode - * "install_local"'s job now. - */ + /** 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( @@ -172,10 +161,7 @@ public function testInstallLocalModeAppliesCustomFileToAlreadyInstalledLanguage( $setup_language->expects($this->once()) ->method('insertLanguageForApplyingLocalChanges') ->with('de'); - // The object_data bookkeeping itself is delegated to - // ilSetupLanguage::registerInstalledLanguage() (shared with - // installLanguages()) - see ilSetupLanguageTest for coverage of the - // actual INSERT/UPDATE SQL it builds. + // object_data bookkeeping is delegated to ilSetupLanguage::registerInstalledLanguage() (see ilSetupLanguageTest) $setup_language->expects($this->once()) ->method('registerInstalledLanguage') ->with( @@ -196,13 +182,7 @@ public function testInstallLocalModeAppliesCustomFileToAlreadyInstalledLanguage( $this->assertSame([], $result['invalid_local_language_files']); } - /** - * "install_local" still runs its write methods for an already installed - * language even when no customizing/local file actually exists for it - * (there is simply nothing for them to write) - but the *result* must - * report this as "already installed", not as "installed with local - * file", since nothing observable actually changed. - */ + /** "install_local" without a local file is reported as "already installed": nothing changed. */ public function testInstallLocalModeOnAlreadyInstalledLanguageWithoutCustomFileIsReportedAsAlreadyInstalled(): void { $setup_language = $this->createSetupLanguageMock( @@ -229,11 +209,7 @@ public function testInstallLocalModeOnAlreadyInstalledLanguageWithoutCustomFileI $this->assertSame([], $result['not_installed_language_keys']); } - /** - * "install_local" on a language that is not installed at all must be a - * complete no-op - there is nothing installed yet to apply local - * changes on top of. - */ + /** "install_local" on a language that is not installed is a no-op. */ public function testInstallLocalModeOnNotInstalledLanguageIsCompleteNoOp(): void { $setup_language = $this->createSetupLanguageMock([], [], []); @@ -254,12 +230,7 @@ public function testInstallLocalModeOnNotInstalledLanguageIsCompleteNoOp(): void $this->assertSame(['de'], $result['not_installed_language_keys']); } - /** - * When every requested language turns out to be a no-op (given the - * chosen mode and each language's install status), perform() must not - * even read the available/local languages or scan for invalid local - * files - there is nothing left to do that would need them. - */ + /** 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); @@ -285,11 +256,7 @@ public function testAllRequestedLanguagesBeingNoOpsSkipsLanguageAndFileLookupsEn $this->assertSame([], $result['invalid_local_language_files']); } - /** - * A single request can carry languages in every possible state at once; - * each must land in its own bucket, and only the ones actually affected - * may trigger any write method - under mode "install". - */ + /** 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( @@ -309,8 +276,7 @@ public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBu $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage'); $setup_language->expects($this->once()) ->method('getInvalidLocalLanguageFiles') - // Only the two languages actually being (fully) installed are - // relevant - the already-installed 'de' is untouched. + // the already-installed 'de' is untouched ->with(['en', 'fr']); $result = $this->createActivity($setup_language)->perform([ @@ -324,12 +290,7 @@ public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBu $this->assertSame([], $result['not_installed_language_keys']); } - /** - * The same idea, but under mode "install_local": no key may ever trigger - * checkLanguageForInstallation/flushLanguageForInstallation/ - * insertLanguageForInstallation - only insertLanguageForApplyingLocalChanges, - * and only for the already-installed keys. - */ + /** Mode "install_local": only insertLanguageForApplyingLocalChanges, only for already-installed keys. */ public function testBulkRequestUnderInstallLocalModePartitionsEachKeyIntoTheCorrectBucket(): void { $setup_language = $this->createSetupLanguageMock( @@ -349,8 +310,7 @@ public function testBulkRequestUnderInstallLocalModePartitionsEachKeyIntoTheCorr $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage'); $setup_language->expects($this->once()) ->method('getInvalidLocalLanguageFiles') - // Only the two already-installed languages are affected - the - // not-installed 'fr' is left untouched. + // the not-installed 'fr' is left untouched ->with(['de', 'en']); $result = $this->createActivity($setup_language)->perform([ @@ -555,12 +515,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity($this->createSetupLanguageMock([], [], []))->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see InstallLanguage::perform()): a non-array $parameters must now - * raise the concrete InvalidInputException - not just the more general - * \InvalidArgumentException it extends. - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -568,19 +523,7 @@ public function testNonArrayParametersAreRejected(): void $this->createActivity($this->createSetupLanguageMock([], [], []))->perform('not-an-array'); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind() (via its - * private joinListOfStringsRawValue() helper - see that method's own - * docblock) must join that array into the same shape a real HTML text - * input would carry BEFORE - * it reaches the declared Text field, rather than rejecting it. This - * is exercised through the REAL getInputDescription()/grind() pipeline - * (createRealFieldsUiFactory(), not a mocked FormInput) via - * maybePerformAs() - the previously blocking regression this test - * guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndInstallsEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 7bf7bf7bb6f2..18c9fe367a75 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -168,9 +168,6 @@ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedForm } } -/** - * LanguageActivity whose getInputDescription() always throws; every other abstract method throws too. - */ final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity { public function __construct( diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index 14bea4c4c26f..f13d58bb738f 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -24,14 +24,7 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * RemoveLocalLanguageChanges has no ilSetupLanguage collaborator (unlike - * InstallLanguage/UpdateLanguage) - instead it resolves language keys to - * objects via two closures, $lng_objects and $obj_language_factory. The - * object returned by $obj_language_factory must behave like \ilObjLanguage - * (isInstalled(), removeLocalChanges()), but \ilObjLanguage itself cannot be - * constructed or meaningfully mocked in a unit test (its constructor needs - * the global $DIC) - so tests use a small fake object instead, injected - * through $obj_language_factory. + * \ilObjLanguage cannot be constructed in a unit test (its constructor needs $DIC), so a fake object stands in. */ class RemoveLocalLanguageChangesTest extends ActivityWithPerformResultContractTestCase { @@ -50,12 +43,7 @@ protected function validPerformParameters(): array } /** - * @param array $language_objects - * Keyed by language key; each entry becomes a fake object with - * the given isInstalled() answer (default true) and - * removeLocalChanges() return value (default true) reachable - * from the corresponding obj_id (assigned in iteration order, - * starting at 1). + * @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] */ @@ -80,14 +68,6 @@ private function buildFakeLanguageWorld(array $language_objects): array return [$fakes_by_obj_id, $lng_objects, $obj_language_factory]; } - // ----------------------------------------------------------------- - // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" - // objects sharing the same title must reject the whole request for - // that title rather than silently resolving to whichever one happened - // to be enumerated last - see UninstallLanguage's identical guard and - // UninstallLanguageTest's equivalent tests for the same reasoning. - // ----------------------------------------------------------------- - public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void { $lng_objects = static fn(): array => [ @@ -112,37 +92,7 @@ public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactory } } - /** - * Regression test: the AmbiguousLanguageTitleException message embeds - * the ambiguous title RAW/unescaped - escaping HTML-significant - * characters for safe display is deliberately no longer this domain - * layer's job (see RemoveLocalLanguageChanges::perform()'s own comment - * above the throw), only \ILIAS\Language\RendersActivityErrors:: - * activityErrorMessage()'s. If this message were pre-escaped here, it - * would end up double-escaped once it reaches that single seam. - * - * NOTE (found while adding format validation coverage for - * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used - * an HTML/XSS payload ('&"quoted"') as BOTH the - * ambiguous objects' title AND the requested 'language_keys' value - - * $language_keys must equal an object's title verbatim for - * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted - * (perform() intersects the requested, already-parsed $language_keys - * against that map's keys). Since toLanguageKeyList() now rejects any - * value that is not exactly two lowercase ASCII letters BEFORE that - * intersection is even computed, such a value now throws - * InvalidInputException immediately and never reaches - * AmbiguousLanguageTitleException at all - this test would otherwise - * fail with that InvalidInputException instead of the expected - * AmbiguousLanguageTitleException. The dangerous-title scenario is - * therefore no longer reachable through perform()'s public parameter - * surface at all (a real, if incidental, hardening side effect of the - * new format check) - only a title that happens to already be a - * plausible two-letter language key can ever become "ambiguous" via a - * real request. The title/key below is changed accordingly; the - * "no HTML-escaping happens" assertions are kept for continuity, even - * though a two-letter title trivially satisfies them. - */ + /** The ambiguous title is embedded raw: escaping is RendersActivityErrors' job, doing it here would double-escape. */ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle(): void { $ambiguous_title = 'de'; @@ -166,11 +116,7 @@ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle() } } - /** - * Via maybePerformAs(), the same ambiguous-title \RuntimeException must - * surface as a Result\Error, not propagate as an uncaught exception - - * and, again, the object factory must never be reached for it. - */ + /** Via maybePerformAs() the ambiguity surfaces as Result\Error and the object factory is never reached. */ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -194,12 +140,7 @@ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCal $this->assertSame([], $factory_calls); } - /** - * An ambiguous title must not poison unrelated, unambiguous titles in - * the same $lng_objects() world/request - a genuinely unique language - * key coexisting with an ambiguous one must still resolve and have its - * local changes removed normally. - */ + /** An ambiguous title does not poison unrelated, unambiguous ones. */ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInTheSameLngObjectsList(): void { $lng_objects = static fn(): array => [ @@ -224,20 +165,7 @@ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInThe $this->assertSame(1, $fr->removeLocalChangesCallCount()); } - /** - * Regression test for the ambiguity check being moved OUT of the - * per-key foreach loop and performed for ALL requested keys BEFORE any - * removeLocalChanges() call (see the comment directly above that check - * in perform(): "Checked upfront, for all requested keys at once, so a - * request naming both an unambiguous and an ambiguous key never - * changes the unambiguous one before rejecting the whole call."). A - * request naming an unambiguous key ('de') FIRST and an ambiguous one - * ('fr') SECOND must reject the whole request and must NOT have already - * changed 'de' by the time 'fr' is reached - before this fix, the - * ambiguity check ran inline inside the loop, so 'de' (processed first) - * would already have had its local changes removed for real before the - * loop reached the ambiguous 'fr' and threw. - */ + /** 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); @@ -260,25 +188,18 @@ public function testAnUnambiguousKeyBeforeAnAmbiguousOneNeverHasLocalChangesRemo $activity = $this->createActivity($lng_objects, $obj_language_factory); try { - // 'de' listed BEFORE the ambiguous 'fr' on purpose - see the - // "Checked upfront, for all requested keys at once" comment - // above the ambiguity check in perform(). + // '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 to a factory call, let alone changed - - // the ambiguity check ran BEFORE the per-key loop. + // 'de' was never resolved, the check ran before the per-key loop $this->assertSame([], $factory_calls); $this->assertSame(0, $de->removeLocalChangesCallCount()); } } - /** - * Same regression, exercised end-to-end through maybePerformAs(): the - * Result\Error must surface without 'de' having had its local changes - * removed. - */ + /** Same, end-to-end through maybePerformAs(). */ public function testMaybePerformAsNeverRemovesLocalChangesForAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -307,10 +228,6 @@ public function testMaybePerformAsNeverRemovesLocalChangesForAnUnambiguousKeyWhe $this->assertSame(0, $de->removeLocalChangesCallCount()); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -344,12 +261,7 @@ public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void $this->assertFalse($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); } - /** - * isAllowedToPerform() must be a pure permission check: it must never - * touch $lng_objects or $obj_language_factory (and therefore never call - * removeLocalChanges()) - unlike perform(), which is the only place - * allowed to have side effects. - */ + /** isAllowedToPerform() is a pure permission check: no object access, no removeLocalChanges(). */ public function testIsAllowedToPerformNeverTouchesLngObjectsOrObjectFactory(): void { $lng_objects = static function (): array { @@ -367,10 +279,6 @@ public function testIsAllowedToPerformNeverTouchesLngObjectsOrObjectFactory(): v $this->assertTrue($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); } - // ----------------------------------------------------------------- - // perform() - // ----------------------------------------------------------------- - public function testInstalledLanguageWithValidFileHasItsLocalChangesRemoved(): void { [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ @@ -403,15 +311,7 @@ public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalle $this->assertSame(0, $fakes[1]->removeLocalChangesCallCount()); } - /** - * Regression test: removeLocalChanges() itself is the authority on - * whether the language file was valid, not merely the outer - * isInstalled() guard. A language that passes isInstalled() (so - * perform() does call removeLocalChanges()) but whose - * removeLocalChanges() nonetheless returns false (invalid language - * file) must be reported as invalid_language_file_keys, never as - * removed_local_changes_language_keys or not_installed_language_keys. - */ + /** 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([ @@ -428,12 +328,7 @@ public function testInstalledLanguageWhereRemoveLocalChangesFailsIsReportedAsInv $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); } - /** - * A language key with no corresponding "lng" object at all must be - * reported as not installed - and, crucially, $obj_language_factory - * must never even be invoked for it (there is no obj_id to construct it - * from in the first place). - */ + /** 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 => []; @@ -453,13 +348,7 @@ public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingThe $this->assertSame([], $factory_calls); } - /** - * A single request can carry languages in every possible state at once; - * each must land in its own bucket, and removeLocalChanges() must only - * ever be invoked for the two genuinely installed languages (regardless - * of whether their file turns out valid or not) - never for the - * not-installed or unknown ones. - */ + /** 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([ @@ -533,20 +422,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see RemoveLocalLanguageChanges::perform(), ~line 143): this check was - * switched from a plain \InvalidArgumentException to the concrete - * InvalidInputException, to be consistent with AddLanguageEntry, - * SetLanguageDetectionEnabled and SetLanguageTranslationEnabled, which - * already used it. The docblock/comment that used to justify the - * opposite ("deliberately NOT an InvalidInputException") is stale and - * has been removed - this test now pins the CURRENT, more specific - * exception class, not just \InvalidArgumentException (which - * InvalidInputException extends, and against which this assertion would - * therefore have kept passing silently even after the behaviour change - - * see RendersActivityErrors, which treats 4xx specially). - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException, which would pass silently. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -554,17 +430,7 @@ public function testNonArrayParametersAreRejected(): void $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform('not-an-array'); } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - - /** - * Regression test analogous to - * UninstallLanguageTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall(): - * a denied permission must short-circuit before perform() ever runs - - * $lng_objects/$obj_language_factory (and therefore removeLocalChanges()) - * must never be touched. - */ + /** Denied permission short-circuits before perform(): no object is touched. */ public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemoveLocalChanges(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -610,19 +476,7 @@ public function testPermissionGrantedPerformsAndReturnsOkResult(): void $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind() (via its - * private joinListOfStringsRawValue() helper - see that method's own - * docblock) must join that array into the same shape a real HTML text - * input would carry BEFORE - * it reaches the declared Text field, rather than rejecting it. This - * is exercised through the REAL getInputDescription()/grind() pipeline - * (createRealFieldsUiFactory(), not a mocked FormInput) via - * maybePerformAs() - the previously blocking regression this test - * guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndRemovesLocalChangesForEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -642,12 +496,7 @@ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndRemovesLocalCha $this->assertSame(1, $fakes[2]->removeLocalChangesCallCount()); } - /** - * A Throwable raised from within perform() (e.g. a validation failure - * on the normalized parameters, or any other unexpected failure) must - * be turned into a Result\Error rather than propagating - this is the - * whole point of maybePerformAs() wrapping perform() in a try/catch. - */ + /** A Throwable from perform() becomes a Result\Error. */ public function testThrowableFromPerformIsTurnedIntoAResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -668,16 +517,7 @@ public function testThrowableFromPerformIsTurnedIntoAResultError(): void $this->assertSame(400, $result->error()->getCode()); } - /** - * A completely missing 'language_keys' key is now caught by grind() - * itself (see GrindsFormInput): the required Text field receives a - * blank raw value and fails its own required-field constraint before - * normalizeParameters()/isAllowedToPerform() are ever reached - so the - * rejection surfaces as a Result\Error carrying the UI field's own - * (string) validation message, not an InvalidArgumentException thrown - * by normalizeParameters(). Either way, the rbac system must never be - * touched (validation happens first). - */ + /** A missing 'language_keys' fails the required Text field in grind(), before rbac is touched. */ public function testMissingLanguageKeysParameterViaMaybePerformAsIsAResultErrorAndNeverChecksPermission(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -711,23 +551,14 @@ private function createActivity( } /** - * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or - * meaningfully mocked in a unit test (its constructor requires the global - * $DIC), so tests substitute this fake via RemoveLocalLanguageChanges's - * $obj_language_factory closure instead. Counts removeLocalChanges() - * invocations so tests can assert it was never called for a bucket other - * than "removed"/"invalid". + * 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 What removeLocalChanges() - * returns - true mimics a real, successful removal of local - * changes; false mimics \ilObjLanguage::removeLocalChanges() - * rejecting the call because the underlying language file failed - * check() (isInstalled() having already let it through). + * @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, diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index ab674bd6425c..8884ec3a7e5a 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -60,12 +60,7 @@ public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void $this->assertFalse($activity->isAllowedToPerform(6, ['enabled' => true])); } - /** - * isAllowedToPerform() must be a pure permission check: it must never - * touch the Setting collaborator (and therefore never write - * "lang_detection") - unlike perform(), which is the only place - * allowed to have side effects. - */ + /** isAllowedToPerform() is a pure permission check: no Setting access. */ public function testIsAllowedToPerformNeverTouchesSettings(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -121,12 +116,7 @@ public function testPerformRejectsNonArrayParameters(): void $this->createActivity(repository: $repository)->perform('not-an-array'); } - /** - * Boundary/type-juggling regression: perform() must require a strict - * bool for 'enabled' and must never silently cast a truthy/falsy - * look-alike (string "1"/"0"/"true", int 1, etc.) - unlike PHP's own - * loose comparison rules, which would happily accept any of these. - */ + /** perform() requires a strict bool for 'enabled'; truthy/falsy look-alikes ("1", 1, ...) are rejected. */ #[DataProvider('nonBooleanEnabledValuesProvider')] public function testPerformRejectsNonStrictBooleanEnabledValues(mixed $value): void { @@ -169,19 +159,7 @@ public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResul $this->assertSame(['enabled' => true], $result->value()); } - /** - * Core security regression test: before this extraction, - * enableLanguageDetectionObject()/disableLanguageDetectionObject() - * performed no RBAC check of their own at all - a request forged - * directly against the corresponding ilCtrl - * commands by a user with only "read" access on the language folder - * would still have flipped "lang_detection". isAllowedToPerform() now - * enforces a "write" check, and this must actually cause - * maybePerformAs() to reject the request (Result\Error) *and* leave the - * Setting collaborator completely untouched - a permission check that - * merely rejected the outcome without preventing the side effect would - * not close the gap at all. - */ + /** 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); @@ -219,17 +197,7 @@ public function testMaybePerformAsRejectsAnEnabledValueOutsideTheCheckboxWhiteli $this->assertSame(400, $result->error()->getCode()); } - /** - * The 'enabled' Checkbox field is not required, so - now that - * maybePerformAs() actually grinds $raw_parameters through - * getInputDescription() (see GrindsFormInput) - a completely missing - * 'enabled' key is no longer rejected: it defaults to false, exactly - * like an unchecked, and therefore never submitted, HTML checkbox - * would. This is a deliberate behavioural change from the old - * (pre-grinding) normalizeParameters(), which used to reject a missing - * key outright. Unlike before, the request now actually reaches - * isAllowedToPerform()/perform(). - */ + /** A missing 'enabled' defaults to false like an unchecked HTML checkbox, and still reaches perform(). */ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -266,14 +234,7 @@ public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array ]; } - /** - * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): - * unlike perform() itself (which still requires a strict bool, see - * testPerformRejectsNonStrictBooleanEnabledValues), maybePerformAs() - * now accepts the common primitive representations of "true" a generic - * (non-HTML) caller might reasonably send, and writes the setting - * accordingly. - */ + /** Unlike perform(), maybePerformAs() accepts common primitive representations of "true". */ #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsAndWritesEnabledTrue(mixed $value): void { @@ -290,9 +251,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsA $this->assertSame(['enabled' => true], $result->value()); } - /** - * Same tolerance for the common representations of "false". - */ + /** Same for "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyRepresentationsAndWritesEnabledFalse(mixed $value): void { diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index b04d1eca872c..64e49d6e30a3 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -61,12 +61,7 @@ public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void $this->assertFalse($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); } - /** - * isAllowedToPerform() must be a pure permission check: it must never - * touch the Setting collaborator (and therefore never read/write - * "lang_translate_*") - unlike perform(), which is the only place - * allowed to have side effects. - */ + /** isAllowedToPerform() is a pure permission check: no Setting access. */ public function testIsAllowedToPerformNeverTouchesSettings(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -121,12 +116,7 @@ public function testPerformWritesOnlyWhenTheRequestedValueDiffersFromTheStoredOn ); } - /** - * get() must be called with the correct per-language key - * ("lang_translate_" . language_key) and the documented default '0' - - * a mutant that drops the default, or concatenates the key in the wrong - * order/without the separator, must be caught here. - */ + /** Pins the key "lang_translate_" and the default '0'. */ public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -136,12 +126,7 @@ public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero() $this->createActivity(repository: $repository)->perform(['language_key' => 'fr', 'enabled' => false]); } - /** - * language_key must be trimmed before being used to build the setting - * key and before being returned - mirroring toNonEmptyString()'s - * behaviour used by normalizeParameters(), but exercised here directly - * through perform() since perform() re-implements its own trim(). - */ + /** perform() re-implements its own trim() of language_key. */ public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -206,13 +191,7 @@ public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResul ); } - /** - * Permission check regression: a denied "write" access must both yield - * a Result\Error *and* leave the Setting collaborator completely - * untouched (no read, no write) - a permission check that merely - * rejected the outcome without preventing the read/write would not be a - * real gate. - */ + /** A denied "write" yields a Result\Error and leaves the Setting untouched. */ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouchesSettings(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -236,12 +215,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche $this->assertSame(403, $result->error()->getCode()); } - /** - * A Throwable raised from within perform() (e.g. the Setting - * collaborator failing to write) must be turned into a Result\Error - * rather than propagating - this is the whole point of maybePerformAs() - * wrapping perform() in a try/catch. - */ + /** A Throwable from perform() becomes a Result\Error. */ public function testDefectFromWithinPerformIsMaskedAsA500ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -260,16 +234,7 @@ public function testDefectFromWithinPerformIsMaskedAsA500ResultError(): void $this->assertSame('database write failed', $error->getPrevious()?->getMessage()); } - /** - * A completely missing 'language_key' key is caught by grind() itself - * (see GrindsFormInput): the required Text field receives a blank raw - * value and fails its own required-field constraint before - * normalizeParameters()/isAllowedToPerform() are ever reached - so the - * rejection surfaces as a Result\Error carrying a field-attributed - * InvalidInputException (see GrindsFormInput::describeInputError()), not - * an InvalidArgumentException. The rbac system must never be touched - * either way (validation happens first). - */ + /** A missing 'language_key' fails the required Text field in grind(), before rbac is touched. */ public function testMaybePerformAsWithMissingRawParametersKeysIsAResultErrorAndNeverChecksPermission(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -338,16 +303,7 @@ public function testMaybePerformAsRejectsInvalidRawParametersAsResultErrorWithou $this->assertSame(400, $result->error()->getCode()); } - /** - * The 'enabled' Checkbox field is not required, so a completely - * missing 'enabled' key is no longer rejected by grind() - it defaults - * to false, exactly like an unchecked, and therefore never submitted, - * HTML checkbox would (same behaviour as - * SetLanguageDetectionEnabledTest's equivalent regression test). - * Unlike a rejection at grind level, this reaches isAllowedToPerform()/ - * perform() and can still write the setting if that changes the - * currently stored value. - */ + /** A missing 'enabled' defaults to false like an unchecked HTML checkbox, and still reaches perform(). */ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -367,15 +323,7 @@ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPe ); } - /** - * A whitespace-only language_key value satisfies the Text field's own - * required-check (min length 1 on the raw string, before trimming), so - * grind() accepts it and the request DOES reach isAllowedToPerform() - - * unlike the fully-missing-key case above. It is perform() itself - * (via its own trim() check, see - * testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSettings) - * that ultimately rejects it. - */ + /** 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); @@ -413,12 +361,7 @@ public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array ]; } - /** - * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): - * unlike perform() itself (which still requires a strict bool), - * maybePerformAs() now accepts the common primitive representations of - * "true" a generic (non-HTML) caller might reasonably send. - */ + /** Unlike perform(), maybePerformAs() accepts common primitive representations of "true". */ #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresentations(mixed $value): void { @@ -439,9 +382,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresent ); } - /** - * Same tolerance for the common representations of "false". - */ + /** Same for "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresentations(mixed $value): void { @@ -462,12 +403,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresenta ); } - /** - * Regression test for Punkt 4 of this component's grinding task: unlike - * before, an unknown/not-installed language_key is now rejected by - * perform() itself, for direct perform()/isAllowedToPerform() callers - * too - not only via maybePerformAs(). - */ + /** An unknown language_key is rejected by perform() itself, for direct callers too. */ public function testPerformRejectsUnknownLanguageKeyWithoutTouchingSettings(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -480,15 +416,7 @@ public function testPerformRejectsUnknownLanguageKeyWithoutTouchingSettings(): v ->perform(['language_key' => 'xx', 'enabled' => true]); } - /** - * Regression test: the "Unknown language key" InvalidInputException - * message embeds the offending language_key RAW/unescaped - escaping - * HTML-significant characters for safe display is deliberately no - * longer this domain layer's job (see perform()'s own comment above the - * throw), only \ILIAS\Language\RendersActivityErrors:: - * activityErrorMessage()'s. If this message were pre-escaped here, it - * would end up double-escaped once it reaches that single seam. - */ + /** The message embeds the key raw: escaping is RendersActivityErrors' job. */ public function testPerformRejectsUnknownLanguageKeyMessageContainsTheRawUnescapedKey(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -508,14 +436,7 @@ public function testPerformRejectsUnknownLanguageKeyMessageContainsTheRawUnescap } } - /** - * The same rejection, exercised end-to-end through maybePerformAs(): - * the unknown language_key passes grind() (any non-blank string - * satisfies the Text field's own required-check) and the permission - * check (isAllowedToPerform() does not know about installed languages - * either), so it is perform() itself that rejects it - and the Setting - * collaborator must never be touched. - */ + /** Same rejection end-to-end: it passes grind() and the permission check, perform() rejects it. */ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckAndNeverTouchesSettings(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -533,14 +454,7 @@ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckA $this->assertSame(400, $result->error()->getCode()); } - /** - * normalizeParameters()'s toNonEmptyString() must trim language_key - * before it reaches perform() - exercised end-to-end through - * maybePerformAs() so a caller submitting e.g. a copy-pasted language - * key with surrounding whitespace still resolves to the same setting - * key as perform() itself would (see - * testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue). - */ + /** A language_key with surrounding whitespace resolves to the same setting key as via perform(). */ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -596,14 +510,7 @@ public function testInputDescriptionBuildsAGroupWithARequiredLanguageKeyTextFiel $this->assertSame($group, $activity->getInputDescription($field)); } - /** - * Uses a real DescriptionFactory (like AddLanguageEntryTest's own - * output-description test) rather than a mocked one, so that - * getFields()'s *actual* iteration order is asserted - a mocked - * object()->with([...]) call would accept the three fields in any - * order (PHPUnit's array comparison is order-independent), silently - * missing a mutation that swapped e.g. 'enabled' and 'changed'. - */ + /** Real DescriptionFactory: a mocked with([...]) would accept the fields in any order. */ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): void { $activity = $this->createActivity(); diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index 612a964ed652..3f01aec42996 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -44,13 +44,6 @@ protected function validPerformParameters(): array return ['language_keys' => 'de']; } - // ----------------------------------------------------------------- - // maybePerformAs() turning a \Throwable from perform() into a - // Result\Error (m5) - here, the new ambiguous-title \RuntimeException - // from resolveObjIdsByLanguageKey() (see below) doubles as the concrete - // \Throwable this class' perform() can actually raise. - // ----------------------------------------------------------------- - public function testThrowableFromWithinPerformIsTurnedIntoAResultErrorByMaybePerformAs(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -71,13 +64,6 @@ public function testThrowableFromWithinPerformIsTurnedIntoAResultErrorByMaybePer $this->assertInstanceOf(\RuntimeException::class, $result->error()); } - // ----------------------------------------------------------------- - // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" - // objects sharing the same title must reject the whole request for - // that title rather than silently resolving to whichever one happened - // to be enumerated last. - // ----------------------------------------------------------------- - public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void { $lng_objects = static fn(): array => [ @@ -102,37 +88,7 @@ public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactory } } - /** - * Regression test: the AmbiguousLanguageTitleException message embeds - * the ambiguous title RAW/unescaped - escaping HTML-significant - * characters for safe display is deliberately no longer this domain - * layer's job (see UninstallLanguage::perform()'s own docblock/comment - * above the throw), only \ILIAS\Language\RendersActivityErrors:: - * activityErrorMessage()'s. If this message were pre-escaped here, it - * would end up double-escaped once it reaches that single seam. - * - * NOTE (found while adding format validation coverage for - * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used - * an HTML/XSS payload ('&"quoted"') as BOTH the - * ambiguous objects' title AND the requested 'language_keys' value - - * $language_keys must equal an object's title verbatim for - * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted - * (perform() intersects the requested, already-parsed $language_keys - * against that map's keys). Since toLanguageKeyList() now rejects any - * value that is not exactly two lowercase ASCII letters BEFORE that - * intersection is even computed, such a value now throws - * InvalidInputException immediately and never reaches - * AmbiguousLanguageTitleException at all - this test would otherwise - * fail with that InvalidInputException instead of the expected - * AmbiguousLanguageTitleException. The dangerous-title scenario is - * therefore no longer reachable through perform()'s public parameter - * surface at all (a real, if incidental, hardening side effect of the - * new format check) - only a title that happens to already be a - * plausible two-letter language key can ever become "ambiguous" via a - * real request. The title/key below is changed accordingly; the - * "no HTML-escaping happens" assertions are kept for continuity, even - * though a two-letter title trivially satisfies them. - */ + /** The ambiguous title is embedded raw: escaping is RendersActivityErrors' job, doing it here would double-escape. */ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle(): void { $ambiguous_title = 'de'; @@ -156,11 +112,7 @@ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle() } } - /** - * Via maybePerformAs(), the same ambiguous-title \RuntimeException must - * surface as a Result\Error, not propagate as an uncaught exception - - * and, again, the object factory must never be reached for it. - */ + /** Via maybePerformAs() the ambiguity surfaces as Result\Error and the object factory is never reached. */ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -184,12 +136,7 @@ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCal $this->assertSame([], $factory_calls); } - /** - * An ambiguous title must not poison unrelated, unambiguous titles in - * the same $lng_objects() world/request - a genuinely unique language - * key coexisting with an ambiguous one must still resolve and uninstall - * normally. - */ + /** An ambiguous title does not poison unrelated, unambiguous ones. */ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInTheSameLngObjectsList(): void { $lng_objects = static fn(): array => [ @@ -214,21 +161,7 @@ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInThe $this->assertSame(1, $fr->uninstallCallCount()); } - /** - * Regression test for the ambiguity check being moved OUT of the - * per-key foreach loop and performed for ALL requested keys BEFORE any - * uninstall() call (see the comment directly above the - * AmbiguousLanguageTitleException throw in UninstallLanguage::perform(): - * "Checked upfront, for all requested keys at once, so a request naming - * both an unambiguous and an ambiguous key never uninstalls the - * unambiguous one before rejecting the whole call"). A request naming - * an unambiguous key ('de') FIRST and an ambiguous one ('fr') SECOND - * must reject the whole request and must NOT have already uninstalled - * 'de' by the time 'fr' is reached - before this fix, the ambiguity - * check ran inline inside the loop, so 'de' (processed first) would - * already have been uninstalled for real before the loop reached the - * ambiguous 'fr' and threw. - */ + /** 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); @@ -251,26 +184,18 @@ public function testAnUnambiguousKeyBeforeAnAmbiguousOneIsNeverUninstalledOnceTh $activity = $this->createActivity($lng_objects, $obj_language_factory); try { - // 'de' listed BEFORE the ambiguous 'fr' on purpose - this is the - // exact ordering the regression above (see this test's own - // docblock) requires: were the ambiguity check still inline in - // the per-key loop, 'de' being processed first would already - // have been uninstalled before 'fr' was ever reached. + // '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 to a factory call, let alone - // uninstalled - the ambiguity check ran BEFORE the per-key loop. + // 'de' was never resolved, the check ran before the per-key loop $this->assertSame([], $factory_calls); $this->assertSame(0, $de->uninstallCallCount()); } } - /** - * Same regression, exercised end-to-end through maybePerformAs(): the - * Result\Error must surface without 'de' having been uninstalled. - */ + /** Same, end-to-end through maybePerformAs(). */ public function testMaybePerformAsNeverUninstallsAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -300,12 +225,7 @@ public function testMaybePerformAsNeverUninstallsAnUnambiguousKeyWhenAnotherRequ } /** - * @param array $language_objects - * Keyed by language key; each entry becomes a fake object with - * the given isSystemLanguage()/isUserLanguage()/isInstalled() - * answers (default false/false/true) and uninstall() return - * value (default 'uninstalled') reachable from the - * corresponding obj_id (assigned in iteration order, starting at 1). + * @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] */ @@ -383,12 +303,7 @@ public function testLanguageCurrentlyInUseIsNeverUninstalled(): void $this->assertSame(0, $fakes[1]->uninstallCallCount()); } - /** - * isSystemLanguage() takes priority: a language that is somehow flagged - * as both the system language and the language currently in use must be - * reported as the system language, not as "in use" - the two branches - * are checked in that order in perform(). - */ + /** isSystemLanguage() takes priority over "in use" when both flags are set. */ public function testSystemLanguageTakesPriorityOverUserLanguage(): void { [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ @@ -420,17 +335,7 @@ public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalle $this->assertSame(0, $fakes[1]->uninstallCallCount()); } - /** - * Regression test: uninstall() itself is the authority on whether the - * uninstallation actually happened, not merely the three outer guards - * (isSystemLanguage()/isUserLanguage()/isInstalled()). A language that - * passes all three guards (so perform() does call uninstall()) but - * whose uninstall() nonetheless returns '' (e.g. because a future, - * additional internal guard inside \ilObjLanguage::uninstall() itself - * rejects it) must be reported as not_installed_language_keys, never - * as uninstalled_language_keys - anything else would misreport a - * rejected uninstallation as a success. - */ + /** 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([ @@ -449,12 +354,7 @@ public function testLanguageWhereUninstallItselfRejectsIsReportedAsNotInstalled( $this->assertSame(1, $fakes[1]->uninstallCallCount()); } - /** - * A language key with no corresponding "lng" object at all must be - * reported as not installed - and, crucially, $obj_language_factory - * must never even be invoked for it (there is no obj_id to construct it - * from in the first place). - */ + /** 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 => []; @@ -538,14 +438,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see UninstallLanguage::perform()): a non-array $parameters must now - * raise the concrete InvalidInputException - not just the more general - * \InvalidArgumentException it extends - since perform() was switched - * from a plain \InvalidArgumentException to InvalidInputException for - * this check, matching every other Activity in this component. - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -629,21 +522,7 @@ public function testPermissionGrantedPerformsAndReturnsOkResult(): void $this->assertSame(1, $fakes[1]->uninstallCallCount()); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind(), via - * collectRawValues()'s call to joinListOfStringsRawValue() (see that - * method's own docblock: "Returns $raw_value joined into a single - * comma-separated string if every one of its items is a string; - * otherwise returns $raw_value unchanged, in whatever shape the caller - * passed"), must join that array into the same shape a real HTML text - * input would carry BEFORE it reaches the declared Text field, rather - * than rejecting it. This is exercised through the REAL - * getInputDescription()/grind() pipeline (createRealFieldsUiFactory(), - * not a mocked FormInput) via maybePerformAs() - the previously - * blocking regression this test guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUninstallsEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -679,24 +558,14 @@ private function createActivity( } /** - * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or - * meaningfully mocked in a unit test (its constructor requires the global - * $DIC), so tests substitute this fake via UninstallLanguage's - * $obj_language_factory closure instead. Counts uninstall() invocations so - * tests can assert it was never called for a bucket other than - * "uninstalled". + * Stand-in for \ilObjLanguage (see above); counts uninstall() calls. */ final class FakeLanguageObject { private int $uninstall_calls = 0; /** - * @param string $uninstall_return_value What uninstall() returns - a - * non-empty string mimics a real, successful uninstall; '' - * mimics \ilObjLanguage::uninstall() internally rejecting the - * call despite the three outer guards (isSystemLanguage()/ - * isUserLanguage()/isInstalled()) having let it through - e.g. a - * future additional guard added inside uninstall() itself. + * @param string $uninstall_return_value '' mimics uninstall() rejecting the call despite the outer guards */ public function __construct( private readonly bool $is_system_language, diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php index 1348e6dd3a94..d25bea413f8a 100644 --- a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -105,13 +105,7 @@ public function testMixedListPartitionsEachKeyIntoTheCorrectBucket(): void $this->assertSame(['fr'], $result['not_installed_language_keys']); } - /** - * getAvailableLanguagesForInstallation() (the $known_languages/$db_languages - * argument) and getLocalLanguages() ($local_language_keys) must be passed - * to registerInstalledLanguage() in exactly this order - both empty (or - * equal) values would make a swap of the two arguments invisible, so - * this test deliberately uses distinguishable shapes for each. - */ + /** registerInstalledLanguage() gets the available and the local languages in this order; distinguishable shapes expose a swap. */ public function testRegisterInstalledLanguageReceivesKnownLanguagesAndLocalLanguageKeysInTheCorrectOrder(): void { $setup_language = $this->createSetupLanguageMock( @@ -252,12 +246,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity($this->createSetupLanguageMock([], [], []))->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see UpdateLanguage::perform()): a non-array $parameters must now - * raise the concrete InvalidInputException - not just the more general - * \InvalidArgumentException it extends. - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -339,19 +328,7 @@ public function testPermissionGrantedPerformsAndReturnsOkResult(): void $this->assertSame(['de'], $result->value()['updated_language_keys']); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind() (via its - * private joinListOfStringsRawValue() helper - see that method's own - * docblock) must join that array into the same shape a real HTML text - * input would carry BEFORE - * it reaches the declared Text field, rather than rejecting it. This - * is exercised through the REAL getInputDescription()/grind() pipeline - * (createRealFieldsUiFactory(), not a mocked FormInput) via - * maybePerformAs() - the previously blocking regression this test - * guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUpdatesEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); diff --git a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php index ece0138b32f0..47718b2bef69 100755 --- a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php +++ b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php @@ -24,9 +24,6 @@ use ILIAS\Language\Activities\UpdateLanguage; use ILIAS\Setup\Objective\NullObjective; -/** - * Class ilLanguageSetupAgentTest - */ class ilLanguageSetupAgentTest extends ilLanguageBaseTestCase { /** diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php index d67dd42878c5..5c8357943ba0 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php @@ -27,50 +27,22 @@ use Psr\Http\Message\ServerRequestInterface; /** - * ilObjLanguageExtGUI is bound tightly to the legacy $DIC/ilObjectGUI - * bootstrap, exactly like ilObjLanguageFolderGUI (see - * ilObjLanguageFolderGUITest's class docblock for the general technique this - * file reuses: ReflectionClass::newInstanceWithoutConstructor() plus - * reflection-injected collaborators). - * - * saveSettingsObject()/saveNewEntryObject() both unconditionally build a - * legacy ilPropertyFormGUI at some point (initNewSettingsForm()/ - * initAddNewEntryForm()) - and ilPropertyFormGUI's own constructor reaches - * into `global $DIC` for $DIC->language()/$DIC->ctrl() unconditionally. Since - * building that form is not what these tests are about (the actual - * behaviour under test is the Activity dispatch and the GUI's handling of - * its Result), initNewSettingsForm()/initAddNewEntryForm() are stubbed out - * via a partial mock of the GUI itself instead - the exact same technique - * ilObjLanguageFolderGUITest uses for checkPermission(). + * Built like ilObjLanguageFolderGUITest (newInstanceWithoutConstructor plus reflection-injected collaborators). + * The ilPropertyFormGUI builders are stubbed via a partial mock: the form constructor needs `global $DIC`. */ class ilObjLanguageExtGUITest extends TestCase { - /** - * $GLOBALS['DIC'] is genuinely global state shared by every test in this - * (single) PHP process - see ilObjLanguageFolderGUITest::tearDown() for - * the same reasoning/pattern. - */ + /** $GLOBALS['DIC'] is global state shared by all tests of the process. */ protected function tearDown(): void { unset($GLOBALS['DIC']); $this->stubbed_activity_error_logger = null; } - /** - * Holds whatever stubLoggerLangError() built for the currently running - * test, so the createGuiFor*() factories below can attach it to the - * GUI instance's `activity_error_logger` property (see - * RendersActivityErrors) instead of a plain permissive stub. Reset in - * tearDown() so it never leaks between tests. - */ + /** Set by stubLoggerLangError(), attached to the GUI's `activity_error_logger`; reset in tearDown(). */ private ?\ilLogger $stubbed_activity_error_logger = null; - /** - * The logger to wire onto a GUI instance's `activity_error_logger` - * property while building it for a test: whatever stubLoggerLangError() - * set up for this test, or - for tests that never expect a Throwable - * to be logged - a permissive stub. - */ + /** 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); @@ -82,17 +54,7 @@ private function setProperty(object $object, string $property_name, mixed $value $property->setValue($object, $value); } - /** - * maybePerformAs() now takes the calling GUI's `ILIAS\UI\Factory::input()` - * as its (unused by these Activities, see LanguageActivity) first - * argument, so every reflection-/mock-builder-constructed GUI instance - * that reaches a maybePerformAs() call needs a `ui_factory` (a typed, - * non-nullable property on the ilObjectGUI ancestor) - otherwise PHP - * fatals with "must not be accessed before initialization" before the - * mocked Activity is ever reached. What input() actually returns is - * irrelevant to any of these tests (the Activities never use it), so a - * bare mock is enough. - */ + /** 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); @@ -103,15 +65,7 @@ private function stubUiFactoryForMaybePerformAs(): \ILIAS\UI\Factory&MockObject return $ui_factory; } - /** - * add_language_entry/set_language_translation_enabled are `private - * readonly` properties declared directly on ilObjLanguageExtGUI (not an - * ancestor) - on a PHPUnit mock subclass, plain `new - * ReflectionProperty($gui, name)` cannot locate a *readonly* property - * declared on the leaf class this way, so it must be looked up via the - * exact declaring class instead (see ilObjLanguageFolderGUITest's - * identical helper for 'current_user_id'). - */ + /** `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, @@ -132,15 +86,8 @@ private function createLanguageMockReturningTopicAsIs(array $installed_languages } /** - * activityErrorMessage() logs `get_class($error) . ': ' . - * $error->getMessage() . "\n" . $error->getTraceAsString()` via - * `$this->activity_error_logger` (see RendersActivityErrors) - the - * trace itself is environment-dependent (absolute file paths, line - * numbers), so only the ": \n" prefix is asserted - * here, not the full logged string. The built mock is stashed so the - * createGuiFor*() factories below can wire it onto the instance under - * test (see ilObjLanguageFolderGUITest::stubLoggerLangError() for the - * identical reasoning/pattern). + * 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 @@ -181,10 +128,6 @@ private function createFakeLanguageObject(string $key): ilObjLanguageExt&MockObj return $object; } - // ----------------------------------------------------------------- - // saveSettingsObject() - // ----------------------------------------------------------------- - /** * @return ilObjLanguageExtGUI&MockObject */ @@ -225,12 +168,7 @@ private function createGuiForSaveSettings( return $gui; } - /** - * A non-empty, non-'' "translation" POST value means the checkbox was - * submitted (checked) - see saveSettingsObject()'s own docblock on the - * production class. This also pins down that the parameters are built - * from $this->object->key, not some other source. - */ + /** 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); @@ -252,11 +190,7 @@ public function testSaveSettingsObjectBuildsEnabledTrueFromANonEmptyTranslationP $gui->saveSettingsObject(); } - /** - * An unchecked HTML checkbox is not submitted at all - a missing - * "translation" POST key must build enabled=false, exactly like an - * explicit empty string does. - */ + /** An unchecked HTML checkbox is not submitted: a missing "translation" key builds enabled=false. */ public function testSaveSettingsObjectBuildsEnabledFalseWhenTranslationPostKeyIsMissing(): void { $user = $this->createMock(ilObjUser::class); @@ -300,11 +234,7 @@ public function testSaveSettingsObjectShowsSuccessMessageWhenResultReportsChange $gui->saveSettingsObject(); } - /** - * Only shown if the value actually changed - exactly reproducing the - * extracted code's original behaviour (see the 'changed' field's own - * description in SetLanguageTranslationEnabled::getOutputDescription()). - */ + /** Only shown if the value changed (see 'changed' in SetLanguageTranslationEnabled::getOutputDescription()). */ public function testSaveSettingsObjectShowsNoMessageWhenResultReportsChangedFalse(): void { $user = $this->createMock(ilObjUser::class); @@ -327,13 +257,7 @@ public function testSaveSettingsObjectShowsNoMessageWhenResultReportsChangedFals $gui->saveSettingsObject(); } - /** - * Unlike ilObjLanguageFolderGUI's write-command methods, saveSettingsObject() - * never redirects - it renders the settings form directly with a - * non-persisted message. A \Throwable error must still go through - * activityErrorMessage() (logged, generic message shown), never the raw - * message. - */ + /** saveSettingsObject() never redirects; a \Throwable still goes through activityErrorMessage(). */ public function testSaveSettingsObjectShowsGenericFailureMessageAndNeverRedirectsOnThrowableError(): void { $this->stubLoggerLangError('boom'); @@ -357,17 +281,10 @@ public function testSaveSettingsObjectShowsGenericFailureMessageAndNeverRedirect $this->createLanguageMockReturningTopicAsIs() ); - // saveSettingsObject() has no $ctrl collaborator injected at all - // here - if it ever tried to redirect, this would fatally error - // (Reflection-constructed instance, $ctrl never set), rather than - // silently passing. + // no $ctrl injected: a redirect would fatally error instead of silently passing $gui->saveSettingsObject(); } - // ----------------------------------------------------------------- - // saveNewEntryObject() - // ----------------------------------------------------------------- - /** * @return ilObjLanguageExtGUI&MockObject */ @@ -413,12 +330,7 @@ private function mockCheckedFormWithInputs(array $inputs): ilPropertyFormGUI&Moc return $form; } - /** - * Exercises the full parameter build-up: 'module'/'identifier' straight - * from the form, and 'translations' as an array keyed by every - * installed language (via $this->lng->getInstalledLanguages()), each - * value trimmed from the form's own "trans_" field. - */ + /** 'translations' is keyed by every installed language, values trimmed from the form's "trans_". */ public function testSaveNewEntryObjectBuildsModuleIdentifierAndTranslationsFromFormAcrossAllInstalledLanguages(): void { $user = $this->createMock(ilObjUser::class); @@ -484,12 +396,7 @@ public function testSaveNewEntryObjectShowsSuccessMessageAndRedirectsToViewOnSuc $gui->saveNewEntryObject(); } - /** - * A \Throwable error must show the generic, logged message (via - * activityErrorMessage()) and redirect to "view", exactly like - * ilObjLanguageFolderGUI's write commands - and must not crash/continue - * to the success path. - */ + /** A \Throwable shows the generic logged message and redirects to "view" without reaching the success path. */ public function testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToViewOnThrowableError(): void { $this->stubLoggerLangError('boom'); @@ -520,23 +427,11 @@ public function testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToVi $this->createLanguageMockReturningTopicAsIs(['de']) ); - // If the `return` after the redirect were ever dropped, execution - // would fall through past the error branch to the success path, - // which would call setOnScreenMessage('success', ...) a second - // time and $ctrl->redirect() a second time too - both `once()` - // expectations above would then fail rather than silently passing. + // a dropped `return` after the redirect would run the success path too and fail the once() expectations $gui->saveNewEntryObject(); } - /** - * Regression test: an input error (4xx other than 403, e.g. AddLanguageEntry's - * InvalidInputException for a missing/blank mandatory "de"/"en" value) - * must NOT redirect - unlike a generic internal failure (see - * testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToViewOnThrowableError() - * above), the form is re-shown with the already-entered values still - * filled in ($form->setValuesByPost(), then addNewEntryObject($form)), - * exactly like the existing $form->checkInput() === false branch does. - */ + /** 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); @@ -553,18 +448,13 @@ public function testSaveNewEntryObjectDoesNotRedirectAndReshowsTheFormWithPosted 'A value is required for: en.' ); - // No `true` (persisted) argument, and no redirect() at all - the - // form is re-rendered directly in this same request. + // 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) (unmocked - it is the actual re-render - // path this test verifies is taken) calls `global $DIC["tpl"]` and - // renders $form->getHTML() into it - unrelated to this test's own - // concern (which is that saveNewEntryObject() takes this path and - // never redirects), so both are stubbed to harmless no-ops. + // 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); @@ -577,10 +467,7 @@ public function testSaveNewEntryObjectDoesNotRedirectAndReshowsTheFormWithPosted $form, $this->createLanguageMockReturningTopicAsIs(['de', 'en']) ); - // addNewEntryObject($form) also unconditionally checks - // $this->http->wrapper()->query()->has("eid") before falling - // through to the given $form - unrelated to this test's concern, - // stubbed to "no such query parameter". + // 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); diff --git a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php index 8fea10a03219..123d02fc7c47 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php @@ -28,35 +28,12 @@ use PHPUnit\Framework\TestCase; /** - * ilObjLanguageFolderGUI is bound tightly to the legacy $DIC/ilObjectGUI - * bootstrap (its constructor alone touches ilCtrl, ilTemplate, ilObjUser, - * ilRbac*, request wrappers, ...), so building a real instance is not - * practical for a unit test. installObject() itself is reachable in - * isolation, though: constructed via - * ReflectionClass::newInstanceWithoutConstructor() plus reflection-injected - * collaborators (a legitimate legacy-code testing technique - see - * docs/development or "Characterization Tests" practice), and called with - * an empty $ids array so the method's only other piece of legacy coupling - * (`new ilObjLanguage((int) $obj_id)` inside the per-id loop) is never - * reached. - * - * This covers the regression fix in installObject()'s error path: the - * previously swallowed $result->error() message must now appear in the - * failure message shown to the user. + * The GUI is bound to the legacy $DIC bootstrap, so it is built via newInstanceWithoutConstructor() plus + * reflection-injected collaborators and called with empty $ids, which avoids `new ilObjLanguage(...)` in the loop. */ class ilObjLanguageFolderGUITest extends TestCase { - /** - * Holds whatever stubLoggerLangError() built for the currently running - * test, so every createGuiWith*Collaborators()/createGuiWith* - * ()-style factory below can attach it to the GUI instance's - * `activity_error_logger` property (see RendersActivityErrors) instead - * of a plain permissive stub. Reset in tearDown() so it never leaks - * between tests - PHP runs every test in this class within the same - * process, and $GLOBALS['DIC'] (written by - * stubComponentRepositoryWithNoPlugins()) is genuinely global state too, - * for the same reason. - */ + /** Set by stubLoggerLangError(), attached to the GUI's `activity_error_logger`; reset in tearDown() as $GLOBALS['DIC'] is global state. */ private ?\ilLogger $stubbed_activity_error_logger = null; protected function tearDown(): void @@ -66,27 +43,11 @@ protected function tearDown(): void } /** - * activityErrorMessage() (see class docblock of the production class) - * logs a \Throwable's raw message via `$this->activity_error_logger` - * (a constructor-injected "lang" component logger - see - * RendersActivityErrors) before returning the generic, localized - * replacement text. This builds a mocked \ilLogger expecting exactly - * that call and stashes it so the GUI-building helpers below can wire - * it onto the instance under test via `activity_error_logger`. + * 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. * - * activityErrorMessage() logs `get_class($error) . ': ' . - * $error->getMessage() . "\n" . $error->getTraceAsString()` (see - * RendersActivityErrors::activityErrorMessage()) - the trace itself is - * environment-dependent (absolute file paths, line numbers), so only - * the ": \n" prefix is asserted here, not the full - * logged string. - * - * @param non-empty-string $expected_logged_message The raw \Throwable - * message activityErrorMessage() must log - never the generic - * replacement text shown to the user. - * @param class-string<\Throwable> $expected_exception_class The class - * of the \Throwable the test's Result\Error is constructed - * with. + * @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, @@ -104,13 +65,7 @@ private function stubLoggerLangError( $this->stubbed_activity_error_logger = $logger; } - /** - * The logger to wire onto a GUI instance's `activity_error_logger` - * property while building it for a test: whatever stubLoggerLangError() - * set up for this test, or - for tests that never expect a Throwable - * to be logged - a permissive stub that tolerates being left untouched - * or called incidentally. - */ + /** 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); @@ -136,17 +91,7 @@ private function createGuiWithCollaborators( return $gui; } - /** - * maybePerformAs() now takes the calling GUI's `ILIAS\UI\Factory::input()` - * as its (unused by these Activities, see LanguageActivity) first - * argument, so every reflection-constructed GUI instance that reaches a - * maybePerformAs() call needs a `ui_factory` (a typed, non-nullable - * property on the ilObjectGUI ancestor) - otherwise PHP fatals with - * "must not be accessed before initialization" before the mocked - * Activity is ever reached. What input() actually returns is irrelevant - * to any of these tests (the Activities never use it), so a bare mock - * is enough. - */ + /** 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); @@ -171,54 +116,6 @@ private function createLanguageMockReturningTopicAsIs(): ilLanguage&\PHPUnit\Fra return $lng; } - // ----------------------------------------------------------------- - // Regression coverage for the explicit ACL check in installObject(), - // refreshSelectedObject(), uninstallObject() and - // uninstallChangesObject(). - // - // All four write-command methods on this class - installObject(), - // uninstallObject(), uninstallChangesObject() and - // refreshSelectedObject() - call $this->checkPermission('write') as - // their very first statement (verified against the class body; see - // also commit 4c98c957d36, "re-introduce explicit permission checks"). - // This is a defense-in-depth check: all four additionally delegate to - // their respective Activity's isAllowedToPerform() (via - // maybePerformAs()) - InstallLanguage, UninstallLanguage, UpdateLanguage - // and RemoveLocalLanguageChanges respectively - so the permission is - // enforced twice: once explicitly here, once inside the Activity (plus - // the `table_action` dispatch in executeCommand(), which calls - // checkPermission('write') itself before invoking any of them when - // reached that way - but any other caller of one of these *public* - // methods gets no ACL protection beyond the method's own explicit - // check and its Activity's isAllowedToPerform()). - // - // checkPermissionBool() (which checkPermission() delegates to) simply - // returns false whenever $this->object is not an object - which it - // never is on a reflection-constructed instance - so checkPermission() - // takes its early, silent `return;` branch and never actually reaches - // $this->access, $this->tpl or $this->ctrl. That makes it impossible - // to fake a "permission denied" *outcome* through $access in this kind - // of lightweight unit test: the method no-ops the same way regardless - // of whether real RBAC would grant or deny 'write', and - even if it - // didn't - checkPermission() itself never throws or exits, it only - // records a flash message and calls $ctrl->redirectToURL(), which is a - // real redirect+exit only at the HTTP layer; against a mocked $ctrl it - // is just another recorded call, so it would not actually stop - // execution here either. A real integration test (with a genuine - // ilAccessHandler backed by RBAC/DB and a request that never reaches - // executeCommand()'s own gate) would be needed to observe an *outcome* - // difference (data changed vs. not changed) for a denied user. - // - // What a unit test *can* pin down directly is the contract itself: - // that the method calls $this->checkPermission('write') exactly once, - // as its first statement, before doing any work. This is a legitimate - // case for interaction verification (rule 16): the presence of that - // call *is* the contract under test, not an implementation detail. A - // partial mock of the GUI itself (stubbing only checkPermission(), a - // protected method not otherwise observable, while every other method - // keeps its real implementation) makes that call directly assertable. - // ----------------------------------------------------------------- - /** @return ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject */ private function createGuiWithMockedCheckPermission(): ilObjLanguageFolderGUI { @@ -232,8 +129,7 @@ private function createGuiWithMockedCheckPermission(): ilObjLanguageFolderGUI $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 declared on the leaf class cannot be found on a PHPUnit mock via a plain ReflectionProperty, - // so it is looked up on the declaring class + // 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, @@ -254,18 +150,7 @@ private function setReadonlyPropertyDeclaredOnGuiClass( ->setValue($gui, $value); } - /** - * uninstallObject() must call $this->checkPermission('write') exactly - * once, as its first statement, before doing any uninstall work - - * exactly like installObject()/refreshSelectedObject()/ - * uninstallChangesObject() do. In addition, UninstallLanguage enforces - * the same 'write' permission itself via isAllowedToPerform()/ - * maybePerformAs() (see - * UninstallLanguageTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall() - * for that side of the contract) - this is a deliberate - * defense-in-depth: the GUI-level check here does not replace the - * Activity-level check, both must hold. - */ + /** checkPermission('write') is the first statement; the Activity enforces the same permission, both must hold. */ public function testUninstallObjectChecksWritePermissionBeforeActing(): void { $gui = $this->createGuiWithMockedCheckPermission(); @@ -280,13 +165,7 @@ public function testUninstallObjectChecksWritePermissionBeforeActing(): void $gui->uninstallObject([]); } - /** - * installObject() must check 'write' permission before doing any - * installation work - exactly like its siblings - * uninstallObject()/refreshSelectedObject()/uninstallChangesObject() - * do (see the class body: all four call $this->checkPermission('write') - * as their first statement). - */ + /** installObject() checks 'write' before any work, like its siblings. */ public function testInstallObjectMustCheckWritePermissionBeforeActing(): void { $gui = $this->createGuiWithMockedCheckPermission(); @@ -300,10 +179,7 @@ public function testInstallObjectMustCheckWritePermissionBeforeActing(): void $gui->installObject([], InstallLanguage::MODE_INSTALL); } - /** - * refreshSelectedObject() variant: same reasoning as - * testInstallObjectMustCheckWritePermissionBeforeActing() above. - */ + /** refreshSelectedObject() variant. */ public function testRefreshSelectedObjectMustCheckWritePermissionBeforeActing(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -318,18 +194,7 @@ public function testRefreshSelectedObjectMustCheckWritePermissionBeforeActing(): $gui->refreshSelectedObject([]); } - /** - * uninstallChangesObject() variant: same reasoning as - * testInstallObjectMustCheckWritePermissionBeforeActing() above. Also - * mirrors the defense-in-depth reasoning of - * testUninstallObjectChecksWritePermissionBeforeActing(): in addition - * to this explicit GUI-level check, RemoveLocalLanguageChanges enforces - * the same 'write' permission itself via isAllowedToPerform()/ - * maybePerformAs() (see - * RemoveLocalLanguageChangesTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemoveLocalChanges() - * for that side of the contract) - the GUI-level check here does not - * replace the Activity-level check, both must hold. - */ + /** uninstallChangesObject() variant; the Activity enforces the same permission, both must hold. */ public function testUninstallChangesObjectMustCheckWritePermissionBeforeActing(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -350,26 +215,7 @@ public function testUninstallChangesObjectMustCheckWritePermissionBeforeActing() $gui->uninstallChangesObject([]); } - /** - * Regression test for installObject()'s explicit "empty selection" - * guard (checked BEFORE the Activity is ever called): an empty $ids - * must show the established 'no_checkbox' failure message and redirect - * to "view" WITHOUT ever calling InstallLanguage::maybePerformAs() - - * before this guard existed, an empty selection reached the Activity - * itself, which rejected it with a technical, untranslated message - * (e.g. "language_keys: not_min_length"). - * - * Note: because of this guard, $ids=[] can no longer be used (as it - * was before this production change) to exercise installObject()'s - * Result-handling logic (error/success/info message assembly, $mode - * pass-through) while sidestepping the method's other legacy coupling - * (`new ilObjLanguage((int) $obj_id)` for a non-empty $ids, which - * cannot be safely reached in this kind of lightweight unit test - see - * class docblock). That logic is therefore no longer directly - * unit-testable via this public method; the underlying business logic - * it merely assembles into a message remains covered by - * InstallLanguageTest (perform()/maybePerformAs()). - */ + /** 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); @@ -449,22 +295,7 @@ private function updatePerformResult(array $updated, array $not_installed): arra ]; } - /** - * Regression test for refreshSelectedObject()'s explicit "empty - * selection" guard - same reasoning as - * testInstallObjectWithEmptySelectionShowsNoCheckboxMessageAndNeverCallsTheActivity() - * above: an empty $ids must show 'no_checkbox' and redirect WITHOUT - * ever calling UpdateLanguage::maybePerformAs(). - * - * Note: as with installObject(), this guard means $ids=[] can no - * longer be used to exercise refreshSelectedObject()'s own - * Result-handling logic (error/success/info message assembly) while - * sidestepping its other legacy coupling (`new ilObjLanguage((int) - * $obj_id)` for a non-empty $ids) - see class docblock. That logic is - * no longer directly unit-testable via this public method; the - * underlying business logic it assembles into a message remains - * covered by UpdateLanguageTest (perform()/maybePerformAs()). - */ + /** 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); @@ -491,16 +322,6 @@ public function testRefreshSelectedObjectWithEmptySelectionShowsNoCheckboxMessag $gui->refreshSelectedObject([]); } - // ----------------------------------------------------------------- - // uninstallObject() - // - // Same reflection-based construction technique as installObject()/ - // refreshSelectedObject() above, with $ids always [] for the same - // reason: the only other piece of legacy coupling in the method - // (`ilObject::_lookupTitle((int) $obj_id)` inside the per-id loop - // that builds $language_keys) must never be reached in a unit test. - // ----------------------------------------------------------------- - private function createGuiWithUninstallLanguageCollaborators( UninstallLanguage $uninstall_language, ilGlobalTemplateInterface $tpl, @@ -601,10 +422,7 @@ public function testUninstallObjectRedirectsToViewAfterAnErrorResultAndDoesNotCo new ResultError(new \RuntimeException('boom')) ); - // If the `return` after the redirect were ever dropped, execution - // would fall through to `$result->value()` - which throws on an - // Error result (see ILIAS\Data\Result\Error::value()) - so this - // would surface as a test error rather than silently passing. + // 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'); @@ -624,23 +442,7 @@ public function testUninstallObjectRedirectsToViewAfterAnErrorResultAndDoesNotCo $gui->uninstallObject([]); } - /** - * $ids is resolved to language keys via - * ilObject::_lookupTitle((int) $obj_id) - untestable in isolation - * without a real database, so - exactly like the existing - * install/update tests - this is only exercised with an empty $ids - * array, which is enough to pin that maybePerformAs() receives an - * (empty) 'language_keys' array and no other key. - * - * An empty $ids array means UninstallLanguage::toLanguageKeyList([]) - * would, in reality, throw \InvalidArgumentException('At least one - * language key is required.') - the real Activity is never reached - * here (it is mocked), but the mock is stubbed to return that same - * realistic error instead of an artificial Ok result, so this test does - * not silently pretend an empty request succeeds (m6). The 'with()' - * expectation below - the actual point of this test - is unaffected - * either way. - */ + /** $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); @@ -694,14 +496,7 @@ public function testUninstallObjectSetsSuccessMessageWhenOnlyUninstalledLanguage $gui->uninstallObject([]); } - /** - * system_language_keys, user_language_keys and not_installed_language_keys - * are all "nothing changed for this language, here is why" outcomes. - * setOnScreenMessage() only keeps one message per type, so - exactly - * like installObject()'s combined info message - all three buckets - * must be combined into a single 'info' call instead of three - * separate calls that would silently overwrite each other. - */ + /** 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); @@ -733,12 +528,7 @@ public function testUninstallObjectCombinesSystemUserAndNotInstalledIntoOneInfoM $gui->uninstallObject([]); } - /** - * A single request can mix a genuinely uninstalled language with all - * three "nothing changed" buckets at once - the success message and - * the combined info message must both be set, as two separate calls - * (different message types), neither one clobbering the other. - */ + /** 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); @@ -771,10 +561,7 @@ public function testUninstallObjectSetsBothSuccessAndInfoMessagesWhenBothCategor self::assertSame(['info', 'cannot_uninstall_systemlanguage: meta_l_en'], $captured_calls[1]); } - /** - * Boundary: when every bucket is empty (e.g. an empty $ids request), - * no message at all must appear - not even an empty one. - */ + /** With every bucket empty no message appears, not even an empty one. */ public function testUninstallObjectSetsNoMessageWhenAllBucketsAreEmpty(): void { $uninstall_language = $this->createMock(UninstallLanguage::class); @@ -801,23 +588,6 @@ public function testUninstallObjectSetsNoMessageWhenAllBucketsAreEmpty(): void $gui->uninstallObject([]); } - // ----------------------------------------------------------------- - // uninstallChangesObject() - // - // Same reflection-based construction technique as uninstallObject() - // above, with $ids always [] for the same reason: the only other piece - // of legacy coupling in the method (`ilObject::_lookupTitle((int) - // $obj_id)` inside the per-id loop that builds $language_keys) must - // never be reached in a unit test. - // - // Unlike uninstallObject(), uninstallChangesObject() calls the legacy - // static ilObjLanguage::refreshPlugins() unconditionally on every - // success path (even when nothing was changed) - exactly like - // refreshSelectedObject() does (see stubComponentRepositoryWithNoPlugins()'s - // docblock above for why an empty plugin iterator is what keeps this a - // unit test), so every success-path test below needs that same stub. - // ----------------------------------------------------------------- - private function createGuiWithRemoveLocalLanguageChangesCollaborators( RemoveLocalLanguageChanges $remove_local_language_changes, ilGlobalTemplateInterface $tpl, @@ -855,10 +625,7 @@ private function removeLocalChangesPerformResult( public function testUninstallChangesObjectEmbedsTheThrowableMessageFromAnErrorResultIntoTheFailureMessage(): void { - // The error path is reached before ilObjLanguage::refreshPlugins() - // would ever be called, so stubComponentRepositoryWithNoPlugins() is - // not needed here - only the "lang" logger stub activityErrorMessage() - // now requires for a \Throwable error. + // the error path returns before ilObjLanguage::refreshPlugins(); only the "lang" logger stub is needed $exception_message = 'boom'; $this->stubLoggerLangError($exception_message); @@ -867,8 +634,7 @@ public function testUninstallChangesObjectEmbedsTheThrowableMessageFromAnErrorRe new ResultError(new \RuntimeException($exception_message)) ); - // uninstallChangesObject() wraps the message as - // "{error}
action_aborted", same as uninstallObject(). + // wrapped as "{error}
action_aborted", like uninstallObject() $tpl = $this->createMock(ilGlobalTemplateInterface::class); $tpl->expects($this->once()) ->method('setOnScreenMessage') @@ -914,11 +680,7 @@ public function testUninstallChangesObjectShowsTheNoPermissionMessageForA403Erro public function testUninstallChangesObjectRedirectsToViewAfterAnErrorResultAndDoesNotContinueToTheSuccessPath(): void { - // The error path is reached (and returns) before - // ilObjLanguage::refreshPlugins() would ever be called, so - // stubComponentRepositoryWithNoPlugins() is not needed here - only - // the "lang" logger stub activityErrorMessage() now requires for a - // \Throwable error. + // 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); @@ -926,13 +688,7 @@ public function testUninstallChangesObjectRedirectsToViewAfterAnErrorResultAndDo new ResultError(new \RuntimeException('boom')) ); - // If the `return` after the redirect were ever dropped, execution - // would fall through to `$result->value()` - which throws on an - // Error result (see ILIAS\Data\Result\Error::value()) - and, even - // past that, to the unconditional ilObjLanguage::refreshPlugins() - // call, which reaches into `global $DIC["component.repository"]` - // and is not stubbed here - so this would surface as a test error - // rather than silently passing. + // 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'); @@ -952,26 +708,7 @@ public function testUninstallChangesObjectRedirectsToViewAfterAnErrorResultAndDo $gui->uninstallChangesObject([]); } - /** - * $ids is resolved to language keys via - * ilObject::_lookupTitle((int) $obj_id) - untestable in isolation - * without a real database, so - exactly like the existing - * uninstallObject() test - this is only exercised with an empty $ids - * array, which is enough to pin that maybePerformAs() receives an - * (empty) 'language_keys' array and no other key. - * - * An empty $ids array means - * RemoveLocalLanguageChanges::toLanguageKeyList([]) would, in reality, - * throw \InvalidArgumentException('At least one language key is - * required.') - the real Activity is never reached here (it is - * mocked), but the mock is stubbed to return that same realistic error - * instead of an artificial Ok result, so this test does not silently - * pretend an empty request succeeds (m6). This also means the error - * path is taken, so stubComponentRepositoryWithNoPlugins() is no longer - * needed (refreshPlugins() is never reached on the error path) - only - * the "lang" logger stub activityErrorMessage() now requires for a - * \Throwable error is. - */ + /** $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); @@ -1081,14 +818,7 @@ public function testUninstallChangesObjectSetsInfoMessageWhenOnlyNotInstalledLan $gui->uninstallChangesObject([]); } - /** - * Unlike uninstallObject()'s combined info message (three "nothing - * happened" buckets sharing one message type, therefore explicitly - * combined into one call to avoid a silent overwrite), all three - * buckets here are different message types (success/failure/info) - so - * all three setOnScreenMessage() calls must actually happen, as three - * separate calls, none clobbering another. - */ + /** The three buckets are different message types, so all three setOnScreenMessage() calls must happen. */ public function testUninstallChangesObjectSetsAllThreeMessagesWhenAllBucketsAreNonEmpty(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -1124,13 +854,7 @@ public function testUninstallChangesObjectSetsAllThreeMessagesWhenAllBucketsAreN self::assertSame(['info', 'meta_l_it language_not_installed'], $captured_calls[2]); } - /** - * Boundary: when every bucket is empty (e.g. an empty $ids request), - * no message at all must appear - not even an empty one. This is also - * a deliberate behavior change against the pre-Activity legacy code, - * which always showed a "selected_languages_updated" message, even - * when nothing changed. - */ + /** With every bucket empty no message appears; unlike the legacy code, which always showed "selected_languages_updated". */ public function testUninstallChangesObjectSetsNoMessageWhenAllBucketsAreEmpty(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -1159,32 +883,9 @@ public function testUninstallChangesObjectSetsNoMessageWhenAllBucketsAreEmpty(): $gui->uninstallChangesObject([]); } - // ----------------------------------------------------------------- - // enableLanguageDetectionObject() / disableLanguageDetectionObject() - // (both backed by setLanguageDetectionEnabledObject()) - // - // Unlike installObject()/uninstallObject()/refreshSelectedObject()/ - // uninstallChangesObject(), neither of these two methods calls - // $this->checkPermission('write') itself - this is unchanged legacy - // behaviour: before the extraction, only the toggle - // button's visibility in viewObject() was write-gated, the request - // itself was not. The extraction deliberately does not retrofit a - // GUI-level checkPermission() call here (that would be an unrequested, - // additional behavioural change on top of the intended one) - the - // *only* enforcement point is now SetLanguageDetectionEnabled's own - // isAllowedToPerform(), reached through maybePerformAs(). This is - // asserted directly below, in contrast to the defense-in-depth tests - // above for the other four write methods. - // - // Both action methods are `protected` (dispatched only via ilCtrl), - // hence invoked here through reflection. - // ----------------------------------------------------------------- - private function invokeProtectedMethod(object $object, string $method_name, array $args = []): mixed { - // ReflectionMethod::setAccessible() has had no effect (and has been - // deprecated) since PHP 8.1 - invoke() already bypasses visibility - // on its own since then. + // invoke() bypasses visibility, setAccessible() is deprecated since PHP 8.1 return (new ReflectionMethod($object, $method_name))->invoke($object, ...$args); } @@ -1210,11 +911,7 @@ private function createGuiWithSetLanguageDetectionEnabledCollaborators( public function testEnableLanguageDetectionObjectCallsMaybePerformAsWithEnabledTrue(): void { - // The stubbed Error result's \Throwable is unconditionally passed - // through activityErrorMessage(), which now logs it via the "lang" - // component logger - this test is only about the maybePerformAs() - // arguments, but still needs the logger stub so the (irrelevant to - // this test) logging call does not fatally error on a null $DIC. + // 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); @@ -1235,9 +932,7 @@ public function testEnableLanguageDetectionObjectCallsMaybePerformAsWithEnabledT public function testDisableLanguageDetectionObjectCallsMaybePerformAsWithEnabledFalse(): void { - // See testEnableLanguageDetectionObjectCallsMaybePerformAsWithEnabledTrue() - // above for why this stub is needed even though this test itself is - // only about the maybePerformAs() arguments. + // logger stub needed for the same reason as above $this->stubLoggerLangError('boom'); $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); @@ -1266,8 +961,7 @@ public function testSetLanguageDetectionEnabledObjectEmbedsTheThrowableMessageAn new ResultError(new \RuntimeException($exception_message)) ); - // setLanguageDetectionEnabledObject() wraps the message as - // "{error}
action_aborted", same as uninstallObject(). + // wrapped as "{error}
action_aborted", like uninstallObject() $tpl = $this->createMock(ilGlobalTemplateInterface::class); $tpl->expects($this->once()) ->method('setOnScreenMessage') @@ -1286,29 +980,11 @@ public function testSetLanguageDetectionEnabledObjectEmbedsTheThrowableMessageAn $this->createLanguageMockReturningTopicAsIs() ); - // If the `return` after the redirect were ever dropped, execution - // would fall through to the success path, which calls - // $this->viewObject() - a method that reaches deep into - // un-stubbed legacy collaborators ($this->languageFolderTable, - // $this->ui_factory, $this->toolbar, ...) on this - // reflection-constructed instance and would surface as a fatal - // error/TypeError rather than silently passing. + // a dropped `return` after the redirect would run the success path into un-stubbed legacy collaborators $this->invokeProtectedMethod($gui, 'enableLanguageDetectionObject'); } - /** - * Regression test documenting the deliberate asymmetry described above: - * unlike installObject()/uninstallObject()/refreshSelectedObject()/ - * uninstallChangesObject() (all four call $this->checkPermission('write') - * as their first statement, see the tests earlier in this class), - * enableLanguageDetectionObject()/disableLanguageDetectionObject() must - * NOT call checkPermission() themselves - this was true before the - * extraction and remains true after it. The only enforcement is now - * SetLanguageDetectionEnabled::isAllowedToPerform() via maybePerformAs() - * (see SetLanguageDetectionEnabledTest:: - * testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWritesTheSetting() - * for that side of the contract). - */ + /** 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 */ @@ -1330,11 +1006,7 @@ public function testSetLanguageDetectionEnabledObjectNeverCallsCheckPermission() ); $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); - // Permission denial is simulated at the Activity level (an Error - // result) so the success path (which would call $this->viewObject(), - // unreachable here) is never taken - this test is only about - // checkPermission() never being invoked, not about the outcome of - // the permission check itself. + // 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)) ); @@ -1348,43 +1020,12 @@ public function testSetLanguageDetectionEnabledObjectNeverCallsCheckPermission() $this->invokeProtectedMethod($gui, 'disableLanguageDetectionObject'); } - // ----------------------------------------------------------------- - // abortIfAnyIdIsNotALanguageObject() - the blocker fix: every request- - // supplied id in $ids must be confirmed to be a "lng" object BEFORE its - // title is ever trusted as a language key (see the method's own - // docblock in the production class for the full rationale). Exercised - // indirectly through every public/protected caller below, since the - // method itself is private. - // - // ilObject::_lookupType()/_lookupTitle() are both backed by - // `global $DIC["ilObjDataCache"]` (see class.ilObject.php) - stubbed via - // stubObjDataCache() below, exactly like stubComponentRepositoryWithNoPlugins() - // already stubs `global $DIC["component.repository"]` for - // ilObjLanguage::refreshPlugins(). - // ----------------------------------------------------------------- - /** - * Stubs `global $DIC["ilObjDataCache"]` (see ilObject::_lookupType()/ - * _lookupTitle()) so abortIfAnyIdIsNotALanguageObject() and the - * downstream `ilObject::_lookupTitle((int) $obj_id)` calls in the - * production class can be driven from a unit test without a real - * database. + * Stubs `global $DIC["ilObjDataCache"]` for ilObject::_lookupType()/_lookupTitle(). Reuses an existing + * $GLOBALS['DIC'] so it can coexist with stubComponentRepositoryWithNoPlugins(). * - * Reuses (rather than overwrites) an already-present $GLOBALS['DIC'] - * container - e.g. one stubComponentRepositoryWithNoPlugins() built - * first in the same test for ilObjLanguage::refreshPlugins() - so both - * stubs can coexist when a single test needs both. - * - * @param array $types_by_id obj_id => type (e.g. "lng", - * "cat"); an id missing from this array resolves to "" - a - * never-installed/non-existing object, exactly like the real - * ilObjectDataCache. - * @param array $titles_by_id obj_id => title. Ignored (and - * lookupTitle() must never even be called - enforced via - * expects($this->never())) when $expect_no_title_lookup is true, - * which every "the request must be aborted before any id's title - * is trusted" test below uses: that IS the property the blocker - * fix guarantees, not just an implementation detail. + * @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, @@ -1409,14 +1050,7 @@ private function stubObjDataCache( $GLOBALS['DIC']['ilObjDataCache'] = $cache; } - /** - * Regression test for installObject(): a single non-"lng" id among - * otherwise-plausible ids must abort the WHOLE request - the established - * 'obj_not_found'+'action_aborted' failure message, a redirect to - * "view", and InstallLanguage::maybePerformAs() must NEVER be called - * (i.e. nothing about the request is honoured, see - * abortIfAnyIdIsNotALanguageObject()'s own docblock). - */ + /** 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); @@ -1445,13 +1079,7 @@ public function testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCall $gui->installObject(['5'], InstallLanguage::MODE_INSTALL); } - /** - * Regression test documenting that installObject()'s previous behaviour - * (for ids that ARE all "lng" objects) is unchanged by the blocker fix: - * InstallLanguage::maybePerformAs() must still be called, with - * 'language_keys' resolved from ilObject::_lookupTitle() exactly as - * before. - */ + /** 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']); @@ -1475,10 +1103,7 @@ public function testInstallObjectWithAllLanguageObjectIdsStillCallsInstallLangua $gui->installObject(['5'], InstallLanguage::MODE_INSTALL); } - /** - * uninstallObject() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(). - */ + /** uninstallObject() variant. */ public function testUninstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsUninstallLanguage(): void { $this->stubObjDataCache([5 => 'usr'], [], true); @@ -1507,11 +1132,7 @@ public function testUninstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCa $gui->uninstallObject(['5']); } - /** - * Regression test documenting that uninstallObject()'s previous - * behaviour (for ids that ARE all "lng" objects) is unchanged by the - * blocker fix. - */ + /** uninstallObject() with all-"lng" ids is unchanged. */ public function testUninstallObjectWithAllLanguageObjectIdsStillCallsUninstallLanguageWithResolvedLanguageKeys(): void { $this->stubObjDataCache([5 => 'lng'], [5 => 'de']); @@ -1535,10 +1156,7 @@ public function testUninstallObjectWithAllLanguageObjectIdsStillCallsUninstallLa $gui->uninstallObject(['5']); } - /** - * uninstallChangesObject() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(). - */ + /** uninstallChangesObject() variant. */ public function testUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsRemoveLocalLanguageChanges(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1567,10 +1185,7 @@ public function testUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObjectAnd $gui->uninstallChangesObject(['5']); } - /** - * refreshSelectedObject() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(). - */ + /** refreshSelectedObject() variant. */ public function testRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsUpdateLanguage(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1600,23 +1215,14 @@ public function testRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObjectAndN } /** - * Builds a `ui_factory` stub sufficient for buildConfirmModal()'s abort - * branch: `$f->modal()->interruptive($title, '', '')->withActionButtonLabel(...)`. - * Returns the exact Interruptive instance that chain produces, so a test - * can assert buildConfirmModal() returned it unchanged (i.e. that the - * abort branch's `return` was actually taken, rather than falling - * through into the per-id loop). + * 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() is declared to return the concrete - // Implementation\Component\Modal\Interruptive class, not just the - // M\Interruptive interface - a mock of the interface alone fails - // that return type check, so the concrete class must be mocked - // here instead (createMock() disables its constructor, so the real - // SignalGeneratorInterface dependency is never actually needed). + // 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(); @@ -1629,21 +1235,7 @@ private function createStubModalUiFactory(): array return [$ui_factory, $interruptive]; } - /** - * buildConfirmModal() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(): - * a non-"lng" id must abort exactly like the four write-command methods - * above (buildConfirmModal() delegates to the very same - * abortIfAnyIdIsNotALanguageObject()) - the method's return type still - * requires a Modal value even on abort (see the inline comment on that - * abort branch: never actually rendered in production, since the - * redirect takes precedence), which this test also pins down: the - * exact Interruptive instance the - * abort branch itself builds must be returned, proving the per-id loop - * (and therefore ilObject::_lookupTitle()/ilObjLanguage::_getLastLocalChange()) - * was never reached - no data belonging to the wrongly-typed object ever - * leaks into the built UI. - */ + /** 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); @@ -1680,12 +1272,7 @@ public function testBuildConfirmModalAbortsWhenAnyIdIsNotALanguageObjectAndNever $this->assertSame($expected_interruptive, $result); } - /** - * confirmRefreshSelectedObject() variant of the abort tests above: takes - * $ids straight from its own $a_ids argument, so - unlike - * confirmUninstallObject()/confirmUninstallChangesObject() below - no - * request_wrapper/id_token stubbing is needed to reach the abort check. - */ + /** confirmRefreshSelectedObject() variant: takes $ids from its $a_ids argument, so no request_wrapper stubbing is needed. */ public function testConfirmRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObject(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1711,14 +1298,7 @@ public function testConfirmRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObj $gui->confirmRefreshSelectedObject(['5']); } - /** - * Builds a reflection-constructed GUI instance whose private - * getIdsFromQueryToken() (used by confirmUninstallObject()/ - * confirmUninstallChangesObject()) resolves to exactly $ids - stubbing - * `request_wrapper`/`id_token`/`refinery` (see the production method's - * body) rather than mocking the private method itself, which PHPUnit - * cannot intercept (private methods are not polymorphic in PHP). - */ + /** Stubs `request_wrapper`/`id_token`/`refinery` so the private getIdsFromQueryToken() resolves to $ids (PHPUnit cannot intercept private methods). */ private function createGuiWithIdsFromQueryToken( array $ids, ilLanguage $lng, @@ -1751,9 +1331,7 @@ private function createGuiWithIdsFromQueryToken( return $gui; } - /** - * confirmUninstallObject() variant of the abort tests above. - */ + /** confirmUninstallObject() variant. */ public function testConfirmUninstallObjectAbortsWhenAnyIdIsNotALanguageObject(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1779,9 +1357,7 @@ public function testConfirmUninstallObjectAbortsWhenAnyIdIsNotALanguageObject(): $gui->confirmUninstallObject(); } - /** - * confirmUninstallChangesObject() variant of the abort tests above. - */ + /** confirmUninstallChangesObject() variant. */ public function testConfirmUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObject(): void { $this->stubObjDataCache([5 => 'cat'], [], true); From 39edbb60c5c9371fe8aff63ebe38f4fd0b06c38d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 19 Oct 2026 12:13:33 +0200 Subject: [PATCH 017/103] Language: Give the language service only to `AddLanguageEntry` `LanguageActivity` no longer takes `Language`: only `AddLanguageEntry` needs it, for the labels of its translation fields. The other six Activities, their `forSetup()` variants and the wiring drop the parameter. --- components/ILIAS/Language/Language.php | 6 ------ .../Language/src/Activities/AddLanguageEntry.php | 4 ++-- .../Language/src/Activities/InstallLanguage.php | 5 +---- .../Language/src/Activities/LanguageActivity.php | 5 ----- .../src/Activities/RemoveLocalLanguageChanges.php | 4 +--- .../src/Activities/SetLanguageDetectionEnabled.php | 4 +--- .../Activities/SetLanguageTranslationEnabled.php | 4 +--- .../Language/src/Activities/UninstallLanguage.php | 4 +--- .../Language/src/Activities/UpdateLanguage.php | 5 +---- .../tests/Activities/ActivityInputSectionTest.php | 12 ++++++------ .../tests/Activities/InstallLanguageTest.php | 9 +-------- .../Activities/LanguageActivitySetupVariantTest.php | 1 - .../tests/Activities/LanguageActivityTest.php | 13 ++----------- .../Activities/RemoveLocalLanguageChangesTest.php | 10 +--------- .../Activities/SetLanguageDetectionEnabledTest.php | 8 +------- .../SetLanguageTranslationEnabledTest.php | 8 +------- .../tests/Activities/UninstallLanguageTest.php | 9 +-------- .../tests/Activities/UpdateLanguageTest.php | 9 +-------- .../Language/tests/ilLanguageSetupAgentTest.php | 2 -- 19 files changed, 22 insertions(+), 100 deletions(-) diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index cc467bdec4de..16b342e03093 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -82,7 +82,6 @@ public function init( $internal[InstallLanguage::class] = static fn() => new InstallLanguage( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $runtime_setup_language(), $internal[Language\LanguageFolder::class] @@ -90,7 +89,6 @@ public function init( $internal[UpdateLanguage::class] = static fn() => new UpdateLanguage( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $runtime_setup_language(), $internal[Language\LanguageFolder::class] @@ -105,7 +103,6 @@ public function init( $internal[UninstallLanguage::class] = static fn() => new UninstallLanguage( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], @@ -113,7 +110,6 @@ public function init( $internal[RemoveLocalLanguageChanges::class] = static fn() => new RemoveLocalLanguageChanges( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], @@ -129,7 +125,6 @@ public function init( $internal[SetLanguageDetectionEnabled::class] = static fn() => new SetLanguageDetectionEnabled( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], @@ -137,7 +132,6 @@ public function init( $internal[SetLanguageTranslationEnabled::class] = static fn() => new SetLanguageTranslationEnabled( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php index 74e602fd14f8..d578807fa0ec 100644 --- a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -31,12 +31,12 @@ class AddLanguageEntry extends LanguageActivity { public function __construct( - Language $language, + private readonly Language $lng, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/InstallLanguage.php b/components/ILIAS/Language/src/Activities/InstallLanguage.php index 90f14e92d044..f7ae7d7b44a9 100644 --- a/components/ILIAS/Language/src/Activities/InstallLanguage.php +++ b/components/ILIAS/Language/src/Activities/InstallLanguage.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\Language\LanguageFolderLegacyProxy; use ILIAS\UI\Component\Input\Container\Form\FormInput; @@ -37,18 +36,16 @@ class InstallLanguage extends LanguageActivity public const string MODE_INSTALL_LOCAL = 'install_local'; public function __construct( - Language $language, ?RBAC $rbac, private readonly \ilSetupLanguage $setup_language, LanguageFolder $language_folder, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public static function forSetup(\ilSetupLanguage $setup_language): self { return new self( - $setup_language, null, $setup_language, // never read: without RBAC, isAllowedToPerform() throws first diff --git a/components/ILIAS/Language/src/Activities/LanguageActivity.php b/components/ILIAS/Language/src/Activities/LanguageActivity.php index 1b7db554bc08..64f5c0131f18 100644 --- a/components/ILIAS/Language/src/Activities/LanguageActivity.php +++ b/components/ILIAS/Language/src/Activities/LanguageActivity.php @@ -25,7 +25,6 @@ use ILIAS\Component\Activities\ActivityType; use ILIAS\Data\Result; use ILIAS\Data\Factory as DataFactory; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Factory as InputFactory; @@ -33,16 +32,12 @@ abstract class LanguageActivity extends ActivityImpl { use GrindsFormInput; - protected Language $lng; - /** @param RBAC|null $rbac null only for forSetup(); isAllowedToPerform() then throws. */ public function __construct( - Language $language, private readonly ?RBAC $rbac, private readonly LanguageFolder $language_folder, protected readonly DataFactory $data = new DataFactory(), ) { - $this->lng = $language; } public function getType(): ActivityType diff --git a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php index 6b763c6f967f..0bf7fa24a2fa 100644 --- a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php +++ b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; class RemoveLocalLanguageChanges extends LanguageActivity @@ -32,12 +31,11 @@ class RemoveLocalLanguageChanges extends LanguageActivity use ResolvesLanguageKeysToObjIds; public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php b/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php index 9708f3003c10..6e2cf0e96834 100644 --- a/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php +++ b/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; @@ -31,12 +30,11 @@ class SetLanguageDetectionEnabled extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php index eb7c42e6a49b..4ed0ffca208c 100644 --- a/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php +++ b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; @@ -31,12 +30,11 @@ class SetLanguageTranslationEnabled extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/UninstallLanguage.php b/components/ILIAS/Language/src/Activities/UninstallLanguage.php index 12d7dc9e35b2..c6dfa6942020 100644 --- a/components/ILIAS/Language/src/Activities/UninstallLanguage.php +++ b/components/ILIAS/Language/src/Activities/UninstallLanguage.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; class UninstallLanguage extends LanguageActivity @@ -32,12 +31,11 @@ class UninstallLanguage extends LanguageActivity use ResolvesLanguageKeysToObjIds; public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/UpdateLanguage.php b/components/ILIAS/Language/src/Activities/UpdateLanguage.php index 3948fc02ada6..8a66b2fa0ae6 100644 --- a/components/ILIAS/Language/src/Activities/UpdateLanguage.php +++ b/components/ILIAS/Language/src/Activities/UpdateLanguage.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\Language\LanguageFolderLegacyProxy; @@ -32,18 +31,16 @@ class UpdateLanguage extends LanguageActivity use DeclaresLanguageKeysOnlyInput; public function __construct( - Language $language, ?RBAC $rbac, private readonly \ilSetupLanguage $setup_language, LanguageFolder $language_folder, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public static function forSetup(\ilSetupLanguage $setup_language): self { return new self( - $setup_language, null, $setup_language, // never read: without RBAC, isAllowedToPerform() throws first diff --git a/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php b/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php index 9995ecf9ff11..c6911e5a4623 100644 --- a/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php +++ b/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php @@ -76,13 +76,13 @@ private function activity(string $name): LanguageActivity $repository = $this->createMock(RepositoryProvider::class); return match ($name) { - 'install' => new InstallLanguage($language, $rbac, $setup_language, $folder), - 'update' => new UpdateLanguage($language, $rbac, $setup_language, $folder), - 'uninstall' => new UninstallLanguage($language, $rbac, $folder, $repository), - 'remove' => new RemoveLocalLanguageChanges($language, $rbac, $folder, $repository), + '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($language, $rbac, $folder, $repository), - 'translation' => new SetLanguageTranslationEnabled($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/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index f8368676d331..ec92890e4729 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -20,7 +20,6 @@ use ILIAS\Language\Activities\InvalidInputException; use ILIAS\UI\Component\Input\Field\Text; use ILIAS\UI\Component\Input\Field\Select; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -558,13 +557,9 @@ public function testPermissionDeniedBeforePerform(): void $setup_language = $this->createSetupLanguageMock([], [], []); $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $setup_language, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de', 'mode' => InstallLanguage::MODE_INSTALL]); $this->assertTrue($result->isError()); @@ -573,10 +568,8 @@ public function testPermissionDeniedBeforePerform(): void private function createActivity( ilSetupLanguage $setup_language, ?\ilRbacSystem $rbac = null, - ?Language $language = null ): InstallLanguage { return new InstallLanguage( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $setup_language, $this->languageFolder() diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php index 9ebb1b68e28e..908818724509 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php @@ -62,7 +62,6 @@ public function testWebActivityAsksRbacSystemOnlyInIsAllowedToPerform(): void $rbac->expects(self::once())->method('system')->willReturn($system); $activity = new InstallLanguage( - $this->createStub(\ILIAS\Language\Language::class), $rbac, $this->createStub(\ilSetupLanguage::class), $this->languageFolder(77) diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 18c9fe367a75..4b8e490ef382 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -24,7 +24,6 @@ use ILIAS\Data\Description; use ILIAS\Data\Text; use ILIAS\Language\Activities\LanguageActivity; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; @@ -49,7 +48,6 @@ public function testExceptionWhileBuildingTheInputDescriptionIsMaskedAsA500Resul $rbac->expects($this->never())->method('checkAccessOfUser'); $activity = new LanguageActivityThrowingInputDescriptionTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac), $this->languageFolder() ); @@ -69,7 +67,6 @@ public function testExceptionWhileBuildingTheInputDescriptionIsMaskedAsA500Resul public function testATypeErrorWhileBuildingTheInputDescriptionIsMaskedAsA500ResultError(): void { $activity = new LanguageActivityThrowingInputDescriptionTestDouble( - $this->createMock(Language::class), $this->rbacOf($this->createMock(\ilRbacSystem::class)), $this->languageFolder(), throw_type_error: true, @@ -87,7 +84,6 @@ public function testATypeErrorWhileBuildingTheInputDescriptionIsMaskedAsA500Resu public function testADefectThrownByGrindIsMaskedAsA500ResultError(): void { $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($this->createMock(\ilRbacSystem::class)), $this->languageFolder(), $this->createMock(FormInput::class), @@ -118,7 +114,6 @@ public function testIsAllowedToPerformRefusesNonUsersWithoutAskingRbac(?int $usr $rbac_system->expects($this->never())->method('checkAccessOfUser'); $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac_system), $this->languageFolder() ); @@ -132,7 +127,6 @@ public function testMaybePerformAsAnswers403ForANonUserAndNeverPerforms(): void $rbac_system->expects($this->never())->method('checkAccessOfUser'); $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac_system), $this->languageFolder() ); @@ -152,7 +146,6 @@ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedForm $rbac->method('checkAccessOfUser')->willReturn(true); $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac), $this->languageFolder() ); @@ -171,12 +164,11 @@ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedForm final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly bool $throw_type_error = false, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown @@ -216,12 +208,11 @@ protected function normalizeParameters(array $grind_result): array final class LanguageActivityPerformEchoTestDouble extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly ?FormInput $description = null, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index f13d58bb738f..d73a56eb5b24 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -19,7 +19,6 @@ use ILIAS\Language\Activities\AmbiguousLanguageTitleException; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\RemoveLocalLanguageChanges; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -240,7 +239,6 @@ public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfig static fn(): array => [], static fn(int $id) => null, $rbac, - null, 42 ); @@ -442,14 +440,10 @@ public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemove [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ 'de' => [], ]); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $lng_objects, $obj_language_factory, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); $this->assertTrue($result->isError()); @@ -538,11 +532,9 @@ private function createActivity( \Closure $lng_objects, \Closure $obj_language_factory, ?\ilRbacSystem $rbac = null, - ?Language $language = null, int $language_folder_ref_id = 0 ): RemoveLocalLanguageChanges { return new RemoveLocalLanguageChanges( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder($language_folder_ref_id), $this->languageObjectProvider($lng_objects, $obj_language_factory) diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index 8884ec3a7e5a..981e72fb27a8 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -18,7 +18,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\SetLanguageDetectionEnabled; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Checkbox; use ILIAS\Data\Description\Description; use ILIAS\Language\Activities\RepositoryProvider; @@ -171,10 +170,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWrites $repository = $this->createMock(RepositoryProvider::class); $repository->expects($this->never())->method('setLanguageDetectionEnabled'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - - $result = $this->createActivity(rbac_system: $rbac, repository: $repository, language: $language) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); $this->assertTrue($result->isError()); @@ -342,11 +338,9 @@ public function testOutputDescriptionDescribesASingleBooleanEnabledField(): void private function createActivity( ?\ilRbacSystem $rbac_system = null, ?RepositoryProvider $repository = null, - ?Language $language = null, int $language_folder_ref_id = 0 ): SetLanguageDetectionEnabled { return new SetLanguageDetectionEnabled( - $language ?? $this->createMock(Language::class), $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 index 64e49d6e30a3..214e8ca19d26 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -18,7 +18,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Text; use ILIAS\UI\Component\Input\Field\Checkbox; use ILIAS\Language\Activities\RepositoryProvider; @@ -204,10 +203,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche $repository->expects($this->never())->method('isPageTranslationEnabled'); $repository->expects($this->never())->method('setPageTranslationEnabled'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - - $result = $this->createActivity(rbac_system: $rbac, repository: $repository, language: $language) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); $this->assertTrue($result->isError()); @@ -528,7 +524,6 @@ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): private function createActivity( ?\ilRbacSystem $rbac_system = null, ?RepositoryProvider $repository = null, - ?Language $language = null, int $language_folder_ref_id = 0, array $installed_languages = ['de', 'fr'], ): SetLanguageTranslationEnabled { @@ -536,7 +531,6 @@ private function createActivity( $repository->method('getInstalledLanguages')->willReturn($installed_languages); return new SetLanguageTranslationEnabled( - $language ?? $this->createMock(Language::class), $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 index 3f01aec42996..0b1a0c74b048 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -20,7 +20,6 @@ use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\UninstallLanguage; use ILIAS\UI\Component\Input\Field\Text; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -488,14 +487,10 @@ public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninst [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ 'de' => [], ]); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $lng_objects, $obj_language_factory, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); $this->assertTrue($result->isError()); @@ -546,10 +541,8 @@ private function createActivity( \Closure $lng_objects, \Closure $obj_language_factory, ?\ilRbacSystem $rbac = null, - ?Language $language = null ): UninstallLanguage { return new UninstallLanguage( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder(), $this->languageObjectProvider($lng_objects, $obj_language_factory) diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php index d25bea413f8a..53f42f62fd8b 100644 --- a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -19,7 +19,6 @@ use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\UpdateLanguage; use ILIAS\UI\Component\Input\Field\Text; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -299,13 +298,9 @@ public function testPermissionDeniedBeforePerform(): void $setup_language = $this->createSetupLanguageMock([], [], ['de']); $setup_language->expects($this->never())->method('getInstalledLanguages'); $setup_language->expects($this->never())->method('flushLanguageForInstallation'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $setup_language, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); $this->assertTrue($result->isError()); @@ -347,10 +342,8 @@ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUpdatesEachOne( private function createActivity( ilSetupLanguage $setup_language, ?\ilRbacSystem $rbac = null, - ?Language $language = null ): UpdateLanguage { return new UpdateLanguage( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $setup_language, $this->languageFolder() diff --git a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php index 47718b2bef69..46e2055f28c5 100755 --- a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php +++ b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php @@ -36,13 +36,11 @@ protected function setUp(): void $refinery = new Refinery($this->createMock(DataFactory::class), $this->createMock(\ilLanguage::class)); $setup_language = $this->createMock(ilSetupLanguage::class); $install_language = new InstallLanguage( - $this->createMock(\ILIAS\Language\Language::class), $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), $setup_language, $this->languageFolder() ); $update_language = new UpdateLanguage( - $this->createMock(\ILIAS\Language\Language::class), $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), $setup_language, $this->languageFolder() From bcba0b52d33c0a290cbd618d4ca7e92d92291f3e Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:10:55 +0200 Subject: [PATCH 018/103] [FIX] File: take the current wording of could_not_create_file_objs --- components/ILIAS/File/lang/ilias_de.lang | 2 +- components/ILIAS/File/lang/ilias_en.lang | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/File/lang/ilias_de.lang b/components/ILIAS/File/lang/ilias_de.lang index 573ab640fdd6..1027e6cec5fd 100644 --- a/components/ILIAS/File/lang/ilias_de.lang +++ b/components/ILIAS/File/lang/ilias_de.lang @@ -36,7 +36,7 @@ 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, wenden Sie sich an die Administrierenden der Plattform. +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. diff --git a/components/ILIAS/File/lang/ilias_en.lang b/components/ILIAS/File/lang/ilias_en.lang index 79ad17b707bd..ef445b44660d 100644 --- a/components/ILIAS/File/lang/ilias_en.lang +++ b/components/ILIAS/File/lang/ilias_en.lang @@ -36,7 +36,7 @@ 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 the administrators of this platform. +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. From b751724e852ae7ce86f8531cb91c320e9f5846d7 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:11:08 +0200 Subject: [PATCH 019/103] [REFACTOR] Language: add license header and restore @internal markers of the component translation classes --- .../CustomizingLanguageFileDirectory.php | 20 +++++++++++++++++-- .../LanguageFileDirectoryManager.php | 1 + .../MainLanguageFileDirectory.php | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php index 06ec277b73ee..e4fecb2ef39f 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php +++ b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php @@ -1,11 +1,27 @@ */ class LanguageFileDirectoryManager diff --git a/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php index 592a67bfbae1..5fe11133d0ce 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php +++ b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php @@ -21,7 +21,8 @@ namespace ILIAS\Language\ComponentTranslation; /** - * @author Fabian Schmid + * @internal Only the Language component shall provide an instance of this. + * @author Fabian Schmid */ class MainLanguageFileDirectory implements LanguageFileDirectory { From 84eebae8e7bdd7ed2f2ff9c6d17496fb542070d2 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:12:51 +0200 Subject: [PATCH 020/103] [FIX] Language: invalidate the translation cache when lng_modules changes --- components/ILIAS/Language/Language.php | 6 +- .../classes/Setup/class.ilSetupLanguage.php | 7 +- .../Language/classes/class.ilObjLanguage.php | 5 +- .../src/Setup/LanguageInstallationManager.php | 3 + .../src/Setup/NullTranslationCache.php | 31 +++++++++ .../Language/src/Setup/TranslationCache.php | 29 +++++++++ .../src/Setup/TranslationCacheLegacyProxy.php | 33 ++++++++++ .../Setup/LanguageInstallationManagerTest.php | 65 +++++++++++++++++++ 8 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 components/ILIAS/Language/src/Setup/NullTranslationCache.php create mode 100644 components/ILIAS/Language/src/Setup/TranslationCache.php create mode 100644 components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 16b342e03093..61663ab4ef25 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -73,11 +73,15 @@ public function init( $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] + $pull[\ILIAS\Database\Connection::class], + $internal[Language\Setup\TranslationCache::class] ); $internal[InstallLanguage::class] = static fn() => diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index 16a8435b4ce5..a6b196f41610 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -25,6 +25,8 @@ 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; /** * language handling for setup @@ -62,6 +64,7 @@ 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; $this->absolute_path = (string) realpath(__DIR__ . "/../../../../../"); @@ -100,7 +103,9 @@ private function manager(): LanguageInstallationManager $this->db() ?? throw new LogicException("No database available for " . self::class . "."), $this->language_file_directory_manager, $this->absolute_path, - $this->repository() + $this->repository(), + null, + $this->translation_cache ); } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguage.php b/components/ILIAS/Language/classes/class.ilObjLanguage.php index 68a58bac602e..1286fcb7d1a4 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -24,6 +24,7 @@ use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\LanguageInstallationManager; +use ILIAS\Language\Setup\TranslationCacheLegacyProxy; /** * Class ilObjLanguage @@ -94,7 +95,9 @@ public function __construct( $DIC->database(), $this->language_file_directory_manager, $this->absolute_path, - $this->repository + $this->repository, + null, + new TranslationCacheLegacyProxy() ); } diff --git a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php index 897051a7c2c4..e04fa1af6547 100644 --- a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php +++ b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php @@ -45,6 +45,7 @@ public function __construct( private readonly string $absolute_path, private readonly InstalledLanguageRepository $repository, private readonly ?\Closure $now = null, + private readonly TranslationCache $translation_cache = new NullTranslationCache(), ) { } @@ -171,6 +172,7 @@ private function flushLanguage(string $lang_key, string $mode = "all"): void 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); } } @@ -383,6 +385,7 @@ private function insertLanguage(string $lang_key, iterable $directories, array $ . 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..6cffda31b90b --- /dev/null +++ b/components/ILIAS/Language/src/Setup/NullTranslationCache.php @@ -0,0 +1,31 @@ +deleteInCache(); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php index f04aec52ba64..9d32ead0b7e2 100644 --- a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php @@ -24,6 +24,7 @@ use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\Language\Setup\LanguageInstallationManager; +use ILIAS\Language\Setup\TranslationCache; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -842,6 +843,70 @@ public function isLocal(): bool }; } + 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)); From 53657985b9e9e14dc3d37939a447af57110b0dbf Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:13:50 +0200 Subject: [PATCH 021/103] [REFACTOR] Language: assert the contributions of the component independent of their order --- components/ILIAS/Language/Language.php | 4 +- components/ILIAS/Language/README.md | 2 +- .../tests/LanguageComponentGraphTest.php | 132 ++++++++++-------- 3 files changed, 79 insertions(+), 59 deletions(-) diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 61663ab4ef25..aaeb67c91079 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -169,9 +169,7 @@ public function init( ); // Provided under the concrete class because Init.php pulls it, so none may become final - // (the GUI tests mock them). The $contribute order is the declaration order below and must - // stay between the Setup\Agent and the UserSettings entry: RenamingDIC numbers the repeated - // Activity key in call order (see LanguageComponentGraphTest). + // (the GUI tests mock them). $activities = [ InstallLanguage::class, UpdateLanguage::class, diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 3fe34951ccd0..b3ddd271e779 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -124,4 +124,4 @@ All other HTML tags are unsupported and will be removed by `ilUtil::stripSlashes * [`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, contribute ordering) rather than relying on prose descriptions here. + (e.g. singleton guarantees) rather than relying on prose descriptions here. diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index 84a884e66bc8..decc88db226d 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -51,6 +51,8 @@ */ class LanguageComponentGraphTest extends TestCase { + private PimpleContainer $contributions; + /** * @param array|\ArrayAccess|null $use * @return array{0: array, 1: RenamingDIC, 2: array, 3: RenamingDIC, 4: array, 5: PimpleContainer, 6: array, 7: PimpleContainer} @@ -75,7 +77,8 @@ private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\ // RenamingDIC (as in the generated bootstrap) keeps several assignments to one key apart; a plain Pimple\Container overwrites $implement = new RenamingDIC(new PimpleContainer()); - $contribute = new RenamingDIC(new PimpleContainer()); + $this->contributions = new PimpleContainer(); + $contribute = new RenamingDIC($this->contributions); $provide = new PimpleContainer(); $internal = new PimpleContainer(); @@ -93,6 +96,32 @@ private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\ 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(); @@ -137,57 +166,50 @@ public function testProvidedServicesShareTheSameInternalSingletonInstances(): vo ); } - public function testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder(): void + public function testContributeEntriesResolveToExpectedConcreteClasses(): void { - [, , , $contribute] = $this->initComponent(); - - // RenamingDIC suffixes every offsetSet with a container-wide "_" in call order, so these keys pin the order of - // the $contribute entries: a hardcoded counter shifts whenever a contribution is added ahead of it - self::assertInstanceOf(MainLanguageFileDirectory::class, $contribute[LanguageFileDirectory::class . '_0']); - self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); - self::assertInstanceOf(InstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_2']); - self::assertInstanceOf(UpdateLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_3']); - self::assertInstanceOf(UninstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_4']); - self::assertInstanceOf(RemoveLocalLanguageChanges::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_5']); - self::assertInstanceOf(AddLanguageEntry::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_6']); - self::assertInstanceOf(SetLanguageDetectionEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_7']); - self::assertInstanceOf(SetLanguageTranslationEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_8']); - self::assertInstanceOf(UserSettingsSettings::class, $contribute[\ILIAS\User\Settings\UserSettings::class . '_9']); + $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 { - [, , , $contribute, , $provide] = $this->initComponent(); + [, , , , , $provide] = $this->initComponent(); // the Setup path and the GUI must share the very same Activity instances, not merely equal ones - self::assertSame( - $provide[InstallLanguage::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_2'] - ); - self::assertSame( - $provide[UpdateLanguage::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_3'] - ); - self::assertSame( - $provide[UninstallLanguage::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_4'] - ); - self::assertSame( - $provide[RemoveLocalLanguageChanges::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_5'] - ); - self::assertSame( - $provide[AddLanguageEntry::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_6'] - ); - self::assertSame( - $provide[SetLanguageDetectionEnabled::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_7'] - ); - self::assertSame( - $provide[SetLanguageTranslationEnabled::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_8'] - ); + $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 @@ -221,7 +243,7 @@ public function testRepositoryGetsTheRootFromDirectories(): void self::assertSame('/ilias-root', self::readPrivateProperty($internal[InstalledLanguageDatabaseRepository::class], 'absolute_path')); } - private function resolveEverything(PimpleContainer $provide, RenamingDIC $contribute): void + private function resolveEverything(PimpleContainer $provide): void { foreach ([ InstallLanguage::class, UpdateLanguage::class, UninstallLanguage::class, RemoveLocalLanguageChanges::class, @@ -229,7 +251,7 @@ private function resolveEverything(PimpleContainer $provide, RenamingDIC $contri ] as $key) { self::assertIsObject($provide[$key]); } - self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); + $this->contributedSetupAgent(); } /** @@ -253,8 +275,8 @@ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 0); $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 2); - [, , , $contribute, , $provide, , $internal] = $this->initComponent(null, $connection); - $this->resolveEverything($provide, $contribute); + [, , , , , $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 @@ -267,12 +289,12 @@ public function testWiringNeverCallsRbacSystemWhileResolving(): void $rbac = $this->createMock(\ILIAS\AccessControl\PublicInterface\RBAC::class); $rbac->expects(self::never())->method('system'); - [, , , $contribute, , $provide] = $this->initComponent([ + [, , , , , $provide] = $this->initComponent([ \ILIAS\Language\Language::class => $this->createStub(\ILIAS\Language\Language::class), \ILIAS\AccessControl\PublicInterface\RBAC::class => $rbac, ]); - $this->resolveEverything($provide, $contribute); + $this->resolveEverything($provide); } public function testSetupAgentIsBuildableWithoutAnyRbacEntry(): void @@ -288,9 +310,9 @@ public function offsetGet(mixed $key): mixed }; $use[\ILIAS\Language\Language::class] = $this->createStub(\ILIAS\Language\Language::class); - [, , , $contribute] = $this->initComponent($use); + $this->initComponent($use); - self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); + $this->contributedSetupAgent(); } /** @return array}> */ @@ -345,8 +367,8 @@ public function testSetupAgentActivitiesDoNotUseThePulledConnection(string $clas $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 1); $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 0); - [, , , $contribute] = $this->initComponent(null, $connection); - $agent = $contribute[\ILIAS\Setup\Agent::class . '_1']; + $this->initComponent(null, $connection); + $agent = $this->contributedSetupAgent(); $this->performReadOnly( self::readPrivateProperty($agent, $class === InstallLanguage::class ? 'install_language' : 'update_language') From 0645be228155ade841ef310adf61c9bb71fcf956 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:14:51 +0200 Subject: [PATCH 022/103] [REFACTOR] Language: hand the installation root to the repository instead of a path read during wiring --- components/ILIAS/Language/Language.php | 2 +- .../classes/Setup/class.ilSetupLanguage.php | 3 +- .../Language/classes/class.ilObjLanguage.php | 3 +- .../src/Setup/DirectoriesInstallationRoot.php | 39 +++++++++++++++++++ .../src/Setup/FixedInstallationRoot.php | 37 ++++++++++++++++++ .../Language/src/Setup/InstallationRoot.php | 29 ++++++++++++++ .../InstalledLanguageDatabaseRepository.php | 10 ++--- .../tests/LanguageComponentGraphTest.php | 19 ++++++++- ...nstalledLanguageDatabaseRepositoryTest.php | 5 ++- 9 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php create mode 100644 components/ILIAS/Language/src/Setup/FixedInstallationRoot.php create mode 100644 components/ILIAS/Language/src/Setup/InstallationRoot.php diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index aaeb67c91079..e1ac84e90d10 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -63,7 +63,7 @@ public function init( new InstalledLanguageDatabaseRepository( $pull[\ILIAS\Database\Connection::class], $internal[LanguageFileDirectoryManager::class], - $use[Directories::class]->getRoot() + new Language\Setup\DirectoriesInstallationRoot($use[Directories::class]) ); // Setup hands in its own database via setDbHandler() instead of the Connection diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index a6b196f41610..b4da0e482969 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -27,6 +27,7 @@ use ILIAS\Language\Setup\LanguageInstallationManager; use ILIAS\Language\Setup\TranslationCache; use ILIAS\Language\Setup\NullTranslationCache; +use ILIAS\Language\Setup\FixedInstallationRoot; /** * language handling for setup @@ -93,7 +94,7 @@ private function repository(): InstalledLanguageRepository return new InstalledLanguageDatabaseRepository( $this->db(), $this->language_file_directory_manager, - $this->absolute_path + new FixedInstallationRoot($this->absolute_path) ); } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguage.php b/components/ILIAS/Language/classes/class.ilObjLanguage.php index 1286fcb7d1a4..c2aecee3dcc1 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -25,6 +25,7 @@ use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\LanguageInstallationManager; use ILIAS\Language\Setup\TranslationCacheLegacyProxy; +use ILIAS\Language\Setup\FixedInstallationRoot; /** * Class ilObjLanguage @@ -89,7 +90,7 @@ public function __construct( $this->repository = new InstalledLanguageDatabaseRepository( $DIC->database(), $this->language_file_directory_manager, - $this->absolute_path + new FixedInstallationRoot($this->absolute_path) ); $this->manager = new LanguageInstallationManager( $DIC->database(), diff --git a/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php new file mode 100644 index 000000000000..56e5bdf7e5ce --- /dev/null +++ b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php @@ -0,0 +1,39 @@ +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..01f52ec55232 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php @@ -0,0 +1,37 @@ +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..a3c686e99311 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstallationRoot.php @@ -0,0 +1,29 @@ +language_file_directory_manager->getCustomizingDirectories() as $directory) { - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); if (is_dir($path)) { $d = dir($path); chdir($path); @@ -176,7 +176,7 @@ public function getInstallableLanguages(): array // chdir() must be undone even on exceptions, see LanguageInstallationManager::insertLanguage() try { foreach ($this->language_file_directory_manager->getDirectories() as $directory) { - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); if (is_dir($path)) { $d = dir($path); chdir($path); @@ -202,7 +202,7 @@ public function getInvalidLocalLanguageFiles(array $language_keys): array $language_keys = array_values(array_unique($language_keys)); foreach ($this->language_file_directory_manager->getCustomizingDirectories() as $directory) { - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); if (!is_dir($path)) { continue; } @@ -269,7 +269,7 @@ private function checkLanguageFile( ): bool { $working_dir = getcwd(); $lang_file = "ilias_" . $lang_key . ".lang" . $directory->getSuffix(); - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); try { if (!is_dir($path)) { diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index decc88db226d..f5341de7af94 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -240,7 +240,24 @@ public function testRepositoryGetsTheRootFromDirectories(): void \ILIAS\Environment\Configuration\Installation\Directories::class => $this->createDirectories('/ilias-root'), ]); - self::assertSame('/ilias-root', self::readPrivateProperty($internal[InstalledLanguageDatabaseRepository::class], 'absolute_path')); + 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 diff --git a/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php index bb105df888f4..0685d89609b9 100644 --- a/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php +++ b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php @@ -22,6 +22,7 @@ use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; +use ILIAS\Language\Setup\FixedInstallationRoot; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -77,7 +78,7 @@ private function createRepository( return new InstalledLanguageDatabaseRepository( $db, new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), - $absolute_path + new FixedInstallationRoot($absolute_path) ); } @@ -443,7 +444,7 @@ static function (): \Generator { $repository = new InstalledLanguageDatabaseRepository( $this->createReadDatabaseMock(), $manager, - '/does/not/matter/for/this/test' + new FixedInstallationRoot('/does/not/matter/for/this/test') ); $this->assertFalse($repository->checkLocalLanguageFile('de')); From 69eabd8a14299b109777def9c6382779516dfae5 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:16:15 +0200 Subject: [PATCH 023/103] [REFACTOR] Language: take the settings of the Activities from a legacy proxy instead of a global $DIC fallback --- components/ILIAS/Language/Language.php | 4 + components/ILIAS/Language/ROADMAP.md | 4 +- .../src/Activities/LanguageSettings.php | 33 +++++++ .../LanguageSettingsLegacyProxy.php | 51 ++++++++++ .../src/Activities/RepositoryProvider.php | 23 +---- .../LanguageSettingsLegacyProxyTest.php | 92 +++++++++++++++++++ .../Activities/RepositoryProviderTest.php | 56 +++-------- 7 files changed, 200 insertions(+), 63 deletions(-) create mode 100644 components/ILIAS/Language/src/Activities/LanguageSettings.php create mode 100644 components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php create mode 100644 components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index e1ac84e90d10..896c37f532d0 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -99,10 +99,14 @@ public function init( ); // 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() => diff --git a/components/ILIAS/Language/ROADMAP.md b/components/ILIAS/Language/ROADMAP.md index 82ed50cbe9b4..d707706e1c45 100755 --- a/components/ILIAS/Language/ROADMAP.md +++ b/components/ILIAS/Language/ROADMAP.md @@ -22,8 +22,8 @@ * 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; `Language.php` still reads - `$GLOBALS['DIC']->settings()` + 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()` diff --git a/components/ILIAS/Language/src/Activities/LanguageSettings.php b/components/ILIAS/Language/src/Activities/LanguageSettings.php new file mode 100644 index 000000000000..a43bb1b5543d --- /dev/null +++ b/components/ILIAS/Language/src/Activities/LanguageSettings.php @@ -0,0 +1,33 @@ +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/RepositoryProvider.php b/components/ILIAS/Language/src/Activities/RepositoryProvider.php index ff96c1ffb7fa..d664f6735936 100644 --- a/components/ILIAS/Language/src/Activities/RepositoryProvider.php +++ b/components/ILIAS/Language/src/Activities/RepositoryProvider.php @@ -20,19 +20,17 @@ namespace ILIAS\Language\Activities; -use ILIAS\Administration\Setting; use ILIAS\Language\Setup\InstalledLanguageRepository; /** - * Every legacy and repository access of the runtime language activities. No component offers the - * settings yet, so they are resolved from the legacy container on first use. + * Every legacy and repository access of the runtime language activities. */ class RepositoryProvider { public function __construct( private readonly InstalledLanguageRepository $installed_language_repository, private readonly \ilDBInterface $db, - private ?Setting $settings = null, + private readonly LanguageSettings $settings, ) { } @@ -120,32 +118,21 @@ public function lookupLogin(int $usr_id): string public function setLanguageDetectionEnabled(bool $enabled): void { - $this->settings()->set('lang_detection', $enabled ? '1' : '0'); + $this->settings->setLanguageDetectionEnabled($enabled); } public function isPageTranslationEnabled(string $lang_key): bool { - return (bool) $this->settings()->get('lang_translate_' . $lang_key, '0'); + return $this->settings->isPageTranslationEnabled($lang_key); } public function setPageTranslationEnabled(string $lang_key, bool $enabled): void { - $this->settings()->set('lang_translate_' . $lang_key, $enabled ? '1' : '0'); + $this->settings->setPageTranslationEnabled($lang_key, $enabled); } private function languageObject(int $obj_id): \ilObjLanguage { return new \ilObjLanguage($obj_id); } - - private function settings(): Setting - { - if (!$this->settings instanceof Setting) { - global $DIC; - - $this->settings = $DIC->settings(); - } - - return $this->settings; - } } 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/RepositoryProviderTest.php b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php index b1135c806471..dc512e5cdad9 100644 --- a/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php +++ b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php @@ -21,7 +21,7 @@ namespace ILIAS\Language\Tests\Activities; -use ILIAS\Administration\Setting; +use ILIAS\Language\Activities\LanguageSettings; use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Setup\InstalledLanguageRepository; use PHPUnit\Framework\Attributes\DataProvider; @@ -39,7 +39,7 @@ public function testGetInstalledLanguagesDelegatesToTheRepository(): void $installed = $this->createMock(InstalledLanguageRepository::class); $installed->expects($this->once())->method('getInstalledLanguages')->willReturn(['de', 'en']); - $provider = new RepositoryProvider($installed, $this->createMock(\ilDBInterface::class)); + $provider = new RepositoryProvider($installed, $this->createMock(\ilDBInterface::class), $this->createMock(LanguageSettings::class)); $this->assertSame(['de', 'en'], $provider->getInstalledLanguages()); } @@ -75,7 +75,7 @@ public function testUpdateModuleCacheLeavesAMissingOrBrokenCacheAlone(?array $ro // 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)) + (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db, $this->createMock(LanguageSettings::class))) ->updateModuleCache('de', 'common', 'topic', 'value'); } finally { restore_error_handler(); @@ -95,54 +95,24 @@ public function testUpdateModuleCacheQuotesLanguageKeyAndModuleIntoTheQuery(): v ); $db->method('fetchAssoc')->willReturn(null); - (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db)) + (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db, $this->createMock(LanguageSettings::class))) ->updateModuleCache('de', "co'mmon", 'topic', 'value'); } - /** @return array */ - public static function storedFlagProvider(): array + public function testSettingsAreDelegatedToTheLanguageSettings(): void { - 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(Setting::class); - $settings->expects($this->once())->method('get')->with('lang_translate_fr', '0')->willReturn($stored); - - $this->assertSame($expected, $this->providerWith($settings)->isPageTranslationEnabled('fr')); - } + $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); - public function testSettingsAreWrittenAsOneAndZeroUnderTheirKeys(): void - { - $written = []; - $settings = $this->createMock(Setting::class); - $settings->method('set')->willReturnCallback( - static function (string $key, string $value) use (&$written): void { - $written[] = [$key, $value]; - } - ); - - $provider = $this->providerWith($settings); - $provider->setLanguageDetectionEnabled(true); - $provider->setLanguageDetectionEnabled(false); - $provider->setPageTranslationEnabled('de', true); - $provider->setPageTranslationEnabled('de', false); - - $this->assertSame( - [['lang_detection', '1'], ['lang_detection', '0'], ['lang_translate_de', '1'], ['lang_translate_de', '0']], - $written - ); - } - - private function providerWith(Setting $settings): RepositoryProvider - { - return new RepositoryProvider( + $provider = new RepositoryProvider( $this->createMock(InstalledLanguageRepository::class), $this->createMock(\ilDBInterface::class), $settings ); + $provider->setLanguageDetectionEnabled(true); + $this->assertTrue($provider->isPageTranslationEnabled('fr')); + $provider->setPageTranslationEnabled('de', false); } } From cdc2f8ab71be454870d6b8b5d7f8e83efc290c45 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 21 Oct 2026 17:26:55 +0200 Subject: [PATCH 024/103] Language: Protect the table actions of `ilObjLanguageFolderGUI` against CSRF The table actions were read from the query by `executeCommand()` without a command, so ilCtrl never checked a token. `ilObjLanguageFolderGUI` now implements `ilCtrlSecurityInterface`, runs the table actions only through the unsafe command `executeTableAction` and dispatches an exact allowlist of commands, since ilCtrl compares commands case-sensitively but PHP calls methods case-insensitively. Takes effect after the ctrl security artifact is rebuilt (`composer dump-autoload` or `setup build`). --- .../classes/class.ilObjLanguageFolderGUI.php | 263 ++++++++++-------- .../tests/ilObjLanguageFolderGUITest.php | 108 +++++++ 2 files changed, 252 insertions(+), 119 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index 38761cec1d6c..d35df6e6888c 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -37,10 +37,12 @@ * * @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; @@ -74,8 +76,11 @@ public function __construct(?array $a_data, int $a_id, bool $a_call_by_reference $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( @@ -93,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 */ @@ -711,127 +730,133 @@ 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': - 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; - 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; + case 'edit': + $ids = $this->getIdsFromQueryToken(); + $this->editFolderObject($ids); break; } + $this->viewObject(); } protected function getCommandFromQueryToken(string $param): ?string diff --git a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php index 123d02fc7c47..240240b97570 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php @@ -1382,4 +1382,112 @@ public function testConfirmUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageOb $gui->confirmUninstallChangesObject(); } + + 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); + } } From 7beef90df385119b05e907fa8c0c3472e22aead0 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 21 Oct 2026 17:49:16 +0200 Subject: [PATCH 025/103] [FIX] Language: Load `ilLangDeprecated` through the autoloader in `ilObjLanguageFolderGUI` Listing and downloading the deprecated entries failed with an error, because `ilObjLanguageFolderGUI` still included `ilLangDeprecated` from the former `Services/Language` directory. --- .../ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index d35df6e6888c..a42e59f29794 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -605,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"); @@ -1033,8 +1030,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) { @@ -1049,7 +1044,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) { From 0599fd9053a0df6a6d3e5cc43dc442fbe4fb387d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 21 Oct 2026 17:53:04 +0200 Subject: [PATCH 026/103] Language: Remove the unreachable confirmation screens from `ilObjLanguageFolderGUI` `confirmRefreshSelectedObject()`, `confirmUninstallObject()`, `confirmUninstallChangesObject()` and the table action `edit` could no longer be reached since the table actions confirm in modals, and their confirmations failed for lack of language ids. --- .../classes/class.ilObjLanguageFolderGUI.php | 102 ---------------- .../tests/ilObjLanguageFolderGUITest.php | 111 ------------------ 2 files changed, 213 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index a42e59f29794..31b831b9776a 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -848,10 +848,6 @@ protected function executeTableActionObject(): void $ids = $this->getIdsFromQueryToken(); $this->setUserLanguageObject($ids); break; - case 'edit': - $ids = $this->getIdsFromQueryToken(); - $this->editFolderObject($ids); - break; } $this->viewObject(); } @@ -884,104 +880,6 @@ public function confirmRefreshObject(): array return $ids; } - public function confirmRefreshSelectedObject(array $a_ids = []): 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"); - } - - if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { - return; - } - - $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()); - } - - public function confirmUninstallObject(): void - { - $this->checkPermission("write"); - - $this->lng->loadLanguageModule("meta"); - - $ids = $this->getIdsFromQueryToken(); - if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { - return; - } - - $conf_screen = new ilConfirmationGUI(); - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_confirm")); - foreach ($ids 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()); - } - - public function confirmUninstallChangesObject(): void - { - $this->checkPermission('write'); - - $this->lng->loadLanguageModule("meta"); - - $ids = $this->getIdsFromQueryToken(); - if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { - return; - } - - $conf_screen = new ilConfirmationGUI(); - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_changes_confirm")); - foreach ($ids 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()); - } - - protected function editFolderObject(array $ids): void - { - $this->ctrl->setParameterByClass("ilobjlanguageextgui", "obj_id", current($ids)); - $this->ctrl->redirectByClass("ilobjlanguageextgui"); - } - protected function disableLanguageDetectionObject(): void { $this->setLanguageDetectionEnabledObject(false); diff --git a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php index 240240b97570..0503f0f2419b 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php @@ -1272,117 +1272,6 @@ public function testBuildConfirmModalAbortsWhenAnyIdIsNotALanguageObjectAndNever $this->assertSame($expected_interruptive, $result); } - /** confirmRefreshSelectedObject() variant: takes $ids from its $a_ids argument, so no request_wrapper stubbing is needed. */ - public function testConfirmRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObject(): 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' - ); - - $this->setProperty($gui, 'tpl', $tpl); - $this->setProperty($gui, 'ctrl', $ctrl); - $this->setProperty($gui, 'lng', $this->createLanguageMockReturningTopicAsIs()); - - $gui->confirmRefreshSelectedObject(['5']); - } - - /** Stubs `request_wrapper`/`id_token`/`refinery` so the private getIdsFromQueryToken() resolves to $ids (PHPUnit cannot intercept private methods). */ - private function createGuiWithIdsFromQueryToken( - array $ids, - ilLanguage $lng, - ilGlobalTemplateInterface $tpl, - ilCtrl $ctrl - ): ilObjLanguageFolderGUI { - /** @var ilObjLanguageFolderGUI $gui */ - $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); - - $id_token = new \ILIAS\UI\URLBuilderToken(['language_folder'], 'obj_ids'); - - $request_wrapper = $this->createMock(\ILIAS\HTTP\Wrapper\RequestWrapper::class); - $request_wrapper->method('has')->with($id_token->getName())->willReturn(true); - $request_wrapper->method('retrieve')->willReturn($ids); - - $custom_group = $this->createMock(\ILIAS\Refinery\Custom\Group::class); - $custom_group->method('transformation')->willReturn( - $this->createMock(\ILIAS\Refinery\Transformation::class) - ); - $refinery = $this->createMock(\ILIAS\Refinery\Factory::class); - $refinery->method('custom')->willReturn($custom_group); - - $this->setProperty($gui, 'id_token', $id_token); - $this->setProperty($gui, 'request_wrapper', $request_wrapper); - $this->setProperty($gui, 'refinery', $refinery); - $this->setProperty($gui, 'lng', $lng); - $this->setProperty($gui, 'tpl', $tpl); - $this->setProperty($gui, 'ctrl', $ctrl); - - return $gui; - } - - /** confirmUninstallObject() variant. */ - public function testConfirmUninstallObjectAbortsWhenAnyIdIsNotALanguageObject(): void - { - $this->stubObjDataCache([5 => 'cat'], [], true); - - $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->createGuiWithIdsFromQueryToken( - ['5'], - $this->createLanguageMockReturningTopicAsIs(), - $tpl, - $ctrl - ); - - $gui->confirmUninstallObject(); - } - - /** confirmUninstallChangesObject() variant. */ - public function testConfirmUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObject(): void - { - $this->stubObjDataCache([5 => 'cat'], [], true); - - $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->createGuiWithIdsFromQueryToken( - ['5'], - $this->createLanguageMockReturningTopicAsIs(), - $tpl, - $ctrl - ); - - $gui->confirmUninstallChangesObject(); - } - public function testOnlyTheStateChangingCommandsAreUnsafeGetCommandsSoTheCsrfTokenIsRequired(): void { $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); From bec2aa85d8f244804e94aead92a652fe0fffb474 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 22 Oct 2026 09:07:13 +0200 Subject: [PATCH 027/103] Language: Pass the previous exception positionally in `GrindsFormInput` Named arguments are not used in the core (review criteria). --- components/ILIAS/Language/src/Activities/GrindsFormInput.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/Language/src/Activities/GrindsFormInput.php b/components/ILIAS/Language/src/Activities/GrindsFormInput.php index 3d749ce8dca9..b7cc00bfe868 100644 --- a/components/ILIAS/Language/src/Activities/GrindsFormInput.php +++ b/components/ILIAS/Language/src/Activities/GrindsFormInput.php @@ -60,7 +60,7 @@ private function grind(FormInput $description, array $raw_parameters): Result 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(), previous: $e)); + return new Result\Error(new InvalidInputException($e->getMessage(), 400, $e)); } } From bdec72fb4f8b3bef036f5a3a85f28083601e7ff2 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 22 Oct 2026 09:07:24 +0200 Subject: [PATCH 028/103] Language: Expect the calls whose arguments the tests check PHPUnit 14 no longer allows `with()` without `expects()`, so the mocks in `SetLanguageTranslationEnabledTest`, `AddLanguageEntryTest` and `LanguageInstallationManagerTest` now expect their calls once. --- .../tests/Activities/AddLanguageEntryTest.php | 14 +++++++------- .../SetLanguageTranslationEnabledTest.php | 15 ++++++++------- .../Setup/LanguageInstallationManagerTest.php | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php index f19784e3a3e3..5f7613628f7c 100644 --- a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -944,12 +944,12 @@ public function getInputDescription(FieldFactory $f): FormInput public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslationFields(): void { $module_text = $this->createMock(Text::class); - $module_text->method('withRequired')->with(true)->willReturnSelf(); - $module_text->method('withDedicatedName')->with('module')->willReturnSelf(); + $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->method('withRequired')->with(true)->willReturnSelf(); - $identifier_text->method('withDedicatedName')->with('identifier')->willReturnSelf(); + $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; @@ -957,7 +957,7 @@ public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslat $de_required = $required; return $de_text; }); - $de_text->method('withDedicatedName')->with('de')->willReturnSelf(); + $de_text->expects($this->once())->method('withDedicatedName')->with('de')->willReturnSelf(); $fr_text = $this->createMock(Text::class); $fr_required = null; @@ -965,7 +965,7 @@ public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslat $fr_required = $required; return $fr_text; }); - $fr_text->method('withDedicatedName')->with('fr')->willReturnSelf(); + $fr_text->expects($this->once())->method('withDedicatedName')->with('fr')->willReturnSelf(); $text_calls = []; $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); @@ -983,7 +983,7 @@ function (string $label, ?string $byline = null) use (&$text_calls, $module_text ); $translations_group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::class); - $translations_group->method('withDedicatedName')->with('translations')->willReturnSelf(); + $translations_group->expects($this->once())->method('withDedicatedName')->with('translations')->willReturnSelf(); $outer_section = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class); diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index 214e8ca19d26..a02344ee0365 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -94,7 +94,8 @@ public function testPerformWritesOnlyWhenTheRequestedValueDiffersFromTheStoredOn ?bool $expected_written_value ): void { $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled') + $repository->expects($this->once()) + ->method('isPageTranslationEnabled') ->with('de') ->willReturn($stored_value); @@ -129,7 +130,7 @@ public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero() public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue(): void { $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(false); $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true); $result = $this->createActivity(repository: $repository) @@ -177,7 +178,7 @@ public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResul $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $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) @@ -306,7 +307,7 @@ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPe $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(true); + $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) @@ -365,7 +366,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresent $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $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) @@ -386,7 +387,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresenta $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(true); + $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) @@ -457,7 +458,7 @@ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $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) diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php index 9d32ead0b7e2..830e5342bdc5 100644 --- a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php @@ -417,7 +417,7 @@ public function testInsertLanguageForApplyingLocalChangesPreservesUnrelatedSeedE }); $repository = $this->createMock(InstalledLanguageRepository::class); - $repository->method('getLanguageEntries')->with('de')->willReturn([ + $repository->expects($this->once())->method('getLanguageEntries')->with('de')->willReturn([ 'common' => [ 'overridden' => 'Old Value', 'untouched_by_customizing' => 'Still Here', @@ -467,7 +467,7 @@ public function testInstallLanguagesRegistersBrandNewLanguage(): void }); $repository = $this->createMock(InstalledLanguageRepository::class); - $repository->method('checkLanguage')->with('de')->willReturn(true); + $repository->expects($this->once())->method('checkLanguage')->with('de')->willReturn(true); $repository->method('getAvailableLanguages')->willReturn([]); $repository->method('getLocalLanguages')->willReturn([]); $repository->method('getLocalChanges')->willReturn([]); From cfeb84f2e947e7d1eed265d20e574aaa802b8b9f Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 22 Oct 2026 11:29:34 +0200 Subject: [PATCH 029/103] Language: Mark the internal settings, cache and installation root types as `@internal` `TranslationCache`, `InstallationRoot`, `LanguageSettings` and their implementations are only wired in `$internal` and used inside the Language component, like the language file directories that already carry the marker. --- components/ILIAS/Language/src/Activities/LanguageSettings.php | 2 ++ .../Language/src/Activities/LanguageSettingsLegacyProxy.php | 2 ++ .../ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php | 2 ++ components/ILIAS/Language/src/Setup/FixedInstallationRoot.php | 2 ++ components/ILIAS/Language/src/Setup/InstallationRoot.php | 2 ++ components/ILIAS/Language/src/Setup/NullTranslationCache.php | 2 ++ components/ILIAS/Language/src/Setup/TranslationCache.php | 2 ++ .../ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php | 2 ++ 8 files changed, 16 insertions(+) diff --git a/components/ILIAS/Language/src/Activities/LanguageSettings.php b/components/ILIAS/Language/src/Activities/LanguageSettings.php index a43bb1b5543d..255352d80c61 100644 --- a/components/ILIAS/Language/src/Activities/LanguageSettings.php +++ b/components/ILIAS/Language/src/Activities/LanguageSettings.php @@ -22,6 +22,8 @@ /** * The settings of the language administration changed by the Activities. + * + * @internal */ interface LanguageSettings { diff --git a/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php b/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php index 3c3c7817bf40..f9b52b802b03 100644 --- a/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php +++ b/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php @@ -24,6 +24,8 @@ /** * No component offers the settings yet, so they are taken from the legacy container on every call. + * + * @internal Only the Language component shall provide an instance of this. */ final class LanguageSettingsLegacyProxy implements LanguageSettings { diff --git a/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php index 56e5bdf7e5ce..f2e78b3a73dc 100644 --- a/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php +++ b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php @@ -24,6 +24,8 @@ /** * Reads the root from the Directories of the installation on every call, never at construction. + * + * @internal Only the Language component shall provide an instance of this. */ final readonly class DirectoriesInstallationRoot implements InstallationRoot { diff --git a/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php index 01f52ec55232..dc425ac17f2d 100644 --- a/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php +++ b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php @@ -22,6 +22,8 @@ /** * A root determined by the caller, for the classes which are not wired by the component bootstrap. + * + * @internal Only the Language component shall provide an instance of this. */ final readonly class FixedInstallationRoot implements InstallationRoot { diff --git a/components/ILIAS/Language/src/Setup/InstallationRoot.php b/components/ILIAS/Language/src/Setup/InstallationRoot.php index a3c686e99311..9b7fdaa64244 100644 --- a/components/ILIAS/Language/src/Setup/InstallationRoot.php +++ b/components/ILIAS/Language/src/Setup/InstallationRoot.php @@ -22,6 +22,8 @@ /** * The root directory of the ILIAS installation, which the language file directories are relative to. + * + * @internal */ interface InstallationRoot { diff --git a/components/ILIAS/Language/src/Setup/NullTranslationCache.php b/components/ILIAS/Language/src/Setup/NullTranslationCache.php index 6cffda31b90b..e0bbc1b66e3d 100644 --- a/components/ILIAS/Language/src/Setup/NullTranslationCache.php +++ b/components/ILIAS/Language/src/Setup/NullTranslationCache.php @@ -22,6 +22,8 @@ /** * The Setup has no global cache, hence there is nothing to invalidate. + * + * @internal Only the Language component shall provide an instance of this. */ final class NullTranslationCache implements TranslationCache { diff --git a/components/ILIAS/Language/src/Setup/TranslationCache.php b/components/ILIAS/Language/src/Setup/TranslationCache.php index 2b02dd3ec431..88aadb61356e 100644 --- a/components/ILIAS/Language/src/Setup/TranslationCache.php +++ b/components/ILIAS/Language/src/Setup/TranslationCache.php @@ -22,6 +22,8 @@ /** * Cache of the translations of a language, which must be invalidated whenever lng_modules changes. + * + * @internal */ interface TranslationCache { diff --git a/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php b/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php index 092d341bc2cb..727501dc8147 100644 --- a/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php +++ b/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php @@ -22,6 +22,8 @@ /** * Delegates to ilCachedLanguage at call time, so it can be built at build time without the global cache. + * + * @internal Only the Language component shall provide an instance of this. */ final class TranslationCacheLegacyProxy implements TranslationCache { From 4aff46382f4696b02806b801fb00641b5088041e Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:16:12 +0200 Subject: [PATCH 030/103] File: Take the language files back into the global language files Reverts the PO pilot of the File component from the base branch: the `file` entries move back into lang/ilias_*.lang and File no longer contributes a LanguageFileDirectory. The PO migration is piloted with TermsOfService and Poll instead. --- components/ILIAS/File/File.php | 7 -- components/ILIAS/File/lang/ilias_de.lang | 115 ----------------------- components/ILIAS/File/lang/ilias_en.lang | 115 ----------------------- lang/ilias_de.lang | 84 +++++++++++++++++ lang/ilias_en.lang | 84 +++++++++++++++++ 5 files changed, 168 insertions(+), 237 deletions(-) delete mode 100644 components/ILIAS/File/lang/ilias_de.lang delete mode 100644 components/ILIAS/File/lang/ilias_en.lang diff --git a/components/ILIAS/File/File.php b/components/ILIAS/File/File.php index c33340f2e693..6d44f47a72dc 100644 --- a/components/ILIAS/File/File.php +++ b/components/ILIAS/File/File.php @@ -46,8 +46,6 @@ 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 { @@ -197,10 +195,5 @@ 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 deleted file mode 100644 index 1027e6cec5fd..000000000000 --- a/components/ILIAS/File/lang/ilias_de.lang +++ /dev/null @@ -1,115 +0,0 @@ -/** -* 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 deleted file mode 100644 index ef445b44660d..000000000000 --- a/components/ILIAS/File/lang/ilias_en.lang +++ /dev/null @@ -1,115 +0,0 @@ -/** -* 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/lang/ilias_de.lang b/lang/ilias_de.lang index 4faef0d73bd7..b7f180498693 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -9868,6 +9868,90 @@ 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 b1ce0563b563..556f3c605185 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -9843,6 +9843,90 @@ 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 From 378cdda358b17e3320cb1a6dbf1009e89173c372 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:20:31 +0200 Subject: [PATCH 031/103] Language: Maintain the translations of single modules in PO files A component can ship gettext .po files for its module instead of lines in lang/ilias_*.lang. Setup builds them into artifacts, ilLanguage serves them via native gettext, and local changes, remarks and plural forms live in an overlay below the client data directory. --- .gitignore | 2 + .../Setup/class.ilLanguageSetupAgent.php | 8 +- ...lLanguagesInstalledAndUpdatedObjective.php | 327 ++- .../classes/Setup/class.ilSetupLanguage.php | 133 +- .../classes/class.ilCachedLanguage.php | 3 + .../Language/classes/class.ilLanguage.php | 228 +- .../classes/class.ilLanguageExtTableGUI.php | 2 + .../Language/classes/class.ilLanguageFile.php | 54 +- .../Language/classes/class.ilObjLanguage.php | 254 +- .../classes/class.ilObjLanguageExt.php | 1410 ++++++++++- .../classes/class.ilObjLanguageExtGUI.php | 431 +++- .../classes/class.ilObjLanguageFolder.php | 4 +- .../classes/class.ilObjLanguageFolderGUI.php | 119 +- .../classes/class.ilPluginLanguage.php | 256 +- ...class.ilLanguageInvalidMarkupException.php | 47 + .../ComponentTranslation/AtomicFileWriter.php | 199 ++ .../Catalog/TranslationCatalog.php | 340 +++ .../Catalog/TranslationEntry.php | 227 ++ .../ComponentLanguageFileDirectory.php | 17 +- .../InvalidMarkupMessage.php | 57 + .../LanguageFileDirectory.php | 5 + .../LegacyFuzzyMarker.php | 37 + .../LocalChangeComments.php | 313 +++ .../MigratedLanguageFilePaths.php | 442 ++++ .../MigratedLanguageFileSync.php | 2222 +++++++++++++++++ .../MigratedTranslations.php | 466 ++++ .../NamesShippedLanguageFiles.php | 38 + .../ComponentTranslation/NativeGettext.php | 252 ++ .../src/ComponentTranslation/PlainLogText.php | 51 + .../ComponentTranslation/PluralFormKey.php | 49 + .../src/ComponentTranslation/PluralForms.php | 456 ++++ .../ComponentTranslation/ShippedPoMerger.php | 586 +++++ .../ShippedTranslations.php | 171 ++ .../ShippedTranslationsBuild.php | 791 ++++++ .../TranslationMarkupPolicy.php | 725 ++++++ .../InstalledLanguageDatabaseRepository.php | 53 +- .../Setup/LanguageDataNotSavedException.php | 39 + .../src/Setup/LanguageInstallationManager.php | 946 ++++++- .../ShippedLanguageFilesCompiledObjective.php | 120 + .../src/Setup/TranslationCacheLegacyProxy.php | 5 + ...dminGuiReadsValuesFromMigratedFileTest.php | 573 +++++ components/ILIAS/Language/tests/ChildPhp.php | 74 + .../AtomicFileWriterTest.php | 312 +++ .../Catalog/TranslationCatalogMoTest.php | 458 ++++ .../Catalog/TranslationCatalogPoTest.php | 832 ++++++ .../Catalog/TranslationEntryTest.php | 286 +++ .../ComponentLanguageFileDirectoryTest.php | 38 + .../InvalidMarkupMessageTest.php | 79 + .../LocalChangeCommentsTest.php | 628 +++++ .../MigratedLanguageFilePathsTest.php | 496 ++++ .../MigratedLanguageFileSyncDeltaTest.php | 443 ++++ ...MigratedLanguageFileSyncEntryRulesTest.php | 328 +++ .../MigratedLanguageFileSyncGuardsTest.php | 356 +++ ...dLanguageFileSyncLockRemoveOverlayTest.php | 171 ++ .../MigratedLanguageFileSyncLockRetryTest.php | 493 ++++ .../MigratedLanguageFileSyncLockTest.php | 289 +++ .../MigratedLanguageFileSyncLockTestCase.php | 119 + ...gratedLanguageFileSyncOverlayFilesTest.php | 343 +++ ...ratedLanguageFileSyncPluralHelpersTest.php | 280 +++ ...ratedLanguageFileSyncPluralOverlayTest.php | 301 +++ ...edLanguageFileSyncPluralRuleChangeTest.php | 389 +++ .../MigratedLanguageFileSyncReadersTest.php | 442 ++++ .../MigratedLanguageFileSyncRemarksTest.php | 366 +++ .../MigratedLanguageFileSyncRevisionTest.php | 218 ++ .../MigratedLanguageFileSyncSymlinkTest.php | 287 +++ .../MigratedLanguageFileSyncTestCase.php | 157 ++ ...MigratedLanguageFileSyncUnwritableTest.php | 163 ++ .../MigratedTranslationsTest.php | 545 ++++ .../NativeGettextTest.php | 359 +++ .../OverlayDirectoryOwnershipTest.php | 252 ++ .../ComponentTranslation/PlainLogTextTest.php | 90 + .../PluralFormKeyTest.php | 91 + .../ComponentTranslation/PluralFormsTest.php | 472 ++++ .../ShippedPoMergerTest.php | 928 +++++++ .../ShippedTranslationsBuildTest.php | 377 +++ .../ShippedTranslationsTest.php | 146 ++ .../TranslationMarkupPolicyTest.php | 546 ++++ .../ImportLanguageFilePluralPlainKeyTest.php | 341 +++ ...UsesShippedValuesOfMigratedModulesTest.php | 742 ++++++ .../tests/LanguageComponentGraphTest.php | 13 + .../tests/MigratedAndLegacyModulesTest.php | 207 ++ .../Language/tests/MigratedPoFixture.php | 444 ++++ .../Language/tests/PoLastLocalChangeTest.php | 376 +++ .../tests/PoMigrationWriteBackTest.php | 457 ++++ .../tests/RendersActivityErrorsTest.php | 4 +- .../tests/ResetMigratedLocalChangesTest.php | 250 ++ .../tests/SaveValuesMergesOntoOverlayTest.php | 403 +++ .../tests/SaveValuesOverlayRemarksTest.php | 389 +++ ...tesLngModulesAfterDeleteModeImportTest.php | 191 ++ .../tests/SaveValuesWritesPluralFormsTest.php | 508 ++++ .../CallbackTranslationCache.php} | 21 +- .../InstallLanguagesRemovesOverlaysTest.php | 148 ++ ...nstalledLanguageDatabaseRepositoryTest.php | 195 +- ...anagerDatabaseRowsOfMigratedModuleTest.php | 227 ++ ...tallationManagerMigratedAfterWriteTest.php | 289 +++ ...allationManagerMigratedDroppedKeysTest.php | 200 ++ ...ManagerMigratedLocalChangesActionsTest.php | 194 ++ ...allationManagerMigratedModulesTestCase.php | 413 +++ ...allationManagerMigratedPluralFormsTest.php | 159 ++ ...ationManagerMigratedReconciliationTest.php | 378 +++ ...InstallationManagerMigratedRemarksTest.php | 246 ++ ...tionManagerMigratedSourcesAndLocksTest.php | 197 ++ .../Setup/LanguageInstallationManagerTest.php | 145 +- ...ppedLanguageFilesCompiledObjectiveTest.php | 584 +++++ ...guagesInstalledAndUpdatedObjectiveTest.php | 734 +++++- .../tests/ShippedChangesSinceBackupTest.php | 159 ++ .../ShippedValuesOfMigratedModulesTest.php | 601 +++++ ...MigratedFilesAfterDeleteModeImportTest.php | 409 +++ .../ILIAS/Language/tests/ToJSMapTest.php | 82 + .../UninstallRemovesMigratedMoFilesTest.php | 502 ++++ ...nstallRemovesPluginMigratedMoFilesTest.php | 500 ++++ .../Language/tests/WithPluralFormKeysTest.php | 130 + .../Language/tests/ilLanguageBaseTestCase.php | 2 + .../Language/tests/ilLanguageFileTest.php | 136 + .../tests/ilLanguageSetupAgentTest.php | 8 +- .../ilObjLanguageExtGUIMaintenanceTest.php | 301 +++ .../ilObjLanguageExtGUISaveNewEntryTest.php | 282 +++ .../ilObjLanguageExtGUISaveObjectTest.php | 217 ++ .../ilObjLanguageExtGUISaveSettingsTest.php | 191 ++ .../tests/ilObjLanguageExtGUITest.php | 493 +--- .../tests/ilObjLanguageExtGUITestFixture.php | 63 + ...ilObjLanguageFolderGUIIdValidationTest.php | 249 ++ ...LanguageFolderGUILanguageDetectionTest.php | 143 ++ .../tests/ilObjLanguageFolderGUITest.php | 1176 +-------- .../ilObjLanguageFolderGUITestFixture.php | 220 ++ ...jLanguageFolderGUIUninstallChangesTest.php | 335 +++ .../ilObjLanguageFolderGUIUninstallTest.php | 300 +++ .../tests/ilObjLanguageGuiTestSupport.php | 121 + .../Language/tests/ilPluginLanguageTest.php | 250 ++ .../Language/tests/ilSetupLanguageTest.php | 112 +- composer.json | 1 + composer.lock | 2 +- lang/ilias_de.lang | 11 + lang/ilias_en.lang | 11 + 134 files changed, 38168 insertions(+), 1906 deletions(-) create mode 100644 components/ILIAS/Language/exceptions/class.ilLanguageInvalidMarkupException.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/AtomicFileWriter.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationCatalog.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationEntry.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/InvalidMarkupMessage.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/LegacyFuzzyMarker.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/LocalChangeComments.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFilePaths.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/MigratedTranslations.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/NamesShippedLanguageFiles.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/NativeGettext.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/PlainLogText.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/PluralFormKey.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/PluralForms.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/ShippedPoMerger.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/ShippedTranslations.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/ShippedTranslationsBuild.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/TranslationMarkupPolicy.php create mode 100644 components/ILIAS/Language/src/Setup/LanguageDataNotSavedException.php create mode 100644 components/ILIAS/Language/src/Setup/ShippedLanguageFilesCompiledObjective.php create mode 100644 components/ILIAS/Language/tests/AdminGuiReadsValuesFromMigratedFileTest.php create mode 100644 components/ILIAS/Language/tests/ChildPhp.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/AtomicFileWriterTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationEntryTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/InvalidMarkupMessageTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/LocalChangeCommentsTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFilePathsTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncDeltaTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncEntryRulesTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncGuardsTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRemoveOverlayTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockRetryTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncLockTestCase.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncOverlayFilesTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralHelpersTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralOverlayTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncPluralRuleChangeTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRemarksTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncRevisionTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncSymlinkTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncTestCase.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncUnwritableTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/MigratedTranslationsTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/NativeGettextTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/OverlayDirectoryOwnershipTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/PlainLogTextTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/PluralFormKeyTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/PluralFormsTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/ShippedPoMergerTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsBuildTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/TranslationMarkupPolicyTest.php create mode 100644 components/ILIAS/Language/tests/ImportLanguageFilePluralPlainKeyTest.php create mode 100644 components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php create mode 100644 components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php create mode 100644 components/ILIAS/Language/tests/MigratedPoFixture.php create mode 100644 components/ILIAS/Language/tests/PoLastLocalChangeTest.php create mode 100644 components/ILIAS/Language/tests/PoMigrationWriteBackTest.php create mode 100644 components/ILIAS/Language/tests/ResetMigratedLocalChangesTest.php create mode 100644 components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php create mode 100644 components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php create mode 100644 components/ILIAS/Language/tests/SaveValuesRecreatesLngModulesAfterDeleteModeImportTest.php create mode 100644 components/ILIAS/Language/tests/SaveValuesWritesPluralFormsTest.php rename components/ILIAS/Language/tests/{bootstrap.php => Setup/CallbackTranslationCache.php} (59%) mode change 100755 => 100644 create mode 100644 components/ILIAS/Language/tests/Setup/InstallLanguagesRemovesOverlaysTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerDatabaseRowsOfMigratedModuleTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedAfterWriteTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedDroppedKeysTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedLocalChangesActionsTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedModulesTestCase.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedPluralFormsTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedReconciliationTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php create mode 100644 components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php create mode 100644 components/ILIAS/Language/tests/ShippedChangesSinceBackupTest.php create mode 100644 components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php create mode 100644 components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php create mode 100644 components/ILIAS/Language/tests/ToJSMapTest.php create mode 100644 components/ILIAS/Language/tests/UninstallRemovesMigratedMoFilesTest.php create mode 100644 components/ILIAS/Language/tests/UninstallRemovesPluginMigratedMoFilesTest.php create mode 100644 components/ILIAS/Language/tests/WithPluralFormKeysTest.php create mode 100644 components/ILIAS/Language/tests/ilLanguageFileTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageExtGUISaveNewEntryTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageExtGUISaveObjectTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageExtGUISaveSettingsTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageExtGUITestFixture.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageFolderGUIIdValidationTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageFolderGUILanguageDetectionTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageFolderGUITestFixture.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageFolderGUIUninstallChangesTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageFolderGUIUninstallTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageGuiTestSupport.php create mode 100644 components/ILIAS/Language/tests/ilPluginLanguageTest.php diff --git a/.gitignore b/.gitignore index 218c33abd964..31e4fd2ef157 100755 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ /db_template_writer.php /components/ILIAS/Database/sql/dbupdate_custom.php /install_client.php +/lang/customizing/*.po +/lang/customizing/ilias_*.lang # Directories /public diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php index 2690522210de..a9be7f366ab5 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php @@ -23,6 +23,7 @@ use ILIAS\UI; use ILIAS\Language\Activities\InstallLanguage; use ILIAS\Language\Activities\UpdateLanguage; +use ILIAS\Language\Setup\ShippedLanguageFilesCompiledObjective; class ilLanguageSetupAgent implements Setup\Agent { @@ -95,11 +96,14 @@ public function getUpdateObjective(?Setup\Config $config = null): Setup\Objectiv } /** - * @inheritdoc + * The directories come from the component graph, via the LanguageFileDirectoryManager of ilSetupLanguage. */ public function getBuildObjective(): Setup\Objective { - return new Setup\Objective\NullObjective(); + return new ShippedLanguageFilesCompiledObjective( + $this->il_setup_language->getLanguageFileDirectoryManager(), + dirname(__DIR__, 5) + ); } /** diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php index e1afafc51f6e..6bca297684bd 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php @@ -19,6 +19,9 @@ declare(strict_types=1); use ILIAS\Setup; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync; +use ILIAS\Language\ComponentTranslation\PlainLogText; +use ILIAS\Language\ComponentTranslation\TranslationMarkupPolicy; use ILIAS\Language\Activities\InstallLanguage; use ILIAS\Language\Activities\UpdateLanguage; @@ -46,11 +49,26 @@ public function __construct( } /** - * @inheritDoc + * The directory configuration is part of the hash: with a class-only hash Setup could keep the + * default-directory instance, which refreshes languages without the component-contributed modules. */ public function getHash(): string { - return hash("sha256", self::class); + $directories = []; + foreach ($this->il_setup_language->getLanguageFileDirectoryManager()->getAllDirectories() as $directory) { + $directories[] = implode('|', [ + $directory::class, + $directory->getPrefix(), + $directory->getPath(), + $directory->getSuffix(), + ]); + } + + return hash("sha256", implode("\n", [ + self::class, + $this->il_setup_language->usesDefaultLanguageFileDirectories() ? 'default-directories' : 'configured-directories', + ...$directories, + ])); } /** @@ -98,11 +116,238 @@ public function achieve(Setup\Environment $environment): Setup\Environment { // Must come first: getInstallLanguages() reads from the database too. $this->useSetupDatabase($environment); - $this->installLanguages($this->getInstallLanguages()); + $this->useSetupClientDataDir($environment); + + $language_keys = []; + $invalid_language_keys = []; + foreach ($this->getInstallLanguages() as $language_key) { + if ($this->il_setup_language->checkLanguageForInstallation($language_key)) { + $language_keys[] = $language_key; + } else { + $invalid_language_keys[] = $language_key; + } + } + if ($invalid_language_keys !== []) { + // an invalid language file only skips that language, the Setup run goes on + $this->inform( + $environment, + 'Skipped languages with invalid language files (left unchanged): ' + . implode(', ', $invalid_language_keys) + ); + } + + $this->informAboutMissingClientDataDirectory($environment, $language_keys); + $this->informAboutUnwritableOverlayDirectories($environment, $language_keys); + $this->informAboutUnwrittenOverlays($environment, $this->installLanguages($language_keys)); + $this->informAboutSkippedInvalidMarkupEntries($environment); + $this->informAboutInvalidMarkupInLocalChanges($environment, $language_keys); return $environment; } + /** + * Customizing entries with disallowed markup are not applied; the languages are installed regardless. + */ + protected function informAboutSkippedInvalidMarkupEntries(Setup\Environment $environment): void + { + foreach ($this->il_setup_language->getSkippedInvalidMarkupEntries() as $language_key => $entries) { + $this->inform( + $environment, + PlainLogText::of( + 'WARNING: Customizing entries of language ' . $language_key . ' were not applied, their ' + . 'value contains HTML that is not allowed in language texts: ' + . PlainLogText::keyList(array_map('strval', array_keys($entries))) + ) + ); + } + } + + /** + * Local changes written before the markup check existed are only reported, never changed: + * that is the administrator's decision. + * + * @param list $language_keys + */ + protected function informAboutInvalidMarkupInLocalChanges(Setup\Environment $environment, array $language_keys): void + { + $policy = new TranslationMarkupPolicy(); + $manager = $this->il_setup_language->getLanguageFileDirectoryManager(); + $client_data_dir = $this->il_setup_language->getClientDataDir(); + foreach ($language_keys as $language_key) { + $values = []; + try { + foreach ($this->il_setup_language->getLocalChanges($language_key) as $module => $entries) { + foreach ($entries as $identifier => $value) { + $values[$module . '#:#' . $identifier] = (string) $value; + } + } + foreach ($manager->getDirectories() as $directory) { + $module = $directory->getPrefix(); + foreach (MigratedLanguageFileSync::loadLocalChanges($manager, $language_key, $module, $client_data_dir) ?? [] as $identifier => $entry) { + $values[$module . '#:#' . $identifier] = $entry['value']; + } + } + } catch (\Throwable $t) { + error_log(PlainLogText::of(sprintf( + 'Could not check the local changes of language "%s" for markup: %s', + $language_key, + $t->getMessage() + ))); + continue; + } + + $keys = array_map('strval', array_keys($policy->findInvalidValues($values))); + if ($keys === []) { + continue; + } + error_log(PlainLogText::of(sprintf( + 'Local changes of language "%s" contain markup that is not allowed: %s', + $language_key, + implode(', ', $keys) + ))); + $this->inform( + $environment, + PlainLogText::of( + 'WARNING: Local changes of language ' . $language_key . ' contain HTML that is not allowed ' + . 'in language texts (left unchanged - please correct them in the administration): ' + . PlainLogText::keyList($keys) + ) + ); + } + } + + /** + * Typically during "setup install", before the client directory exists: the PO/MO files cannot be + * written and the texts come from the database until the next "setup update". + * + * @param list $language_keys + */ + protected function informAboutMissingClientDataDirectory(Setup\Environment $environment, array $language_keys): void + { + if ( + $language_keys === [] + || $this->il_setup_language->getClientDataDir() !== null + || !$this->il_setup_language->hasMigratedModules($language_keys) + ) { + return; + } + + $this->inform( + $environment, + 'WARNING: The client data directory is not known or does not exist yet, so the PO/MO language ' + . 'files of modules maintained in PO files are not written in this run - these modules are served ' + . 'from the database until then. Run "setup update" (as the web server user) once the client data ' + . 'directory exists.' + ); + } + + /** + * Checked before writing. Does not abort: the database is written regardless, the files are + * repaired by the next run with sufficient permissions. + * + * @param list $language_keys + */ + protected function informAboutUnwritableOverlayDirectories(Setup\Environment $environment, array $language_keys): void + { + $unwritable_directories = $this->il_setup_language->findUnwritableOverlayDirectories($language_keys); + if ($unwritable_directories !== []) { + $this->inform( + $environment, + 'WARNING: The PO/MO language files of modules maintained in PO files cannot be written to ' + . implode(', ', $unwritable_directories) . ' (not writable, or a file occupies the ' + . 'directory\'s place). The languages are still installed/updated in the database, but these ' + . 'modules keep showing outdated texts. Setup must be run as the web server user (the owner ' + . 'of the client data directory) - re-run "setup update" as that user to repair the files.' + ); + } + + $this->informIfNotRunAsClientDataDirectoryOwner($environment, $language_keys); + } + + /** + * Writable for Setup is not enough: files created by another user (typically root) cannot be + * replaced by the web server later. The owner of the client data directory counts as web server user. + * + * @param list $language_keys + */ + protected function informIfNotRunAsClientDataDirectoryOwner(Setup\Environment $environment, array $language_keys = []): void + { + $client_data_dir = $this->il_setup_language->getClientDataDir(); + if ($client_data_dir === null || !function_exists('posix_geteuid')) { + return; + } + $owner = @fileowner($client_data_dir); + $current_user = posix_geteuid(); + if ($owner === false || ($owner === $current_user && $current_user !== 0)) { + return; + } + if (!$this->il_setup_language->hasMigratedModules($language_keys)) { + return; + } + + $overlay_root = rtrim($client_data_dir, '/') . '/lang'; + if ($owner === 0) { + $this->inform( + $environment, + sprintf( + 'WARNING: Setup runs as user id %d, and the client data directory %s belongs to root. ' + . 'Running Setup as root is not supported: the web server cannot replace the PO/MO ' + . 'language files and directories of modules maintained in PO files that this run ' + . 'creates. Fix: run "chown -R %s" after this run, and run Setup ' + . 'as the web server user from now on.', + $current_user, + $client_data_dir, + $overlay_root + ) + ); + return; + } + + $owner_info = function_exists('posix_getpwuid') ? @posix_getpwuid($owner) : false; + $owner_name = is_array($owner_info) ? (string) $owner_info['name'] : (string) $owner; + $unwritable_for_owner = $this->il_setup_language->findUnwritableOverlayDirectories($language_keys, $owner); + + $this->inform( + $environment, + sprintf( + 'WARNING: Setup runs as user id %d, but the client data directory %s belongs to user id %d. ' + . 'Running Setup as another user than the web server user (the owner of the client data ' + . 'directory) is not supported%s: the PO/MO language files and directories of modules ' + . 'maintained in PO files that this run creates belong to user id %d and cannot be ' + . 'replaced by the web server later on.%s Fix: run "chown -R %s %s" after this run, and ' + . 'run Setup as user %s from now on.', + $current_user, + $client_data_dir, + $owner, + $current_user === 0 ? ' (this includes root)' : '', + $current_user, + $unwritable_for_owner === [] + ? '' + : ' Already not writable for user id ' . $owner . ': ' . implode(', ', $unwritable_for_owner) . '.', + $owner_name, + $overlay_root, + $owner_name + ) + ); + } + + /** + * @param list $language_keys languages whose PO/MO overlay could not be written + */ + protected function informAboutUnwrittenOverlays(Setup\Environment $environment, array $language_keys): void + { + if ($language_keys === []) { + return; + } + + $this->inform( + $environment, + 'WARNING: The PO/MO language files of modules maintained in PO files could not be written for ' + . 'the languages ' . implode(', ', $language_keys) . ' (see the PHP error log for details). ' + . 'Setup must be run as the web server user - re-run "setup update" as that user.' + ); + } + /** * @inheritDoc */ @@ -118,15 +363,7 @@ public function isApplicable(Setup\Environment $environment): bool } /** - * 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. + * Must run before anything is read from ilSetupLanguage, so that the language list comes from the Setup database. */ protected function useSetupDatabase(Setup\Environment $environment): void { @@ -135,26 +372,72 @@ protected function useSetupDatabase(Setup\Environment $environment): void ); } + /** + * Taken from the Setup environment, which knows it before ilias.ini.php is written; + * otherwise ilSetupLanguage falls back to ilias.ini.php. + */ + protected function useSetupClientDataDir(Setup\Environment $environment): void + { + $ini = $environment->getResource(Setup\Environment::RESOURCE_ILIAS_INI); + $client_id = $environment->getResource(Setup\Environment::RESOURCE_CLIENT_ID); + if (!$ini instanceof ilIniFile || $client_id === null) { + return; + } + + $client_data_dir = \ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths::fromDataDirAndClientId( + (string) $ini->readVariable('clients', 'datadir'), + (string) $client_id + ); + if ($client_data_dir !== null) { + $this->il_setup_language->setClientDataDir($client_data_dir); + } + } + + protected function inform(Setup\Environment $environment, string $message): void + { + $io = $environment->getResource(Setup\Environment::RESOURCE_ADMIN_INTERACTION); + if ($io instanceof Setup\AdminInteraction) { + $io->inform($message); + return; + } + error_log($message); + } + /** * Installs missing languages and refreshes installed ones on every run, so that changed language files take effect. * - * 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. + * Both Activities get the full list and ignore keys that do not apply to them; UpdateLanguage also + * runs on just-installed keys, which is harmless. + * + * An instance with only the default directories never refreshes: that would delete the lng_data + * rows of component-contributed modules. * * @param list $language_keys + * @return list the languages whose PO/MO overlay could not be written (see + * InstallLanguage/UpdateLanguage, "overlay_write_failed_language_keys") */ - protected function installLanguages(array $language_keys): void + protected function installLanguages(array $language_keys): array { - $this->install_language->perform([ + if ($language_keys === []) { + return []; + } + + $install_result = $this->install_language->perform([ 'language_keys' => $language_keys, 'mode' => InstallLanguage::MODE_INSTALL, ]); + $overlay_write_failed = $install_result['overlay_write_failed_language_keys'] ?? []; - $this->update_language->perform([ - 'language_keys' => $language_keys, - ]); + if (!$this->il_setup_language->usesDefaultLanguageFileDirectories()) { + $update_result = $this->update_language->perform([ + 'language_keys' => $language_keys, + ]); + $overlay_write_failed = array_merge( + $overlay_write_failed, + $update_result['overlay_write_failed_language_keys'] ?? [] + ); + } + + return array_values(array_unique($overlay_write_failed)); } } diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index b4da0e482969..3bcc252b5986 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -19,9 +19,10 @@ 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\ComponentTranslation\MigratedLanguageFilePaths; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync; use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\LanguageInstallationManager; @@ -56,19 +57,26 @@ class ilSetupLanguage extends ilLanguage public string $separator = "#:#"; public string $comment_separator = "###"; protected ?ilDBInterface $db = null; + private ?string $client_data_dir = null; + private readonly bool $uses_default_language_file_directories; + private readonly LanguageFileDirectoryManager $language_file_directory_manager; + private ?LanguageInstallationManager $manager = null; + private ?ilDBInterface $manager_db = null; /** - * $connection is the ILIAS\Database\Connection; callers outside the - * component graph pass none. + * @param LanguageFileDirectoryManager|null $language_file_directory_manager directories contributed through the + * component graph; without it only main lang/ and customizing are known (usesDefaultLanguageFileDirectories()) + * @param ilDBInterface|null $connection none for callers outside the component graph */ public function __construct( string $a_lang_key, - private ?LanguageFileDirectoryManager $language_file_directory_manager = null, + ?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; $this->absolute_path = (string) realpath(__DIR__ . "/../../../../../"); + $this->uses_default_language_file_directories = $language_file_directory_manager === null; $this->language_file_directory_manager = $language_file_directory_manager ?? new LanguageFileDirectoryManager( new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory() @@ -98,16 +106,55 @@ private function repository(): InstalledLanguageRepository ); } + /** + * Kept across calls because the manager collects getSkippedInvalidMarkupEntries() over a run; + * rebuilt only when db() changes. + */ 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 - ); + $db = $this->db() ?? throw new LogicException("No database available for " . self::class . "."); + if ($this->manager === null || $this->manager_db !== $db) { + $this->manager_db = $db; + $this->manager = new LanguageInstallationManager( + $db, + $this->language_file_directory_manager, + $this->absolute_path, + $this->repository(), + null, + $this->translation_cache, + fn(): ?string => $this->getClientDataDir() + ); + } + return $this->manager; + } + + /** + * The client data directory the overlay of migrated modules is maintained in. Setup Objectives + * set it from the Setup environment (ilias.ini `datadir` + client id); without it, it is + * resolved via MigratedLanguageFilePaths::resolveClientDataDir(). `null` resets to that default. + */ + public function setClientDataDir(?string $client_data_dir): void + { + $this->client_data_dir = $client_data_dir; + } + + public function getClientDataDir(): ?string + { + return $this->client_data_dir ?? MigratedLanguageFilePaths::resolveClientDataDir($this->absolute_path); + } + + /** + * Such an instance (e.g. `new ilSetupLanguage('en')` in plugin Setup Objectives) must not refresh an + * installed language: that would drop the lng_data rows of every component-contributed module. + */ + public function usesDefaultLanguageFileDirectories(): bool + { + return $this->uses_default_language_file_directories; + } + + public function getLanguageFileDirectoryManager(): LanguageFileDirectoryManager + { + return $this->language_file_directory_manager; } /** @@ -119,6 +166,7 @@ private function manager(): LanguageInstallationManager */ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''): string { + /** @var ilLogger $log */ global $log; if (empty($a_topic)) { @@ -201,17 +249,70 @@ public function flushLanguageForUninstallation(string $lang_key): void $this->manager()->flushLanguageForUninstallation($lang_key); } - public function insertLanguageForInstallation(string $lang_key): void + /** + * @return array>> + */ + public function getSkippedInvalidMarkupEntries(): array + { + return $this->manager?->getSkippedInvalidMarkupEntries() ?? []; + } + + /** + * @return list the migrated modules whose overlay could not be written + */ + public function insertLanguageForInstallation(string $lang_key): array { - $this->manager()->insertLanguageForInstallation($lang_key); + return $this->manager()->insertLanguageForInstallation($lang_key); } /** * See LanguageInstallationManager::insertLanguageForApplyingLocalChanges(). + * + * @return list see insertLanguageForInstallation() + */ + public function insertLanguageForApplyingLocalChanges(string $lang_key): array + { + return $this->manager()->insertLanguageForApplyingLocalChanges($lang_key); + } + + /** + * See LanguageInstallationManager::findUnwritableOverlayDirectories() - checked by Setup before + * installing/updating languages. File-based like the manager's, but without needing a database + * (the manager is only built with one, see manager()). + * + * @param list $lang_keys + * @param int|null $for_user_id evaluate the writability for this user instead of the current process + * @return list + */ + public function findUnwritableOverlayDirectories(array $lang_keys, ?int $for_user_id = null): array + { + return MigratedLanguageFileSync::findUnwritableOverlayDirectories( + $this->language_file_directory_manager, + $this->absolute_path, + $lang_keys, + $this->getClientDataDir(), + $for_user_id + ); + } + + /** + * See LanguageInstallationManager::hasMigratedModules() - file-based, needs no database. + * + * @param list $lang_keys */ - public function insertLanguageForApplyingLocalChanges(string $lang_key): void + public function hasMigratedModules(array $lang_keys): bool { - $this->manager()->insertLanguageForApplyingLocalChanges($lang_key); + foreach ($lang_keys as $lang_key) { + if (MigratedLanguageFileSync::findShippedModuleFiles( + $this->language_file_directory_manager, + $this->absolute_path, + $lang_key + ) !== []) { + return true; + } + } + + return false; } /** diff --git a/components/ILIAS/Language/classes/class.ilCachedLanguage.php b/components/ILIAS/Language/classes/class.ilCachedLanguage.php index d0e5ecfc00be..f20514c7178e 100755 --- a/components/ILIAS/Language/classes/class.ilCachedLanguage.php +++ b/components/ILIAS/Language/classes/class.ilCachedLanguage.php @@ -64,6 +64,9 @@ public function isForced(): bool /** * Return whether the global cache is active + * + * Always true, so cached_modules is always filled: ilLanguage::loadLanguageModule() must check the + * PO/MO overlay of a migrated module before cached_modules, or the DB cache hides the overlay. */ public function isActive(): bool { diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index a34a0c011d6e..ce0f872b3ec7 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -18,26 +18,31 @@ declare(strict_types=1); +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; +use ILIAS\Language\ComponentTranslation\MigratedTranslations; + /** * language handling * * this class offers the language handling for an application. - * it works initially on one file: languages.txt - * from this file the class can generate many single language files. * the constructor is called with a small language abbreviation * e.g. $lng = new Language("en"); - * the constructor reads the single-languagefile en.lang and puts this into an array. * with * e.g. $lng->txt("user_updated"); * you can translate a lang-topic into the actual language * + * Two coexisting backends per module: the legacy DB tables lng_data/lng_modules (for every module + * not migrated yet), and - for a module migrated for the requested language - its build served + * through native gettext with the overlay of the local changes on top (see MigratedTranslations), + * never the DB. The values of a migrated module do not go into $text: loadLanguageModule() records + * its identifiers in $migrated_key_modules, and txt() looks them up when asked. The DB path exists + * to be fully replaced and eventually removed as more modules migrate onto the file-based one, not + * to be maintained forever alongside it. + * * @author Peter Gabriel * @version $Id$ - * - * - * @todo Das Datefeld wird bei Aenderungen einer Sprache (update, install, deinstall) nicht richtig gesetzt!!! - * Die Formatfunktionen gehoeren nicht in class.Language. Die sind auch woanders einsetzbar!!! - * Daher->besser in class.Format */ class ilLanguage implements \ILIAS\Language\Language { @@ -53,6 +58,28 @@ class ilLanguage implements \ILIAS\Language\Language public array $loaded_modules = array(); protected static array $used_topics = array(); protected static array $used_modules = array(); + + /** + * Static, since _lookupEntry() is; taken from the list the constructor reads anyway, `null` until known. + * + * @var list|null + */ + private static ?array $installed_languages = null; + + /** + * Identifier => module; the module loaded last is responsible. An identifier the responsible migrated module + * does not translate gives "-identifier-", never the value of an earlier module (see txt()). + * + * @var array + */ + protected array $migrated_key_modules = []; + + /** + * Per "|", see isShippedWithoutBuild(). + * + * @var array + */ + private static array $shipped_without_build = []; protected array $cached_modules = array(); protected array $map_modules_txt = array(); protected bool $usage_log_enabled = false; @@ -99,6 +126,7 @@ public function __construct(string $a_lang_key) } $langs = $this->getInstalledLanguages(); + self::$installed_languages = array_values(array_map('strval', $langs)); if (!in_array($this->lang_key, $langs, true)) { $this->lang_key = $this->lang_default; @@ -176,7 +204,22 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): // remember the used topics self::$used_topics[$a_topic] = $a_topic; - $translation = $this->text[$a_topic] ?? ""; + $module = $this->migrated_key_modules[$a_topic] ?? null; + if ($module !== null) { + $translation = $this->migratedText($module, $a_topic); + if ($translation !== null) { + if ($this->usage_log_enabled) { + self::logUsage($module, $a_topic); + } + return $translation; + } + // deliberately not the value of an earlier module: with the same identifier it mostly means + // something else, and "-key-" is noticed. Differs from the .lang path (array_merge kept the earlier value). + // Only the fallback module the caller names still applies. + $translation = ""; + } else { + $translation = $this->text[$a_topic] ?? ""; + } if ($translation === "" && $a_default_lang_fallback_mod !== "") { // #13467 - try current language first (could be missing module) @@ -217,6 +260,11 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): */ public function exists(string $a_topic): bool { + $module = $this->migrated_key_modules[$a_topic] ?? null; + if ($module !== null) { + return $this->migratedText($module, $a_topic) !== null; + } + return isset($this->text[$a_topic]); } @@ -243,8 +291,18 @@ public function loadLanguageModule(string $a_module): void $lang_key = $this->lang_user; } + // Must run before the cached_modules check: ilCachedLanguage::isActive() is always true, and migrated + // modules keep a DB row on purpose (dual-write, see ilObjLanguage::syncMigratedLanguageFile()). + $migrated_keys = self::migratedKeysOf($a_module, $lang_key); + if ($migrated_keys !== null) { + // the module loaded last is responsible for its identifiers + $this->migrated_key_modules = $migrated_keys + $this->migrated_key_modules; + return; + } + if (isset($this->cached_modules[$a_module]) && is_array($this->cached_modules[$a_module])) { $this->text = array_merge($this->text, $this->cached_modules[$a_module]); + $this->forgetMigratedKeys($this->cached_modules[$a_module]); if ($this->usage_log_enabled) { foreach (array_keys($this->cached_modules[$a_module]) as $key) { @@ -268,6 +326,7 @@ public function loadLanguageModule(string $a_module): void $new_text = unserialize($row["lang_array"], ["allowed_classes" => false]); if (is_array($new_text)) { $this->text = array_merge($this->text, $new_text); + $this->forgetMigratedKeys($new_text); if ($this->usage_log_enabled) { foreach (array_keys($new_text) as $key) { @@ -277,6 +336,132 @@ public function loadLanguageModule(string $a_module): void } } + /** + * A module that is not migrated, loaded after migrated ones, wins for its identifiers. + * + * @param array $text + */ + private function forgetMigratedKeys(array $text): void + { + if ($this->migrated_key_modules !== []) { + $this->migrated_key_modules = array_diff_key($this->migrated_key_modules, $text); + } + } + + private function migratedText(string $module, string $a_topic): ?string + { + return MigratedTranslations::text($module, $this->migratedLangKey(), $a_topic, self::clientDataDir()); + } + + private function migratedLangKey(): string + { + return $this->lang_key !== '' ? $this->lang_key : $this->lang_user; + } + + /** + * The migrated state is only served for installed languages; the list is the one the constructor already + * determined, without a query of its own. Without it (no ilLanguage constructed yet) nothing is restricted. + */ + private static function isInstalledLanguage(string $lang_key): bool + { + return self::$installed_languages === null || in_array($lang_key, self::$installed_languages, true); + } + + /** + * $lang_key was uninstalled in this request: its migrated state is no longer served (see isInstalledLanguage()), + */ + public static function forgetInstalledLanguage(string $lang_key): void + { + if (self::$installed_languages !== null) { + self::$installed_languages = array_values(array_diff(self::$installed_languages, [$lang_key])); + } + MigratedTranslations::forgetLanguage($lang_key); + } + + /** + * Makes an overlay write (see MigratedLanguageFileSync) visible to the rest of the request. + */ + public static function invalidateMigratedLanguageFileCache(string $a_module, string $lang_key): void + { + MigratedTranslations::invalidate($a_module, $lang_key); + } + + /** + * `null` unless $a_module is migrated for $lang_key, so the caller reads lng_modules/lng_data. A module whose + * `.po` is not built yet is migrated but has no identifiers (reported); never read from the database instead. + * + * Deliberately only when CLIENT_DATA_DIR is defined, not via MigratedLanguageFilePaths::resolveClientDataDir(): + * before ilInitialisation::initClientDataDir() (e.g. plugin Setup Objectives) lng_modules is the correct source. + * + * @return array|null + */ + private static function migratedKeysOf(string $a_module, string $lang_key): ?array + { + if (!defined('CLIENT_DATA_DIR') || !self::isInstalledLanguage($lang_key)) { + return null; + } + $keys = MigratedTranslations::keysOf($a_module, $lang_key, self::clientDataDir()); + if ($keys !== null || !self::isShippedWithoutBuild($a_module, $lang_key)) { + return $keys; + } + MigratedTranslations::reportProblem(sprintf( + 'Module "%s" ships a language file for "%s" that is not built - run "php cli/setup.php build".', + $a_module, + $lang_key + )); + + return []; + } + + private static function isShippedWithoutBuild(string $a_module, string $lang_key): bool + { + return self::$shipped_without_build[$a_module . '|' . $lang_key] ??= self::findShippedWithoutBuild($a_module, $lang_key); + } + + private static function findShippedWithoutBuild(string $a_module, string $lang_key): bool + { + $directory = self::findLanguageFileDirectory($a_module); + if ($directory === null) { + return false; + } + try { + return is_file(MigratedLanguageFilePaths::shippedBasePath( + defined('ILIAS_ABSOLUTE_PATH') ? (string) ILIAS_ABSOLUTE_PATH : dirname(__DIR__, 4), + $directory, + $lang_key + ) . '.po'); + } catch (\InvalidArgumentException) { + return false; + } + } + + private static function clientDataDir(): ?string + { + return defined('CLIENT_DATA_DIR') ? (string) CLIENT_DATA_DIR : null; + } + + /** + * Assembled by the Language.php component graph and re-exposed to legacy $DIC code by AllModernComponents.php. + */ + private static function findLanguageFileDirectory(string $a_module): ?LanguageFileDirectory + { + global $DIC; + + if (!$DIC->offsetExists(LanguageFileDirectoryManager::class)) { + return null; + } + + /** @var LanguageFileDirectoryManager $manager */ + $manager = $DIC[LanguageFileDirectoryManager::class]; + foreach ($manager->getDirectories() as $directory) { + if ($directory->getPrefix() === $a_module) { + return $directory; + } + } + + return null; + } + /** * Get installed languages */ @@ -307,6 +492,25 @@ public static function _getInstalledLanguages(): array public static function _lookupEntry(string $a_lang_key, string $a_mod, string $a_id): string { + // a module migrated for $a_lang_key is never read from lng_data + $migrated_keys = self::migratedKeysOf($a_mod, $a_lang_key); + if ($migrated_keys !== null) { + $value = isset($migrated_keys[$a_id]) + ? MigratedTranslations::text($a_mod, $a_lang_key, $a_id, self::clientDataDir()) + : null; + if ($value === null || $value === '') { + return "-" . $a_id . "-"; + } + self::$used_topics[$a_id] = $a_id; + self::$used_modules[$a_mod] = $a_mod; + + if (self::isUsageLogEnabled()) { + self::logUsage($a_mod, $a_id); + } + + return $value; + } + global $DIC; $ilDB = $DIC->database(); @@ -503,7 +707,11 @@ public function toJSMap(array $a_map, ?ilGlobalTemplateInterface $a_tpl = null): foreach ($a_map as $k => $v) { if ($v != "") { - $a_tpl->addOnloadCode("il.Language.setLangVar('" . $k . "', " . json_encode($v, JSON_THROW_ON_ERROR) . ");"); + // HEX flags: a value or key can never end the surrounding her'); + + $this->assertTrue($this->policy->isAllowed($result)); + $this->assertStringNotContainsString('bad()', $result); + $this->assertSame('vorher', trim($result)); + } + + #[DataProvider('allowedHrefUrls')] + public function testAllowedHrefUrlSchemes(string $url): void + { + $this->assertTrue($this->policy->isAllowed(sprintf('x', $url))); + } + + /** + * @return array + */ + public static function allowedHrefUrls(): array + { + return [ + 'http' => ['http://ilias.de'], + 'https' => ['https://ilias.de'], + 'mailto' => ['mailto:info@ilias.de'], + 'relative path' => ['/goto.php'], + 'relative with query' => ['goto.php?target=1'], + 'fragment' => ['#top'], + 'placeholder' => ['%1$s'], + ]; + } + + #[DataProvider('disallowedSrcUrls')] + public function testMailtoIsNotAllowedForImgSrc(string $url): void + { + $this->assertFalse($this->policy->isAllowed(sprintf('x', $url))); + } + + /** + * @return array + */ + public static function disallowedSrcUrls(): array + { + return [ + 'mailto is only allowed for href, not src' => ['mailto:info@ilias.de'], + 'javascript' => ['javascript:alert(1)'], + 'data' => ['data:text/html,x'], + ]; + } + + /** + * The single check before any local write (admin edit, import, customizing file, "add new variable"): returns only + * the invalid entries, keyed by original key in iteration order, never the values (ilLanguageInvalidMarkupException + * reports only keys). + */ + public function testFindInvalidValuesReturnsOnlyTheInvalidEntriesInInputOrder(): void + { + $result = (new TranslationMarkupPolicy())->findInvalidValues([ + 'greeting' => 'Hallo', + 'bad_one' => '', + 'farewell' => 'Tschüss', + 'bad_two' => '', + ]); + + $this->assertSame(['bad_one', 'bad_two'], array_keys($result)); + $this->assertContains('tag ']); + + $this->assertSame([42], array_keys($result)); + $this->assertSame('42', implode(',', array_keys($result))); + } + + /** + * A non-string value (e.g. a bool/int/null slipping through from an untyped array) is cast to + * string before the check, exactly like findViolations() itself expects. + */ + public function testFindInvalidValuesCastsNonStringValuesToStringBeforeChecking(): void + { + $result = (new TranslationMarkupPolicy())->findInvalidValues(['flag' => false, 'greeting' => 'Hallo']); + + // false -> "" -> allowed (no tag at all) + $this->assertSame([], $result); + } + + /** + * A value is checked only when the write actually changes it (differs from current and shipped): an unmodified + * value with disallowed markup that was shipped that way (e.g. a historical `
`) must not block saving or + * importing the whole file/form; only an edited value violating the policy is rejected. + */ + /** + * @param array $values + * @param array $current + * @param array $shipped + * @param list $expected_invalid_keys + */ + #[DataProvider('findInvalidChangedValuesCases')] + public function testFindInvalidChangedValues(array $values, array $current, array $shipped, array $expected_invalid_keys): void + { + $result = (new TranslationMarkupPolicy())->findInvalidChangedValues($values, $current, $shipped); + + $this->assertSame($expected_invalid_keys, array_keys($result)); + } + + /** + * @return array, 1: array, 2: array, 3: list}> + */ + public static function findInvalidChangedValuesCases(): array + { + $bad = ''; + + return [ + 'unmodified value equal to the shipped one, even with disallowed markup, is not checked' => [ + ['key' => $bad], ['key' => 'irrelevant, current differs too'], ['key' => $bad], [], + ], + 'unmodified value equal to the current one, even with disallowed markup, is not checked' => [ + ['key' => $bad], ['key' => $bad], ['key' => 'unrelated shipped value'], [], + ], + 'a genuinely changed value with disallowed markup is rejected' => [ + ['key' => $bad], ['key' => 'Hallo'], ['key' => 'Servus'], ['key'], + ], + 'a genuinely changed but allowed value is never in the result' => [ + ['key' => 'Hallo'], ['key' => 'Hallo'], ['key' => 'Servus'], [], + ], + 'a key not present in current or shipped at all (a new entry) is checked' => [ + ['key' => $bad], [], [], ['key'], + ], + 'export-then-reimport unchanged: same as both current and shipped, byte for byte' => [ + ['key' => $bad], ['key' => $bad], ['key' => $bad], [], + ], + ]; + } + + public function testFindInvalidChangedValuesReturnsTheSameViolationsAsFindInvalidValues(): void + { + $policy = new TranslationMarkupPolicy(); + $bad = ''; + + $changed = $policy->findInvalidChangedValues(['key' => $bad], ['key' => 'Hallo'], []); + $plain = $policy->findInvalidValues(['key' => $bad]); + + $this->assertSame($plain, $changed); + } +} diff --git a/components/ILIAS/Language/tests/ImportLanguageFilePluralPlainKeyTest.php b/components/ILIAS/Language/tests/ImportLanguageFilePluralPlainKeyTest.php new file mode 100644 index 000000000000..5b0d64dfddcc --- /dev/null +++ b/components/ILIAS/Language/tests/ImportLanguageFilePluralPlainKeyTest.php @@ -0,0 +1,341 @@ + realpath(__DIR__ . '/../../../../'), + 'ILIAS_HTTP_PATH' => 'http://localhost', + 'ILIAS_VERSION' => 'test', + ] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } + } + $this->created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + MigratedPoFixture::resetRuntime(); + (new ReflectionClass(ilCachedLanguage::class))->getProperty('instances')->setValue(null, []); + (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects')->setValue(null, []); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . $this->fixture_directory); + MigratedPoFixture::removeDirectory(rtrim(CLIENT_DATA_DIR, '/') . '/lang'); + } + if ($this->upload_file !== null && is_file($this->upload_file)) { + unlink($this->upload_file); + } + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects')->setValue(null, []); + + parent::tearDown(); + } + + private function seedShippedPluralModule(): LanguageFileDirectory + { + $this->fixture_directory = 'tmp-import-plural-fixtures-' . bin2hex(random_bytes(4)); + $relative_path = 'components/ILIAS/Language/tests/' . $this->fixture_directory . '/'; + + $catalog = new TranslationCatalog(); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $entry = new TranslationEntry(self::MODULE, 'item'); + $entry->setPlural('items', ['Eintrag', 'Einträge']); + $catalog->add($entry); + MigratedPoFixture::writeShippedPo($relative_path, self::MODULE, self::LANG_KEY, $catalog); + + $directory = MigratedPoFixture::directory(self::MODULE, $relative_path); + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory) + ); + + return $directory; + } + + private function stubLng(): ilLanguage + { + $lng = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $this->setGlobalVariable('lng', $lng); + + return $lng; + } + + private function seedEmptyGlobalLanguageFile(string $lang_key): void + { + $fake_global_file = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor(); + $fake_global_file->setAllValues([]); + $fake_global_file->setAllComments([]); + + $cache = (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects'); + $existing = $cache->isInitialized() ? $cache->getValue() : []; + $cache->setValue(null, $existing + [$lang_key => $fake_global_file]); + } + + /** + * @param list}> $replace_calls filled with every + * replace("lng_data", ...) call: [identifier, ['value' => ..., 'local_change' => ...]] + */ + private function mockDatabaseForWrites(array &$replace_calls): ilDBInterface&Stub + { + $lng_modules_stmt = $this->createStub(ilDBStatement::class); + $remarks_stmt = $this->createStub(ilDBStatement::class); + $lng_data_stmt = $this->createStub(ilDBStatement::class); + $query_f_stmt = $this->createStub(ilDBStatement::class); + + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value, string $type = ''): string => "'" . (string) $value . "'"); + $db->method('in')->willReturn("module IN ('placeholder')"); + $db->method('query')->willReturnCallback( + static function (string $sql) use ($lng_modules_stmt, $remarks_stmt, $lng_data_stmt): ilDBStatement { + if (str_contains($sql, 'lng_modules')) { + return $lng_modules_stmt; + } + return str_contains($sql, 'remarks') ? $remarks_stmt : $lng_data_stmt; + } + ); + $db->method('queryF')->willReturn($query_f_stmt); + $db->method('fetchAssoc')->willReturnCallback( + static function (ilDBStatement $statement) use ($query_f_stmt): ?array { + return $statement === $query_f_stmt ? ['lang_array' => serialize([])] : null; + } + ); + $db->method('manipulate')->willReturn(0); + $db->method('insert')->willReturn(1); + $db->method('replace')->willReturnCallback( + static function (string $table, array $keys, array $values) use (&$replace_calls): int { + if ($table === 'lng_data') { + $replace_calls[] = [$keys['identifier'][1] ?? null, [ + 'value' => $values['value'][1] ?? null, + 'local_change' => $values['local_change'][1] ?? null, + ]]; + } + return 1; + } + ); + + $this->setGlobalVariable('ilDB', $db); + $this->setGlobalVariable('ilErr', $this->createStub(ilErrorHandling::class)); + + return $db; + } + + private function languageObject(): ilObjLanguageExt + { + $object = (new ReflectionClass(ilObjLanguageExt::class))->newInstanceWithoutConstructor(); + $object->key = self::LANG_KEY; + $object->separator = '#:#'; + + return $object; + } + + private function overlayPoPath(): string + { + return rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE . '/' . self::LANG_KEY . '/' . self::MODULE . '_' . self::LANG_KEY . '.po'; + } + + private function writeUploadFile(string $content): string + { + $this->upload_file = sys_get_temp_dir() . '/ilias_import_plural_' . bin2hex(random_bytes(4)) . '_' . self::LANG_KEY . '.lang'; + file_put_contents($this->upload_file, "/* header */\n\n" . $content); + + return $this->upload_file; + } + + /** Changes the default form (form 1) locally, so an overlay exists that differs from the shipped value. */ + private function locallyChangeDefaultForm(ilLanguage $lng, string $value): void + { + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + ilObjLanguageExt::_saveValues(self::LANG_KEY, [self::MODULE . $lng->separator . 'item [1]' => $value], []); + + $entry = TranslationCatalog::fromPoFile($this->overlayPoPath())->find(null, 'item'); + $this->assertNotNull($entry, 'precondition: the overlay holds the local change'); + $this->assertSame($value, $entry->getPluralTranslations()[1] ?? null, 'precondition failed'); + } + + /** "keepnew"/"keepall" keep the locally changed value for a plain-key line, like for an explicit form-key line. */ + #[DataProvider('modesThatKeepTheLocalChange')] + public function testPlainKeyLineIsKeptWhenTheStandardFormIsAlreadyLocallyChanged(string $mode): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $this->locallyChangeDefaultForm($lng, 'Alte lokale Form'); + + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + $file = $this->writeUploadFile(self::MODULE . "#:#item#:#X\n"); + + $this->languageObject()->importLanguageFile($file, $mode); + + $entry = TranslationCatalog::fromPoFile($this->overlayPoPath())->find(null, 'item'); + $this->assertNotNull($entry); + $this->assertSame('Alte lokale Form', $entry->getPluralTranslations()[1] ?? null, 'the local change is kept, not overwritten by X'); + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertSame([], $item_calls, 'a kept key needs no rewrite: nothing to save for it'); + } + + /** @return array */ + public static function modesThatKeepTheLocalChange(): array + { + return ['keepnew' => ['keepnew'], 'keepall' => ['keepall']]; + } + + public function testPlainKeyLineOverwritesTheStandardFormInReplaceMode(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $this->locallyChangeDefaultForm($lng, 'Alte lokale Form'); + + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + $file = $this->writeUploadFile(self::MODULE . "#:#item#:#X\n"); + + $this->languageObject()->importLanguageFile($file, 'replace'); + + $entry = TranslationCatalog::fromPoFile($this->overlayPoPath())->find(null, 'item'); + $this->assertNotNull($entry); + $this->assertSame('X', $entry->getPluralTranslations()[1] ?? null, 'replace overwrites the standard form with the uploaded value'); + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('X', $item_calls[0][1]['value']); + } + + /** With $skipInvalidMarkup the invalid plain-key line is left out and reported under the resolved form key ("#:#item [1]"). */ + public function testInvalidMarkupOfAPlainPluralKeyIsSkippedUnderItsResolvedFormKey(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + $file = $this->writeUploadFile(self::MODULE . "#:#item#:#\n"); + + $object = $this->languageObject(); + $object->importLanguageFile($file, 'replace', true); + + $skipped = $object->getSkippedInvalidMarkupValues(); + $this->assertArrayHasKey(self::MODULE . '#:#item [1]', $skipped, 'reported under the resolved form key'); + $this->assertArrayNotHasKey(self::MODULE . '#:#item', $skipped, 'never under the plain key the file line used'); + + if (is_file($this->overlayPoPath())) { + $entry = TranslationCatalog::fromPoFile($this->overlayPoPath())->find(null, 'item'); + $this->assertNotNull($entry); + $this->assertSame('Einträge', $entry->getPluralTranslations()[1] ?? null); + } + } + + /** Without $skipInvalidMarkup the invalid-value map of the exception is keyed by the resolved form key too. */ + public function testInvalidMarkupOfAPlainPluralKeyRejectsTheImportNamingTheResolvedFormKey(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + $file = $this->writeUploadFile(self::MODULE . "#:#item#:#\n"); + + try { + $this->languageObject()->importLanguageFile($file, 'replace'); + $this->fail('Expected an ilLanguageInvalidMarkupException'); + } catch (ilLanguageInvalidMarkupException $e) { + $this->assertSame([self::MODULE . '#:#item [1]'], array_keys($e->getInvalidValues())); + } + } + + /** Plain and explicit form line of the same form: the later one wins; the plain line's "###" comment becomes the remark. */ + public function testLaterFormLineWinsOverAnEarlierPlainLineAndItsCommentBecomesTheFormsRemark(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + $file = $this->writeUploadFile( + self::MODULE . "#:#item#:#Von Plain-Key###Bitte pruefen\n" + . self::MODULE . "#:#item [1]#:#Von Formzeile\n" + ); + + $this->languageObject()->importLanguageFile($file, 'replace'); + + $entry = TranslationCatalog::fromPoFile($this->overlayPoPath())->find(null, 'item'); + $this->assertNotNull($entry); + $this->assertSame('Von Formzeile', $entry->getPluralTranslations()[1] ?? null, 'the later, explicit form line wins'); + } + + public function testLaterPlainLineWinsOverAnEarlierFormLine(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + $file = $this->writeUploadFile( + self::MODULE . "#:#item [1]#:#Von Formzeile\n" + . self::MODULE . "#:#item#:#Von Plain-Key###Bitte pruefen\n" + ); + + $this->languageObject()->importLanguageFile($file, 'replace'); + + $entry = TranslationCatalog::fromPoFile($this->overlayPoPath())->find(null, 'item'); + $this->assertNotNull($entry); + $this->assertSame('Von Plain-Key', $entry->getPluralTranslations()[1] ?? null, 'the later, plain line wins'); + $this->assertSame( + 'Bitte pruefen', + \ILIAS\Language\ComponentTranslation\LocalChangeComments::getRemark($entry), + 'the plain line\'s "###" comment becomes the remark of the resolved form' + ); + } +} diff --git a/components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php b/components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php new file mode 100644 index 000000000000..80ba0b6cec57 --- /dev/null +++ b/components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php @@ -0,0 +1,742 @@ +> module => lang_array written to lng_modules */ + private array $lng_modules = []; + /** @var array "module#:#identifier" => local_change written to lng_data */ + private array $local_changes = []; + private ?string $last_lang_array = null; + /** @var array "module#:#identifier" => remarks written to lng_data */ + private array $replaced_remarks = []; + /** @var list lng_data rows _getValues() reads */ + private array $db_rows = []; + /** @var list every writing database call (manipulate/insert/replace), in call order */ + private array $writes = []; + /** Whether the "SELECT lang_array FROM lng_modules" lookup of _deleteValues() answers with $lng_modules_row */ + private bool $answer_lng_modules_lookup = false; + /** @var array{lang_array: mixed}|null the lng_modules row that lookup returns (null = no row) */ + private ?array $lng_modules_row = null; + + protected function setUp(): void + { + parent::setUp(); + + foreach ([ + 'ILIAS_ABSOLUTE_PATH' => realpath(__DIR__ . '/../../../../'), + 'ILIAS_HTTP_PATH' => 'http://localhost', + 'ILIAS_VERSION' => 'test', + ] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } + } + // writes the overlay under CLIENT_DATA_DIR on every test: a foreign CLIENT_DATA_DIR must skip, not be written to + $this->created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + (new ReflectionClass(ilCachedLanguage::class))->getProperty('instances')->setValue(null, []); + MigratedPoFixture::resetRuntime(); + + $this->fixture_directory = __DIR__ . '/tmp-import-fixtures-' . bin2hex(random_bytes(4)); + MigratedPoFixture::writePo( + $this->fixture_directory . '/itest_' . self::LANG . '.po', + MigratedPoFixture::catalog('itest', ['greeting' => 'Aus der PO', 'farewell' => 'Tschüss aus der PO']) + ); + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('itest', 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/') + ) + ); + $this->setGlobalVariable('lng', (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor()); + $this->setGlobalVariable('ilErr', $this->createStub(ilErrorHandling::class)); + $this->setGlobalVariable('ilDB', $this->database()); + + $this->upload_file = $this->fixture_directory . '/ilias_' . self::LANG . '.lang'; + file_put_contents( + $this->upload_file, + "/* header */\n\n" + . "itest#:#greeting#:#Aus der lang-Datei\n" + . "common#:#yes#:#Ja\n" + ); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeDirectory($this->fixture_directory); + MigratedPoFixture::removeDirectory(rtrim(CLIENT_DATA_DIR, '/') . '/lang'); + } + // never remove a foreign CLIENT_DATA_DIR + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects')->setValue(null, []); + + parent::tearDown(); + } + + /** @param array $values "module#:#identifier" => value of lang/ilias_zz.lang */ + private function seedGlobalLanguageFile(array $values): void + { + $global = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor(); + $global->setAllValues($values); + $global->setAllComments([]); + (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects')->setValue(null, [self::LANG => $global]); + } + + private function database(): ilDBInterface + { + $self_check = $this->createStub(ilDBStatement::class); + + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value): string => "'" . (string) $value . "'"); + $db->method('in')->willReturn('1 = 1'); + $lng_modules_lookup = $this->createStub(ilDBStatement::class); + $db->method('query')->willReturnCallback(function (string $sql) use ($lng_modules_lookup): ilDBStatement { + if ($this->answer_lng_modules_lookup && str_starts_with($sql, /** @lang text */ 'SELECT lang_array FROM lng_modules')) { + return $lng_modules_lookup; + } + $rows = str_starts_with($sql, 'SELECT module, identifier, value FROM lng_data') ? $this->db_rows : []; + $statement = $this->createStub(ilDBStatement::class); + $statement->method('fetchRow')->willReturnCallback(static function () use (&$rows): ?array { + return array_shift($rows); + }); + return $statement; + }); + $db->method('queryF')->willReturn($self_check); + $db->method('manipulate')->willReturnCallback(function (string $sql): int { + $this->writes[] = $sql; + return 1; + }); + $db->method('fetchAssoc')->willReturnCallback( + fn(ilDBStatement $statement): ?array => match ($statement) { + $self_check => ['lang_array' => $this->last_lang_array], + $lng_modules_lookup => $this->lng_modules_row, + default => null, + } + ); + $db->method('insert')->willReturnCallback(function (string $table, array $values): int { + $this->writes[] = 'insert ' . $table; + if ($table === 'lng_modules') { + $this->last_lang_array = $values['lang_array'][1]; + $this->lng_modules[$values['module'][1]] = unserialize($values['lang_array'][1]); + } + return 1; + }); + $db->method('replace')->willReturnCallback(function (string $table, array $keys, array $values): int { + $this->writes[] = 'replace ' . $table; + $this->local_changes[$keys['module'][1] . '#:#' . $keys['identifier'][1]] = $values['local_change'][1]; + $this->replaced_remarks[$keys['module'][1] . '#:#' . $keys['identifier'][1]] = $values['remarks'][1] ?? null; + return 1; + }); + + return $db; + } + + private function languageObject(): ilObjLanguageExt + { + $object = (new ReflectionClass(ilObjLanguageExt::class))->newInstanceWithoutConstructor(); + $object->key = self::LANG; + $object->separator = '#:#'; + + return $object; + } + + public function testAnImportWithoutRefreshKeepsTheValuesOfTheFile(): void + { + $this->seedGlobalLanguageFile([]); + + $this->languageObject()->importLanguageFile($this->upload_file, 'replace'); + + $this->assertSame(['greeting' => 'Aus der lang-Datei', 'farewell' => 'Tschüss aus der PO'], $this->lng_modules['itest']); + } + + public function testSaveValuesComparesAMigratedModuleAgainstTheShippedPoNotTheLangFile(): void + { + $this->seedGlobalLanguageFile([ + 'itest#:#greeting' => 'Veraltet in lang/', + 'common#:#yes' => 'Ja', + ]); + $this->db_rows = [ + ['module' => 'itest', 'identifier' => 'greeting', 'value' => 'Alt in der DB'], + ['module' => 'common', 'identifier' => 'yes', 'value' => 'Alt in der DB'], + ]; + MigratedPoFixture::writePair( + $this->overlayDirectory() . '/itest_' . self::LANG, + MigratedPoFixture::catalog('itest', ['greeting' => 'Alt im Overlay']) + ); + + ilObjLanguageExt::_saveValues(self::LANG, [ + 'itest#:#greeting' => 'Aus der PO', + 'common#:#yes' => 'Ja', + ]); + + $this->assertNull($this->local_changes['itest#:#greeting'], 'equals the shipped .po value: not a local change'); + $this->assertNull($this->local_changes['common#:#yes'], 'non-migrated: compared against lang/ as before'); + } + + public function testSaveValuesMarksAValueThatOnlyEqualsTheStaleLangLineAsLocalChange(): void + { + $this->seedGlobalLanguageFile(['itest#:#greeting' => 'Veraltet in lang/']); + $this->db_rows = [['module' => 'itest', 'identifier' => 'greeting', 'value' => 'Alt in der DB']]; + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Veraltet in lang/']); + + $this->assertNotNull($this->local_changes['itest#:#greeting']); + } + + /** The table sends no `#.` note of the shipped .po as remark: an unchanged entry with one is no local change. */ + public function testSavingAnUnchangedEntryWithAShippedNoteIsNoLocalChange(): void + { + $this->seedGlobalLanguageFile([]); + $catalog = MigratedPoFixture::catalog('itest', ['greeting' => 'Aus der PO']); + $catalog->find('itest', 'greeting')?->addExtractedComment('Notiz des Entwicklers'); + MigratedPoFixture::writePo($this->fixture_directory . '/itest_' . self::LANG . '.po', $catalog); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => '']); + $this->assertArrayNotHasKey('itest#:#greeting', $this->local_changes, 'unchanged value and remark: nothing written'); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Geändert'], ['itest#:#greeting' => '']); + $this->assertNotNull($this->local_changes['itest#:#greeting'], 'a changed value'); + } + + public function testSavingALocallyChangedValueBackToTheShippedOneEndsTheLocalChange(): void + { + $this->seedGlobalLanguageFile([]); + MigratedPoFixture::writePair( + $this->overlayDirectory() . '/itest_' . self::LANG, + MigratedPoFixture::catalog('itest', ['greeting' => 'Lokal']) + ); + $manager = $GLOBALS['DIC'][LanguageFileDirectoryManager::class]; + $this->assertTrue(MigratedLanguageFileSync::hasOverlay($manager, self::LANG, 'itest', CLIENT_DATA_DIR), 'precondition'); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => '']); + + $this->assertArrayHasKey('itest#:#greeting', $this->local_changes); + $this->assertNull($this->local_changes['itest#:#greeting']); + $this->assertFalse(MigratedLanguageFileSync::hasOverlay($manager, self::LANG, 'itest', CLIENT_DATA_DIR), 'no delta left'); + } + + public function testChangingOnlyTheRemarkOfAnEntryWithAShippedNoteIsNoLocalChange(): void + { + $this->seedGlobalLanguageFile([]); + $catalog = MigratedPoFixture::catalog('itest', ['greeting' => 'Aus der PO']); + $catalog->find('itest', 'greeting')?->addExtractedComment('Notiz des Entwicklers'); + MigratedPoFixture::writePo($this->fixture_directory . '/itest_' . self::LANG . '.po', $catalog); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => 'Eigene Bemerkung']); + + $this->assertArrayNotHasKey('itest#:#greeting', $this->local_changes); + $this->assertNotEmpty(array_filter($this->writes, static fn(string $sql): bool => str_contains($sql, 'remarks') && str_contains($sql, 'Eigene Bemerkung'))); + } + + private function shipPoWithNote(string $note = 'Notiz des Entwicklers'): void + { + $catalog = MigratedPoFixture::catalog('itest', ['greeting' => 'Aus der PO']); + $catalog->find('itest', 'greeting')?->addExtractedComment($note); + MigratedPoFixture::writePo($this->fixture_directory . '/itest_' . self::LANG . '.po', $catalog); + } + + private function overlayRemark(string $identifier): ?string + { + $manager = $GLOBALS['DIC'][LanguageFileDirectoryManager::class]; + + return (MigratedLanguageFileSync::loadRemarks($manager, self::LANG, 'itest', CLIENT_DATA_DIR) ?? [])[$identifier] ?? null; + } + + public function testSavingTheShippedNoteUnchangedWritesNothing(): void + { + $this->seedGlobalLanguageFile([]); + $this->shipPoWithNote(); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => 'Notiz des Entwicklers']); + + $this->assertSame([], $this->local_changes); + $this->assertSame([], array_filter($this->writes, static fn(string $sql): bool => str_contains($sql, 'remarks'))); + $this->assertNull($this->overlayRemark('greeting')); + } + + public function testSavingALongShippedNoteCutToTheFieldLengthWritesNothing(): void + { + $this->seedGlobalLanguageFile([]); + $note = str_repeat('n', 300); + $this->shipPoWithNote($note); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => substr($note, 0, 250)]); + + $this->assertNull($this->overlayRemark('greeting')); + $this->assertSame([], array_filter($this->writes, static fn(string $sql): bool => str_contains($sql, 'remarks'))); + } + + public function testANewRemarkOverANoteIsStoredLocallyAndInTheOverlay(): void + { + $this->seedGlobalLanguageFile([]); + $this->shipPoWithNote(); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => 'Eigene Bemerkung']); + + $this->assertNotEmpty(array_filter($this->writes, static fn(string $sql): bool => str_contains($sql, 'remarks') && str_contains($sql, 'Eigene Bemerkung'))); + $this->assertSame('Eigene Bemerkung', $this->overlayRemark('greeting')); + } + + public function testAnEmptyRemarkRemovesTheLocalRemarkAndOtherwiseWritesNothing(): void + { + $this->seedGlobalLanguageFile([]); + $this->shipPoWithNote(); + $catalog = MigratedPoFixture::catalog('itest', ['greeting' => 'Aus der PO']); + \ILIAS\Language\ComponentTranslation\LocalChangeComments::setRemark($catalog->find('itest', 'greeting'), 'Lokal'); + MigratedPoFixture::writePair($this->overlayDirectory() . '/itest_' . self::LANG, $catalog); + $this->assertSame('Lokal', $this->overlayRemark('greeting'), 'precondition'); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => '']); + + $this->assertNull($this->overlayRemark('greeting')); + $this->assertNotEmpty(array_filter($this->writes, static fn(string $sql): bool => str_contains($sql, 'remarks'))); + } + + public function testAnEmptyRemarkWithoutALocalOneWritesNothing(): void + { + $this->seedGlobalLanguageFile([]); + $this->shipPoWithNote(); + + ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Aus der PO'], ['itest#:#greeting' => '']); + + $this->assertSame([], array_filter($this->writes, static fn(string $sql): bool => str_contains($sql, 'remarks') || str_contains($sql, 'lng_data'))); + $this->assertSame([], $this->local_changes); + } + + public function testSavingTheNoteOfEveryFormOfAPluralMessageUnchangedWritesNothing(): void + { + $this->seedGlobalLanguageFile([]); + $catalog = MigratedPoFixture::catalog('itest', []); + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry('itest', 'item'); + $entry->setPlural('items', ['Eintrag', 'Einträge']); + $entry->addExtractedComment('Notiz zur Nachricht'); + $catalog->add($entry); + MigratedPoFixture::writePo($this->fixture_directory . '/itest_' . self::LANG . '.po', $catalog); + + ilObjLanguageExt::_saveValues( + self::LANG, + ['itest#:#item [0]' => 'Eintrag', 'itest#:#item [1]' => 'Einträge'], + ['itest#:#item [0]' => 'Notiz zur Nachricht', 'itest#:#item [1]' => 'Notiz zur Nachricht'] + ); + + $this->assertNull($this->overlayRemark('item')); + $this->assertSame([], array_filter($this->writes, static fn(string $sql): bool => str_contains($sql, 'remarks'))); + $this->assertSame([], $this->local_changes); + } + + public function testAnImportedCommentEqualToTheShippedNoteIsNoLocalRemark(): void + { + $this->seedGlobalLanguageFile([]); + $this->shipPoWithNote(); + file_put_contents( + $this->upload_file, + "\nitest#:#greeting#:#Aus der PO###Notiz des Entwicklers\n" + ); + + $this->languageObject()->importLanguageFile($this->upload_file, 'delete'); + + $this->assertNull($this->overlayRemark('greeting')); + $this->assertNotContains('Notiz des Entwicklers', $this->replaced_remarks); + } + + /** "Delete" mode wiped lng_data: every file line is written for a migrated module too, a deviation from the shipped value is a local change. */ + public function testDeleteModeImportWritesEveryRowOfAMigratedModule(): void + { + $this->seedGlobalLanguageFile([]); + MigratedPoFixture::writePair( + $this->overlayDirectory() . '/itest_' . self::LANG, + MigratedPoFixture::catalog('itest', ['greeting' => 'Lokal']) + ); + file_put_contents( + $this->upload_file, + "\nitest#:#greeting#:#Lokal\nitest#:#farewell#:#Tschüss aus der PO\n" + ); + + $this->languageObject()->importLanguageFile($this->upload_file, 'delete'); + + $this->assertNotNull($this->local_changes['itest#:#greeting'] ?? null, 'differs from the shipped value'); + $this->assertArrayHasKey('itest#:#farewell', $this->local_changes, 'equal to the shipped value, written anyway'); + $this->assertNull($this->local_changes['itest#:#farewell']); + } + + public static function importModes(): array + { + return [ + 'keepall' => ['keepall'], + 'keepnew' => ['keepnew'], + 'replace' => ['replace'], + 'delete' => ['delete'], + ]; + } + + public function testAnImportWithoutRefreshIsNotBlockedByAnUnreadableShippedPo(): void + { + $this->seedGlobalLanguageFile([]); + file_put_contents($this->fixture_directory . '/itest_' . self::LANG . '.po', "msgid \"kaputt\n"); + $this->stubLogger(); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $this->languageObject()->importLanguageFile($this->upload_file, 'replace'); + } finally { + restore_error_handler(); + } + + $this->assertSame(['greeting' => 'Aus der lang-Datei'], $this->lng_modules['itest']); + } + + private function overlayDirectory(): string + { + return rtrim(CLIENT_DATA_DIR, '/') . '/lang/itest/' . self::LANG; + } + + /** Root-proof: a regular file occupies the overlay directory, so no user can create it. Removed by the returned closure. */ + private function blockOverlayDirectory(): \Closure + { + $blocked = $this->overlayDirectory(); + if (!is_dir(dirname($blocked))) { + mkdir(dirname($blocked), 0775, true); + } + file_put_contents($blocked, 'not a directory'); + + return static function () use ($blocked): void { + if (is_file($blocked)) { + unlink($blocked); + } + }; + } + + private function stubLogger(): ilLogger&\PHPUnit\Framework\MockObject\Stub + { + $logger = $this->createStub(ilLogger::class); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + + return $logger; + } + + /** The database is written regardless (dual-write), the module whose overlay failed is returned by the import and _saveValues(). */ + public function testAnImportReturnsTheMigratedModuleWhoseOverlayCouldNotBeWritten(): void + { + $this->seedGlobalLanguageFile([]); + $this->stubLogger(); + $unblock = $this->blockOverlayDirectory(); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $unwritten = $this->languageObject()->importLanguageFile($this->upload_file, 'replace'); + } finally { + restore_error_handler(); + $unblock(); + } + + $this->assertSame(['itest'], $unwritten); + $this->assertSame(['yes' => 'Ja'], $this->lng_modules['common'], 'the database is written regardless'); + $this->assertSame( + ['farewell' => 'Tschüss aus der PO', 'greeting' => 'Aus der lang-Datei'], + $this->sorted($this->lng_modules['itest']) + ); + } + + public function testSaveValuesReturnsOnlyTheMigratedModuleWhoseOverlayCouldNotBeWrittenAndLogsIt(): void + { + $this->seedGlobalLanguageFile(['common#:#yes' => 'Ja']); + $logger = $this->createMock(ilLogger::class); + $logger->expects($this->once())->method('warning')->with($this->stringContains('"itest"')); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + $unblock = $this->blockOverlayDirectory(); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $unwritten = ilObjLanguageExt::_saveValues(self::LANG, [ + 'itest#:#greeting' => 'Servus', + 'common#:#yes' => 'Jawohl', + ]); + } finally { + restore_error_handler(); + $unblock(); + } + + $this->assertSame(['itest'], $unwritten); + $this->assertSame( + ['greeting' => 'Servus', 'farewell' => 'Tschüss aus der PO'], + $this->lng_modules['itest'], + 'the database is written regardless' + ); + $this->assertSame(['yes' => 'Jawohl'], $this->lng_modules['common']); + } + + public function testSaveValuesReturnsNothingWhenEveryOverlayWasWritten(): void + { + $this->seedGlobalLanguageFile([]); + + $this->assertSame([], ilObjLanguageExt::_saveValues(self::LANG, ['itest#:#greeting' => 'Servus', 'common#:#yes' => 'Ja'])); + $this->assertFileExists(MigratedPoFixture::overlayMo($this->overlayDirectory() . '/itest_' . self::LANG)); + } + + /** replaceLangModule() reports an overlay failure as `false` (database written), `true` for a non-PO module. */ + public function testReplaceLangModuleReturnsFalseOnlyForAMigratedModuleWhoseOverlayCouldNotBeWritten(): void + { + $this->stubLogger(); + $unblock = $this->blockOverlayDirectory(); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $migrated = ilObjLanguage::replaceLangModule(self::LANG, 'itest', ['greeting' => 'Servus']); + $not_migrated = ilObjLanguage::replaceLangModule(self::LANG, 'common', ['yes' => 'Ja']); + } finally { + restore_error_handler(); + $unblock(); + } + + $this->assertFalse($migrated); + $this->assertTrue($not_migrated); + $this->assertSame(['greeting' => 'Servus'], $this->lng_modules['itest'], 'the database is written regardless'); + + $this->assertTrue(ilObjLanguage::replaceLangModule(self::LANG, 'itest', ['greeting' => 'Servus']), 'writable again'); + } + + /** The GUI's "clear" asks getUnreadableShippedPoModules() after the exception: answered from the per-object cache, without parsing and logging again. */ + + /** @param list $warnings collects every PHP warning/notice raised during $action */ + private function collectingWarnings(array &$warnings, \Closure $action): mixed + { + set_error_handler(static function (int $errno, string $message) use (&$warnings): bool { + $warnings[] = $message; + return true; + }); + try { + return $action(); + } finally { + restore_error_handler(); + } + } + + /** Without an lng_modules row the module is rewritten empty: entries to delete are not written back, no warning. */ + public function testDeleteValuesWithoutLngModulesRowWritesAnEmptyModuleWithoutWarning(): void + { + $this->answer_lng_modules_lookup = true; + $this->lng_modules_row = null; + $warnings = []; + + $unwritten = $this->collectingWarnings( + $warnings, + static fn(): array => ilObjLanguageExt::_deleteValues(self::LANG, ['common#:#yes' => 'Ja', 'common#:#no' => 'Nein']) + ); + + $this->assertSame([], $this->lng_modules['common']); + $this->assertSame([], $unwritten); + $this->assertSame([], $warnings); + } + + public function testDeleteValuesWithAnUnreadableLngModulesRowWritesAnEmptyModule(): void + { + $this->answer_lng_modules_lookup = true; + $this->lng_modules_row = ['lang_array' => null]; + $warnings = []; + + $this->collectingWarnings( + $warnings, + static fn(): array => ilObjLanguageExt::_deleteValues(self::LANG, ['common#:#yes' => 'Ja']) + ); + + $this->assertSame([], $this->lng_modules['common']); + $this->assertSame([], $warnings); + } + + public function testDeleteValuesKeepsTheRemainingEntriesOfTheLngModulesRow(): void + { + $this->answer_lng_modules_lookup = true; + $this->lng_modules_row = ['lang_array' => serialize(['yes' => 'Ja', 'no' => 'Nein', 'maybe' => 'Vielleicht'])]; + + $unwritten = ilObjLanguageExt::_deleteValues(self::LANG, ['common#:#yes' => 'Ja', 'common#:#unknown' => 'x']); + + $this->assertSame(['no' => 'Nein', 'maybe' => 'Vielleicht'], $this->lng_modules['common']); + $this->assertSame([], $unwritten); + } + + public function testDeleteValuesReturnsTheMigratedModuleWhoseOverlayCouldNotBeWritten(): void + { + $this->stubLogger(); + $this->answer_lng_modules_lookup = true; + $this->lng_modules_row = ['lang_array' => serialize(['greeting' => 'Hallo', 'farewell' => 'Tschüss'])]; + $unblock = $this->blockOverlayDirectory(); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $unwritten = ilObjLanguageExt::_deleteValues(self::LANG, [ + 'itest#:#farewell' => 'Tschüss', + 'common#:#yes' => 'Ja', + ]); + } finally { + restore_error_handler(); + $unblock(); + } + + $this->assertSame([], $unwritten); + $this->assertSame(['greeting' => 'Aus der PO'], $this->lng_modules['itest'], 'the database is written regardless'); + } + + /** + * @param array $values + * @return array + */ + private function sorted(array $values): array + { + ksort($values); + return $values; + } + + /** One valid and one invalid entry, both new keys, so both count as "changed" and the invalid one is checked. */ + private function writeUploadFileWithOneInvalidEntry(): string + { + $file = $this->fixture_directory . '/ilias_invalid_' . self::LANG . '.lang'; + file_put_contents( + $file, + "/* header */\n\n" + . "common#:#yes#:#Ja\n" + . "common#:#bad#:#\n" + ); + + return $file; + } + + /** "delete" mode wipes lng_data/lng_modules up front, so the markup check must run before: a rejected value leaves the database untouched. */ + #[DataProvider('importModes')] + public function testInvalidMarkupAbortsBeforeAnyChangeIncludingTheDeleteModeWipe(string $mode): void + { + $this->seedGlobalLanguageFile([]); + $file = $this->writeUploadFileWithOneInvalidEntry(); + + try { + $this->languageObject()->importLanguageFile($file, $mode); + $this->fail('Expected an ilLanguageInvalidMarkupException'); + } catch (ilLanguageInvalidMarkupException $e) { + $this->assertSame(['common#:#bad'], array_keys($e->getInvalidValues())); + $this->assertStringNotContainsString('alert(1)', $e->getMessage()); + $this->assertStringNotContainsString('script', $e->getMessage()); + } + + $this->assertSame([], $this->writes, 'no database write at all - not even the "delete" mode wipe'); + $this->assertSame([], $this->lng_modules); + } + + /** With $skipInvalidMarkup (customizing-file/installation path) the invalid entry is left out and reported, no exception. */ + public function testSkipInvalidMarkupImportsOnlyTheValidEntriesAndReportsTheSkippedOne(): void + { + $this->seedGlobalLanguageFile([]); + $file = $this->writeUploadFileWithOneInvalidEntry(); + + $object = $this->languageObject(); + $object->importLanguageFile($file, 'replace', true); + + $this->assertSame('Ja', $this->lng_modules['common']['yes']); + $this->assertArrayNotHasKey('bad', $this->lng_modules['common']); + $skipped = $object->getSkippedInvalidMarkupValues(); + $this->assertArrayHasKey('common#:#bad', $skipped); + $this->assertNotEmpty($skipped['common#:#bad']); + } + + /** In "keepall"/"keepnew" a kept key must not block the import because the file carries a disallowed value for it: that value is never applied. */ + public function testAnInvalidFileValueForAKeyThatModeKeepsDoesNotBlockTheImport(): void + { + $this->seedGlobalLanguageFile(['common#:#yes' => 'Ja']); + $this->db_rows = [['module' => 'common', 'identifier' => 'yes', 'value' => 'Aus der Datenbank']]; + $file = $this->fixture_directory . '/ilias_kept_' . self::LANG . '.lang'; + file_put_contents( + $file, + "/* header */\n\n" + . "common#:#yes#:#\n" + ); + + $this->languageObject()->importLanguageFile($file, 'keepall'); + + $this->assertArrayNotHasKey('common', $this->lng_modules, 'the already-correct, kept value needs no rewrite'); + } + + private function directoryManager(): LanguageFileDirectoryManager + { + return new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('itest', 'components/ILIAS/Language/tests/' . basename((string) $this->fixture_directory) . '/') + ); + } + + /** @return array */ + private function loadRemarks(): array + { + return \ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync::loadRemarks( + $this->directoryManager(), + self::LANG, + 'itest', + CLIENT_DATA_DIR, + ILIAS_ABSOLUTE_PATH + ) ?? []; + } + + /** A "###" line comment of a migrated module becomes a remark like for any module; importLanguageFile() has no "clear" branch that strips it. */ + public function testImportTakesOverALangLineCommentOfAMigratedModuleAsARemark(): void + { + $this->seedGlobalLanguageFile([]); + file_put_contents( + $this->upload_file, + "/* header */\n\n" + . "itest#:#greeting#:#Aus der lang-Datei###Bitte pruefen\n" + . "common#:#yes#:#Ja\n" + ); + + $this->languageObject()->importLanguageFile($this->upload_file, 'replace', false); + + $this->assertSame('Bitte pruefen', $this->loadRemarks()['greeting'] ?? null); + } +} diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index f5341de7af94..d1c88893c9fe 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -392,6 +392,19 @@ public function testSetupAgentActivitiesDoNotUseThePulledConnection(string $clas ); } + /** Activities write through an installation manager that invalidates the global language cache (a no-op without cache). */ + public function testTheRuntimeInstallationManagerHasATranslationCacheThatIsSafeWithoutGlobalCache(): void + { + [, , , , , $provide] = $this->initComponent(); + $setup_language = self::readPrivateProperty($provide[InstallLanguage::class], 'setup_language'); + $manager = (new \ReflectionMethod($setup_language, 'manager'))->invoke($setup_language); + + $translation_cache = self::readPrivateProperty($manager, 'translation_cache'); + + self::assertInstanceOf(\ILIAS\Language\Setup\TranslationCacheLegacyProxy::class, $translation_cache); + $translation_cache->invalidate('de'); + } + private static function readPrivateProperty(object $object, string $property): mixed { return (new \ReflectionProperty($object, $property))->getValue($object); diff --git a/components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php b/components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php new file mode 100644 index 000000000000..c644fded978f --- /dev/null +++ b/components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php @@ -0,0 +1,207 @@ + the warnings the component logger received */ + private array $warnings = []; + + protected function setUp(): void + { + parent::setUp(); + + if (!defined('ILIAS_ABSOLUTE_PATH')) { + define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../')); + } + if (!defined('ILIAS_LOG_ENABLED')) { + define('ILIAS_LOG_ENABLED', false); + } + MigratedPoFixture::resetRuntime(); + MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + $this->fixture_directory = __DIR__ . '/tmp-mixed-modules-' . bin2hex(random_bytes(4)); + $relative_path = $this->relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + // mig_a: shared/only_a in de, shared/other in en (so "other" is listed but not translated in de); mig_b: shared and other in de + MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'de', MigratedPoFixture::catalog('mig_a', ['shared' => 'Mig A', 'only_a' => 'A'])); + MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'en', MigratedPoFixture::catalog('mig_a', ['shared' => 'Mig A en', 'other' => 'Other en'])); + MigratedPoFixture::writeShippedPo($relative_path, 'mig_b', 'de', MigratedPoFixture::catalog('mig_b', ['shared' => 'Mig B', 'other' => 'Mig B other'])); + + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('mig_a', $relative_path), + MigratedPoFixture::directory('mig_b', $relative_path) + ) + ); + $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + $logger = $this->createStub(ilLogger::class); + $logger->method('warning')->willReturnCallback(function (string $message): void { + $this->warnings[] = $message; + }); + $factory = $this->createStub(ilLoggerFactory::class); + $factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $factory); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . basename($this->fixture_directory)); + } + if (defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** @param array $legacy_values what lng_modules holds for the module "legacy" in the language */ + private function language(array $legacy_values = [], string $lang_key = 'de'): ilLanguage + { + global $DIC; + MigratedPoFixture::build($DIC[LanguageFileDirectoryManager::class], (string) ILIAS_ABSOLUTE_PATH); + $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $reflected = new ReflectionObject($language); + foreach (['lang_key', 'lang_user', 'lang_default'] as $property) { + $reflected->getProperty($property)->setValue($language, $lang_key); + } + $reflected->getProperty('log')->setValue($language, $this->createStub(ilLogger::class)); + $reflected->getProperty('cached_modules')->setValue($language, ['legacy' => $legacy_values]); + + return $language; + } + + /** @return iterable, string}> */ + public static function loadOrders(): iterable + { + yield 'migrated, then legacy' => [['mig_a', 'legacy'], 'Legacy']; + yield 'legacy, then migrated' => [['legacy', 'mig_a'], 'Mig A']; + yield 'migrated, legacy, another migrated' => [['mig_a', 'legacy', 'mig_b'], 'Mig B']; + yield 'legacy, migrated, migrated' => [['legacy', 'mig_a', 'mig_b'], 'Mig B']; + yield 'migrated, migrated, legacy' => [['mig_b', 'mig_a', 'legacy'], 'Legacy']; + } + + /** @param list $order */ + #[DataProvider('loadOrders')] + public function testTheModuleLoadedLastWinsAlsoAcrossMigratedAndLegacyModules(array $order, string $expected): void + { + $language = $this->language(['shared' => 'Legacy', 'only_legacy' => 'L']); + + foreach ($order as $module) { + $language->loadLanguageModule($module); + } + + $this->assertSame($expected, $language->txt('shared')); + $this->assertTrue($language->exists('shared')); + $this->assertSame('L', $language->txt('only_legacy'), 'what only the legacy module has stays'); + $this->assertSame(in_array('mig_a', $order, true) ? 'A' : '-only_a-', $language->txt('only_a'), 'what only a migrated module has stays'); + $this->assertSame([], $this->warnings); + } + + /** Unlike the .lang/database path (array_merge() kept the earlier value) an identifier listed but untranslated in the last module gives "-key-", without a warning. */ + public function testAnIdentifierTheMigratedModuleLoadedLastDoesNotTranslateIsMissingEvenIfALegacyModuleBeforeHasIt(): void + { + $language = $this->language(['other' => 'Legacy other']); + + $language->loadLanguageModule('legacy'); + $language->loadLanguageModule('mig_a'); + + $this->assertSame('-other-', $language->txt('other'), 'listed by mig_a (it is translated in en), not translated in de'); + $this->assertFalse($language->exists('other')); + $this->assertSame([], $this->warnings, 'an untranslated key is no problem'); + } + + public function testAnIdentifierTheMigratedModuleLoadedLastDoesNotTranslateIsMissingEvenIfAMigratedModuleBeforeHasIt(): void + { + $language = $this->language(); + + $language->loadLanguageModule('mig_b'); + $this->assertSame('Mig B other', $language->txt('other'), 'precondition'); + $language->loadLanguageModule('mig_a'); + + $this->assertSame('-other-', $language->txt('other')); + $this->assertFalse($language->exists('other')); + $this->assertSame([], $this->warnings); + } + + /** A module shipping a language the build lacks (no "setup build" since) has no identifiers and is reported once per module, language and request. */ + public function testWhetherALanguageIsShippedWithoutBuildIsDecidedOncePerRequest(): void + { + $this->language(); + $migrated_keys_of = new ReflectionMethod(ilLanguage::class, 'migratedKeysOf'); + MigratedPoFixture::writeShippedPo($this->relative_path, 'mig_a', 'it', MigratedPoFixture::catalog('mig_a', ['shared' => 'Mig A it'])); + + $this->assertSame([], $migrated_keys_of->invoke(null, 'mig_a', 'it'), 'shipped, not built: migrated without any identifier'); + $this->assertNull($migrated_keys_of->invoke(null, 'mig_a', 'fr'), 'not shipped: not migrated'); + $this->assertCount(1, $this->warnings); + $this->assertStringContainsString('ships a language file for "it" that is not built', $this->warnings[0]); + + MigratedPoFixture::writeShippedPo($this->relative_path, 'mig_a', 'fr', MigratedPoFixture::catalog('mig_a', ['shared' => 'Mig A fr'])); + unlink(ILIAS_ABSOLUTE_PATH . '/' . $this->relative_path . 'mig_a_it.po'); + + $this->assertNull($migrated_keys_of->invoke(null, 'mig_a', 'fr'), 'decided before the file appeared'); + $this->assertSame([], $migrated_keys_of->invoke(null, 'mig_a', 'it'), 'decided before the file vanished'); + $this->assertCount(1, $this->warnings, 'reported once'); + } + + public function testAnIdentifierNobodyTranslatesIsMissingWithoutAWarning(): void + { + $language = $this->language(); + + $language->loadLanguageModule('mig_a'); + + $this->assertSame('-other-', $language->txt('other')); + $this->assertFalse($language->exists('other')); + $this->assertSame('-unknown-', $language->txt('unknown')); + $this->assertSame([], $this->warnings); + } + + public function testTxtlngAndLookupEntryReadAnyLanguageIndependentOfTheInstance(): void + { + $language = $this->language(); + $language->loadLanguageModule('mig_a'); + + $this->assertSame('Mig A en', $language->txtlng('mig_a', 'shared', 'en')); + $this->assertSame('Other en', ilLanguage::_lookupEntry('en', 'mig_a', 'other')); + $this->assertSame('Mig A', $language->txtlng('mig_a', 'shared', 'de')); + $this->assertSame('Mig A', ilLanguage::_lookupEntry('de', 'mig_a', 'shared')); + $this->assertSame('-other-', ilLanguage::_lookupEntry('de', 'mig_a', 'other'), 'listed, not translated in de'); + $this->assertSame('-nokey-', ilLanguage::_lookupEntry('de', 'mig_a', 'nokey')); + $this->assertSame('Mig B', ilLanguage::_lookupEntry('de', 'mig_b', 'shared'), 'a module that was never loaded in this instance'); + $this->assertSame([], $this->warnings); + } +} diff --git a/components/ILIAS/Language/tests/MigratedPoFixture.php b/components/ILIAS/Language/tests/MigratedPoFixture.php new file mode 100644 index 000000000000..4a771edad010 --- /dev/null +++ b/components/ILIAS/Language/tests/MigratedPoFixture.php @@ -0,0 +1,444 @@ + */ + private static array $artifact_directories = []; + + /** + * @param array $entries identifier => value or details. + * 'context' defaults to $module, 'original' seeds a LocalChangeComments "original", + * 'local_change' a raw "local_change: " comment, 'fuzzy' the fuzzy flag. + */ + public static function catalog(string $module, array $entries): TranslationCatalog + { + $catalog = new TranslationCatalog(); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + foreach ($entries as $identifier => $details) { + $details = is_array($details) ? $details : ['value' => $details]; + $entry = new TranslationEntry(array_key_exists('context', $details) ? $details['context'] : $module, (string) $identifier); + $entry->translate($details['value']); + if (isset($details['original'])) { + LocalChangeComments::setOriginal($entry, $details['original']); + } + if (isset($details['local_change'])) { + $entry->addTranslatorComment('local_change: ' . $details['local_change']); + } + if (!empty($details['fuzzy'])) { + $entry->addFlag('fuzzy'); + } + $catalog->add($entry); + } + + return $catalog; + } + + public static function entry(?string $context, string $id, string $value): TranslationEntry + { + $entry = new TranslationEntry($context, $id); + $entry->translate($value); + + return $entry; + } + + public static function writePo(string $file, TranslationCatalog $catalog): void + { + self::ensureDirectory(dirname($file)); + file_put_contents($file, $catalog->toPoString()); + } + + public static function writeMo(string $file, TranslationCatalog $catalog): void + { + self::ensureDirectory(dirname($file)); + file_put_contents($file, $catalog->toMoString()); + } + + /** Writes ".po" and, like MigratedLanguageFileSync::sync(), its compiled revision named by `current`. */ + public static function writePair(string $base_path, TranslationCatalog $catalog): void + { + self::writePo($base_path . '.po', $catalog); + self::writeOverlayRevision($base_path, $catalog); + } + + /** Compiles $catalog into a revision of the overlay $base_path and points `current` to it, like sync() without the `.po`. */ + public static function writeOverlayRevision(string $base_path, TranslationCatalog $catalog): void + { + $overlay_directory = dirname($base_path); + $lang_key = basename($overlay_directory); + $module = basename(dirname($overlay_directory)); + $compiled = ShippedTranslations::compileCatalog($catalog, $module, false); + $keys_json = json_encode(array_map('strval', array_keys($compiled['values'])), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n"; + $revision = 'r-' . substr(hash('sha256', $compiled['mo'] . "\0" . $keys_json), 0, 32); + $revision_directory = MigratedLanguageFilePaths::overlayRevisionDirectory($overlay_directory, $revision); + $mo_file = MigratedLanguageFilePaths::overlayMoFile($revision_directory, $module, $lang_key); + self::ensureDirectory(dirname($mo_file)); + file_put_contents($mo_file, $compiled['mo']); + file_put_contents(MigratedLanguageFilePaths::overlayKeysFile($revision_directory), $keys_json); + file_put_contents(MigratedLanguageFilePaths::overlayCurrentFile($overlay_directory), $revision . "\n"); + } + + /** The overlay of $directory's module for $lang_key without the `.po` extension. */ + public static function overlayBase(string $client_data_dir, LanguageFileDirectory $directory, string $lang_key): string + { + return substr(MigratedLanguageFilePaths::overlayPoFile($client_data_dir, $directory, $lang_key), 0, -strlen('.po')); + } + + /** The compiled catalog of the revision `current` names; a non-existing path if there is none. */ + public static function overlayMo(string $base_path): string + { + $overlay_directory = dirname($base_path); + $current = MigratedLanguageFilePaths::overlayCurrentFile($overlay_directory); + $revision = is_file($current) ? trim((string) file_get_contents($current)) : ''; + if (!MigratedLanguageFilePaths::isOverlayRevision($revision)) { + return $overlay_directory . '/no-current-revision.mo'; + } + + return MigratedLanguageFilePaths::overlayMoFile( + MigratedLanguageFilePaths::overlayRevisionDirectory($overlay_directory, $revision), + basename(dirname($overlay_directory)), + basename($overlay_directory) + ); + } + + public static function overlayCurrent(string $base_path): string + { + return MigratedLanguageFilePaths::overlayCurrentFile(dirname($base_path)); + } + + public static function overlayLock(string $base_path): string + { + return MigratedLanguageFilePaths::overlayLockFile(dirname($base_path)); + } + + public static function readPo(string $file): TranslationCatalog + { + return TranslationCatalog::fromPoFile($file); + } + + /** + * @return array identifier => translation of every message without context of the `.mo` $file, + * as native gettext serves it for txt() (a plural message under its identifier with its default form) + */ + public static function readMo(string $file): array + { + $result = []; + foreach (self::moStrings((string) file_get_contents($file)) as [$original, $translated]) { + if ($original === '' || str_contains($original, "\x04")) { + continue; + } + $result[explode("\0", $original, 2)[0]] = explode("\0", $translated, 2)[0]; + } + + return $result; + } + + /** + * Every message of the `.mo` $data as [original, translated], header included (original ""): + * "\x04", "\0", forms separated by NUL. + * + * @return list + */ + public static function moStrings(string $data): array + { + $format = unpack('V', $data)[1] === 0x950412de ? 'V' : 'N'; + ['count' => $count, 'originals' => $originals, 'translations' => $translations] = unpack( + $format . 'count/' . $format . 'originals/' . $format . 'translations', + $data, + 8 + ); + $strings = []; + for ($i = 0; $i < $count; $i++) { + ['length' => $length, 'offset' => $offset] = unpack($format . 'length/' . $format . 'offset', $data, $originals + $i * 8); + $original = substr($data, $offset, $length); + ['length' => $length, 'offset' => $offset] = unpack($format . 'length/' . $format . 'offset', $data, $translations + $i * 8); + $strings[] = [$original, substr($data, $offset, $length)]; + } + + return $strings; + } + + /** + * Messages of the `.mo` $data by identifier (contexts dropped), a plural message with its default form + * (PluralForms, rule from the "Plural-Forms" header of $data). + * + * @return array + */ + public static function readMoTranslations(string $data): array + { + $plural_forms = PluralForms::fromHeaderOrGermanic(self::moPluralFormsHeader($data)); + $result = []; + foreach (self::moStrings($data) as [$original, $translated]) { + if ($original === '') { + continue; + } + $id_and_plural = explode("\0", explode("\x04", $original, 2)[1] ?? $original, 2); + $result[$id_and_plural[0]] = isset($id_and_plural[1]) + ? $plural_forms->defaultValueOf(explode("\0", $translated)) + : explode("\0", $translated, 2)[0]; + } + + return $result; + } + + /** + * Every form of the plural messages of the `.mo` $data by identifier (contexts dropped). + * + * @return array> + */ + public static function readMoPluralForms(string $data): array + { + $result = []; + foreach (self::moStrings($data) as [$original, $translated]) { + $id_and_plural = explode("\0", explode("\x04", $original, 2)[1] ?? $original, 2); + if ($original !== '' && isset($id_and_plural[1])) { + $result[$id_and_plural[0]] = explode("\0", $translated); + } + } + + return $result; + } + + /** The "Plural-Forms" header of the `.mo` $data, `null` without. */ + public static function moPluralFormsHeader(string $data): ?string + { + foreach (self::moStrings($data) as [$original, $translated]) { + if ($original === '' && preg_match('/^Plural-Forms:\s*(.*)$/mi', $translated, $matches) === 1) { + return trim($matches[1]); + } + } + + return null; + } + + public static function artifactDirectory(): string + { + // per process: two PHPUnit runs at once (e.g. PHP 8.4 and 8.5 on one host) must not empty each other's build + return sys_get_temp_dir() . '/ilias_lang_test_artifacts_' . getmypid() . '_' . self::$artifact_generation; + } + + /** Forgets the static state of the runtime read path and points build and runtime at $artifact_directory (default: the emptied temporary one), never at the installation's artifacts. */ + public static function resetRuntime(?string $artifact_directory = null): void + { + if ($artifact_directory === null) { + self::removeDirectory(self::artifactDirectory()); + self::$artifact_generation++; + self::$artifact_directories[] = self::artifactDirectory(); + if (!self::$artifact_cleanup_registered) { + self::$artifact_cleanup_registered = true; + register_shutdown_function(static function (): void { + array_map(self::removeDirectory(...), self::$artifact_directories); + }); + } + } + (new ReflectionProperty(ilLanguage::class, 'installed_languages'))->setValue(null, null); + (new ReflectionProperty(ilLanguage::class, 'shipped_without_build'))->setValue(null, []); + MigratedLanguageFilePaths::useArtifactDirectoryForTests($artifact_directory ?? self::artifactDirectory()); + MigratedTranslations::resetForTests(); + NativeGettext::resetForTests(); + } + + /** Moves the builds to a directory this process has not looked at: the C library keeps what it read from a path (mapped, not copied), so a file damaged in place must not be one it read. */ + public static function moveArtifactsToFreshDirectory(): void + { + $old = self::artifactDirectory(); + self::$artifact_generation++; + $new = self::artifactDirectory(); + self::$artifact_directories[] = $new; + if (!rename($old, $new)) { + throw new RuntimeException('Cannot move "' . $old . '" to "' . $new . '".'); + } + MigratedLanguageFilePaths::useArtifactDirectoryForTests($new); + MigratedTranslations::resetForTests(); + } + + /** + * What the runtime serves for $module/$lang_key (identifier => value), `null` if the module is not migrated for it, + * after building the shipped `.po` files of the LanguageFileDirectoryManager in $DIC with $build. + * + * @return array|null + */ + public static function servedTexts(string $module, string $lang_key, bool $build = true): ?array + { + global $DIC; + if ($build && isset($DIC) && $DIC->offsetExists(LanguageFileDirectoryManager::class)) { + self::build($DIC[LanguageFileDirectoryManager::class], (string) ILIAS_ABSOLUTE_PATH); + } + $keys = (new ReflectionMethod(ilLanguage::class, 'migratedKeysOf'))->invoke(null, $module, $lang_key); + if ($keys === null) { + return null; + } + $texts = []; + foreach (array_keys($keys) as $key) { + $value = MigratedTranslations::text($module, $lang_key, (string) $key, defined('CLIENT_DATA_DIR') ? (string) CLIENT_DATA_DIR : null); + if ($value !== null) { + $texts[(string) $key] = $value; + } + } + + return $texts; + } + + /** + * Builds the shipped `.po` files of $manager below $ilias_absolute_path into the artifact directory of resetRuntime(). + * + * @return array{build: ?string, compiled: int, unchanged: bool, removed: int, warnings: list, collisions: list} + */ + public static function build(LanguageFileDirectoryManager $manager, string $ilias_absolute_path): array + { + $result = (new ShippedTranslationsBuild($manager, $ilias_absolute_path))->run(static function (string $message): void { + }); + MigratedTranslations::resetForTests(); + + return $result; + } + + public static function directory(string $prefix, string $path, bool $local = false): LanguageFileDirectory + { + return new class ($prefix, $path, $local) implements LanguageFileDirectory { + public function __construct( + private readonly string $prefix, + private readonly string $path, + private readonly bool $local + ) { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return $this->local ? '.local' : ''; + } + + public function isLocal(): bool + { + return $this->local; + } + }; + } + + /** Writes $catalog as the shipped `.po`: a module only counts as migrated while that exists. Remove with removeShippedDirectory(). */ + public static function writeShippedPo( + string $relative_path, + string $module, + string $lang_key, + TranslationCatalog $catalog + ): void { + self::writePo(self::shippedDirectory($relative_path) . '/' . $module . '_' . $lang_key . '.po', $catalog); + } + + public static function removeShippedDirectory(string $relative_path): void + { + self::removeDirectory(self::shippedDirectory($relative_path)); + } + + private static function shippedDirectory(string $relative_path): string + { + $directory = rtrim((string) ILIAS_ABSOLUTE_PATH, '/') . '/' . trim($relative_path, '/'); + self::ensureDirectory($directory); + + return $directory; + } + + /** + * Guards every fixture that writes under CLIENT_DATA_DIR: a full-suite run may have defined it as the real + * /var/iliasdata (a constant cannot be redefined), so a foreign non-temp CLIENT_DATA_DIR is never written into + * and never deleted: the test skips instead. + * + * @return bool true only if this call defined CLIENT_DATA_DIR (the caller then owns the root and may remove it + * in tearDown()); false if it was already defined + */ + public static function ensureClientDataDirDefinedOrSkip(\PHPUnit\Framework\TestCase $test): bool + { + if (defined('CLIENT_DATA_DIR') && !str_starts_with(CLIENT_DATA_DIR, sys_get_temp_dir() . '/')) { + $test->markTestSkipped( + 'CLIENT_DATA_DIR ("' . CLIENT_DATA_DIR . '") is not a test-owned temp directory - ' + . 'refusing to write fixture files there.' + ); + } + if (!defined('CLIENT_DATA_DIR')) { + define('CLIENT_DATA_DIR', sys_get_temp_dir() . '/ilias_lang_test_client_data_dir_' . getmypid()); + return true; + } + return false; + } + + public static function removeDirectory(string $directory): void + { + if (is_link($directory)) { + unlink($directory); + return; + } + if (!is_dir($directory)) { + return; + } + @chmod($directory, 0775); + foreach (scandir($directory) ?: [] as $name) { + if ($name === '.' || $name === '..') { + continue; + } + $path = $directory . '/' . $name; + if (is_dir($path) && !is_link($path)) { + self::removeDirectory($path); + } else { + @chmod($path, 0664); + unlink($path); + } + } + rmdir($directory); + } + + private static function ensureDirectory(string $directory): void + { + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + } +} diff --git a/components/ILIAS/Language/tests/PoLastLocalChangeTest.php b/components/ILIAS/Language/tests/PoLastLocalChangeTest.php new file mode 100644 index 000000000000..80328ed9cf52 --- /dev/null +++ b/components/ILIAS/Language/tests/PoLastLocalChangeTest.php @@ -0,0 +1,376 @@ +markTestSkipped( + 'CLIENT_DATA_DIR ("' . CLIENT_DATA_DIR . '") is not a test-owned temp directory - ' + . 'refusing to write fixture files there.' + ); + } + if (!defined('CLIENT_DATA_DIR')) { + define('CLIENT_DATA_DIR', sys_get_temp_dir() . '/ilias_lang_test_client_data_dir_' . getmypid()); + $this->created_client_data_dir_root = true; + } + } + + protected function tearDown(): void + { + if (isset($this->fixture_directory) && is_dir($this->fixture_directory)) { + array_map('unlink', glob($this->fixture_directory . '/*') ?: []); + rmdir($this->fixture_directory); + } + if (isset($this->fixture_directory)) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . basename($this->fixture_directory)); + } + // never remove a foreign CLIENT_DATA_DIR + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** + * Writes a .po/.mo pair for a throwaway module at the overlay location under CLIENT_DATA_DIR, the only one + * _getLastMigratedLocalChange() reads. + * + * @param array $entries keyed by identifier; + * 'original' seeds LocalChangeComments::setOriginal() as baseline for refresh(). + * @param array $local_changes identifier => ISO-8601 UTC local_change timestamp + * ('Y-m-d\TH:i:s\Z') stamped via LocalChangeComments::refresh(), simulating a direct .po edit. + */ + private function seedFixtureModule( + string $module, + string $lang_key, + array $entries, + array $local_changes = [] + ): LanguageFileDirectory { + $this->guardClientDataDirIsTestOwned(); + $this->fixture_directory ??= rtrim(CLIENT_DATA_DIR, '/') . '/lang/components/ILIAS/Language/tests/' + . 'tmp-lastchange-fixtures-' . bin2hex(random_bytes(4)); + if (!is_dir($this->fixture_directory)) { + mkdir($this->fixture_directory, 0775, true); + } + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + foreach ($entries as $identifier => $entry) { + $translation = MigratedPoFixture::entry($module, $identifier, $entry['value']); + if (isset($entry['original'])) { + LocalChangeComments::setOriginal($translation, $entry['original']); + } + if (isset($local_changes[$identifier])) { + $now = DateTimeImmutable::createFromFormat( + 'Y-m-d\TH:i:s\Z', + $local_changes[$identifier], + new DateTimeZone('UTC') + ); + // refresh() stamps local_change only if the value differs from the previous one; an empty previous value always does + $previous = $entry['original'] ?? ''; + LocalChangeComments::refresh( + $translation, + $previous === $entry['value'] ? $previous . ' (old)' : $previous, + $entry['value'], + $now + ); + } + $translations->add($translation); + } + + $base_path = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/' . $module . '_' . $lang_key; + MigratedPoFixture::writePo($base_path . '.po', $translations); + MigratedPoFixture::writeOverlayRevision($base_path, $translations); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + MigratedPoFixture::writeShippedPo($relative_path, $module, $lang_key, $translations); + + return new class ($module, $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed) + ); + } + + private function invokeGetLastMigratedLocalChange(string $lang_key): ?string + { + $method = (new ReflectionClass(ilObjLanguage::class))->getMethod('_getLastMigratedLocalChange'); + return $method->invoke(null, $lang_key); + } + + /** _getLastLocalChange() issues exactly one query() call, so a single canned fetchRow() result suffices. */ + private function stubDatabase(?string $db_last_change): void + { + $statement = $this->createStub(ilDBStatement::class); + $statement->method('fetchRow')->willReturn( + $db_last_change === null ? [] : ['last_change' => $db_last_change] + ); + + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback( + static fn(mixed $value, string $type): string => "'" . (string) $value . "'" + ); + $db->method('query')->willReturn($statement); + + $this->setGlobalVariable('ilDB', $db); + } + + public function testReturnsTheNormalizedTimestampForAModuleWithOneLocallyChangedEntry(): void + { + $directory = $this->seedFixtureModule( + 'lctest', + 'de', + ['greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo']], + ['greeting' => '2026-09-21T10:00:00Z'] + ); + $this->registerDirectoryManager($directory); + + $this->assertSame('2026-09-21 10:00:00', $this->invokeGetLastMigratedLocalChange('de')); + } + + public function testReturnsTheMaximumAcrossTwoEntriesNotTheFirstOrLastIterated(): void + { + // the later change is added first: iteration order must not decide, only the timestamp + $directory = $this->seedFixtureModule( + 'lctest', + 'de', + [ + 'farewell' => ['value' => 'Tschüss, geändert', 'original' => 'Tschüss'], + 'greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo'], + ], + [ + 'farewell' => '2026-09-21T18:00:00Z', + 'greeting' => '2026-09-21T10:00:00Z', + ] + ); + $this->registerDirectoryManager($directory); + + $this->assertSame('2026-09-21 18:00:00', $this->invokeGetLastMigratedLocalChange('de')); + } + + public function testReturnsTheMaximumAcrossTwoContributedModules(): void + { + $module_a = $this->seedFixtureModule( + 'lca', + 'de', + ['greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo']], + ['greeting' => '2026-09-21T10:00:00Z'] + ); + $module_b = $this->seedFixtureModule( + 'lcb', + 'de', + ['farewell' => ['value' => 'Tschüss, geändert', 'original' => 'Tschüss']], + ['farewell' => '2026-09-22T09:30:00Z'] + ); + $this->registerDirectoryManager($module_a, $module_b); + + $this->assertSame('2026-09-22 09:30:00', $this->invokeGetLastMigratedLocalChange('de')); + } + + public function testReturnsNullWhenNoEntryOfTheMigratedModuleHasALocalChange(): void + { + $directory = $this->seedFixtureModule('lctest', 'de', [ + 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'], + ]); + $this->registerDirectoryManager($directory); + + $this->assertNull($this->invokeGetLastMigratedLocalChange('de')); + } + + public function testSkipsAContributedDirectoryMissingTheMoOrPoFileForTheRequestedLanguage(): void + { + $directory = $this->seedFixtureModule( + 'lctest', + 'de', + ['greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo']], + ['greeting' => '2026-09-21T10:00:00Z'] + ); + $this->registerDirectoryManager($directory); + + $this->assertNull($this->invokeGetLastMigratedLocalChange('fr')); + } + + public function testReturnsNullWhenNoDirectoryManagerIsRegisteredAtAll(): void + { + $this->assertNull($this->invokeGetLastMigratedLocalChange('de')); + } + + public function testSkipsATranslationWhoseContextDoesNotMatchTheModule(): void + { + $this->guardClientDataDirIsTestOwned(); + $this->fixture_directory ??= rtrim(CLIENT_DATA_DIR, '/') . '/lang/components/ILIAS/Language/tests/' + . 'tmp-lastchange-fixtures-' . bin2hex(random_bytes(4)); + if (!is_dir($this->fixture_directory)) { + mkdir($this->fixture_directory, 0775, true); + } + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + // an entry of a different module in the same .po must not leak a local_change into "lctest" + $foreign = MigratedPoFixture::entry('other_module', 'greeting', 'Hallo, geändert'); + LocalChangeComments::setOriginal($foreign, 'Hallo'); + LocalChangeComments::refresh($foreign, 'Hallo', 'Hallo, geändert', new DateTimeImmutable('2026-09-21T10:00:00Z')); + $translations->add($foreign); + + $base_path = rtrim(CLIENT_DATA_DIR, '/') . '/lang/lctest/de/lctest_de'; + MigratedPoFixture::writePo($base_path . '.po', $translations); + MigratedPoFixture::writeOverlayRevision($base_path, $translations); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + MigratedPoFixture::writeShippedPo($relative_path, 'lctest', 'de', $translations); + $directory = new class ('lctest', $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + $this->registerDirectoryManager($directory); + + $this->assertNull($this->invokeGetLastMigratedLocalChange('de')); + } + + public function testPoValueWinsWhenItIsLaterThanTheDbValue(): void + { + $this->stubDatabase('2026-09-20 08:00:00'); + $directory = $this->seedFixtureModule( + 'lctest', + 'de', + ['greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo']], + ['greeting' => '2026-09-21T10:00:00Z'] + ); + $this->registerDirectoryManager($directory); + + $this->assertSame('2026-09-21 10:00:00', ilObjLanguage::_getLastLocalChange('de')); + } + + public function testDbValueWinsWhenItIsLaterThanThePoValue(): void + { + $this->stubDatabase('2026-09-22 12:00:00'); + $directory = $this->seedFixtureModule( + 'lctest', + 'de', + ['greeting' => ['value' => 'Hallo, geändert', 'original' => 'Hallo']], + ['greeting' => '2026-09-21T10:00:00Z'] + ); + $this->registerDirectoryManager($directory); + + $this->assertSame('2026-09-22 12:00:00', ilObjLanguage::_getLastLocalChange('de')); + } + + public function testReturnsTheDbValueUnchangedWhenNoMigratedModuleContributesAnything(): void + { + $this->stubDatabase('2026-09-20 08:00:00'); + + $this->assertSame('2026-09-20 08:00:00', ilObjLanguage::_getLastLocalChange('de')); + } + + /** The "" sentinel makes ilLanguageFolderTable disable the "reset local changes" row action. */ + public function testReturnsEmptyStringWhenNeitherSourceHasAnyLocalChange(): void + { + $this->stubDatabase(null); + $directory = $this->seedFixtureModule('lctest', 'de', [ + 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'], + ]); + $this->registerDirectoryManager($directory); + + $this->assertSame('', ilObjLanguage::_getLastLocalChange('de')); + } +} diff --git a/components/ILIAS/Language/tests/PoMigrationWriteBackTest.php b/components/ILIAS/Language/tests/PoMigrationWriteBackTest.php new file mode 100644 index 000000000000..5751da6d1db8 --- /dev/null +++ b/components/ILIAS/Language/tests/PoMigrationWriteBackTest.php @@ -0,0 +1,457 @@ +fixture_directory) && is_dir($this->fixture_directory)) { + array_map('unlink', glob($this->fixture_directory . '/*') ?: []); + rmdir($this->fixture_directory); + } + + $overlay_dir = rtrim(CLIENT_DATA_DIR, '/') . '/lang'; + if (isset($this->fixture_directory) && is_dir($overlay_dir)) { + MigratedPoFixture::removeDirectory($overlay_dir); + } + // never remove a foreign CLIENT_DATA_DIR + if ($this->created_client_data_dir_root && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** + * Seeds a .po/.mo pair for a throwaway module at the shipped location; never creates an overlay. + * + * @param array $entries 'original' seeds the + * baseline an overlay bootstrapped by sync() would carry (the shipped `.po` never has it); omit it to simulate + * a key without baseline, e.g. one added after migration. + */ + private function seedFixtureModule(string $module, string $lang_key, array $entries): LanguageFileDirectory + { + $this->fixture_directory ??= __DIR__ . '/tmp-writeback-fixtures-' . bin2hex(random_bytes(4)); + if (!is_dir($this->fixture_directory)) { + mkdir($this->fixture_directory, 0775, true); + } + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + foreach ($entries as $identifier => $entry) { + $translation = MigratedPoFixture::entry($module, $identifier, $entry['value']); + if ($entry['fuzzy'] ?? false) { + $translation->addFlag('fuzzy'); + } + if (isset($entry['original'])) { + LocalChangeComments::setOriginal($translation, $entry['original']); + } + $translations->add($translation); + } + + MigratedPoFixture::writePo($this->fixture_directory . '/' . $module . '_' . $lang_key . '.po', $translations); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + + return new class ($module, $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + } + + /** Copies the shipped pair into the overlay, simulating an already installed language (an admin edit keeps an existing "original" baseline). */ + private function ensureClientDataDir(): void + { + $created = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + $this->created_client_data_dir_root = $this->created_client_data_dir_root || $created; + } + + private function bootstrapOverlayFromShipped(string $module, string $lang_key): void + { + $this->ensureClientDataDir(); + $overlay_base = $this->overlayBase($module, $lang_key); + if (!is_dir(dirname($overlay_base))) { + mkdir(dirname($overlay_base), 0775, true); + } + copy($this->fixture_directory . '/' . $module . '_' . $lang_key . '.po', $overlay_base . '.po'); + MigratedPoFixture::writeOverlayRevision($overlay_base, MigratedPoFixture::readPo($overlay_base . '.po')); + } + + private function overlayBase(string $module, string $lang_key): string + { + return rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/' . $module . '_' . $lang_key; + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed) + ); + } + + /** replaceLangModule() unserializes what it wrote as a self-check (mantis #20046/#19140): the stub only has to decode to an array. */ + private function stubDatabaseForReplaceLangModule(): void + { + $this->stubDatabaseForReplaceLangModuleCapturingInsert(); + } + + /** Like stubDatabaseForReplaceLangModule(), but returns the stub to capture what was inserted into lng_modules. */ + private function stubDatabaseForReplaceLangModuleCapturingInsert(): ilDBInterface&Stub + { + $statement = $this->createStub(ilDBStatement::class); + $db = $this->createStub(ilDBInterface::class); + $db->method('queryF')->willReturn($statement); + $db->method('fetchAssoc')->willReturn(['lang_array' => serialize([])]); + $this->setGlobalVariable('ilDB', $db); + + return $db; + } + + private function loadFixturePo(string $module, string $lang_key): \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog + { + return MigratedPoFixture::readPo($this->fixture_directory . '/' . $module . '_' . $lang_key . '.po'); + } + + private function loadOverlayPo(string $module, string $lang_key): \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog + { + return MigratedPoFixture::readPo($this->overlayBase($module, $lang_key) . '.po'); + } + + public function testUpdatesAnExistingEntryAndClearsItsFuzzyFlag(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', [ + 'greeting' => ['value' => 'Hallo', 'fuzzy' => true], + ]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Hallo, geändert']); + + $translation = $this->loadOverlayPo('wtest', 'de')->find(null, 'greeting'); + $this->assertNotNull($translation); + $this->assertSame('Hallo, geändert', $translation->getTranslation()); + $this->assertFalse($translation->hasFlag('fuzzy')); + + $shipped = $this->loadFixturePo('wtest', 'de')->find('wtest', 'greeting'); + $this->assertSame('Hallo', $shipped->getTranslation()); + $this->assertTrue($shipped->hasFlag('fuzzy')); + } + + /** + * A value unchanged from the shipped one, even with disallowed markup, is sanitised only for the DB mirror: sync() must + * see the raw value, else it finds a fake delta against the raw shipped `.po` and writes an overlay. + */ + public function testAnUnmodifiedShippedValueWithDisallowedMarkupIsSanitisedInTheDatabaseButNotInTheOverlay(): void + { + $bad = 'Hallo'; + $db = $this->stubDatabaseForReplaceLangModuleCapturingInsert(); + $captured_lang_array = null; + $db->method('insert')->willReturnCallback( + function (string $table, array $values) use (&$captured_lang_array): int { + if ($table === 'lng_modules') { + $captured_lang_array = unserialize($values['lang_array'][1] ?? '', ['allowed_classes' => false]); + } + return 1; + } + ); + $directory = $this->seedFixtureModule('wtest', 'de', ['greeting' => ['value' => $bad]]); + $this->ensureClientDataDir(); + $this->registerDirectoryManager($directory); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => $bad]); + + $this->assertStringNotContainsString('assertStringContainsString('Hallo', $captured_lang_array['greeting'] ?? ''); + $this->assertFileDoesNotExist( + $this->overlayBase('wtest', 'de') . '.po', + 'no fake local change may appear in the overlay merely because the DB mirror is sanitised' + ); + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase('wtest', 'de'))); + $this->assertSame($bad, $this->loadFixturePo('wtest', 'de')->find('wtest', 'greeting')->getTranslation()); + } + + public function testAddsANewEntryThatDidNotExistInTheFileBefore(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', [ + 'greeting' => ['value' => 'Hallo'], + ]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + + ilObjLanguage::replaceLangModule('de', 'wtest', [ + 'greeting' => 'Hallo', + 'farewell' => 'Tschüss', + ]); + + $translation = $this->loadOverlayPo('wtest', 'de')->find(null, 'farewell'); + $this->assertNotNull($translation); + $this->assertSame('Tschüss', $translation->getTranslation()); + + $this->assertNull($this->loadFixturePo('wtest', 'de')->find('wtest', 'farewell')); + } + + public function testRemovesAnEntryThatIsNoLongerInTheReplacedArray(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', [ + 'greeting' => ['value' => 'Hallo'], + 'farewell' => ['value' => 'Tschüss'], + ]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + + // "farewell" is deleted before replaceLangModule() rebuilds the row, like _deleteValues(); "greeting" is changed so the overlay has content + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Servus']); + + $this->assertNull($this->loadOverlayPo('wtest', 'de')->find(null, 'farewell')); + $this->assertNotNull($this->loadOverlayPo('wtest', 'de')->find(null, 'greeting')); + + $this->assertNotNull($this->loadFixturePo('wtest', 'de')->find('wtest', 'farewell')); + $this->assertNotNull($this->loadFixturePo('wtest', 'de')->find('wtest', 'greeting')); + } + + /** An admin edit recompiles a missing overlay `.mo`: the overlay is always maintained for an installed language. */ + public function testRecompilesAMissingOverlayMoOnTheNextEdit(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', ['greeting' => ['value' => 'Hallo']]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + unlink(MigratedPoFixture::overlayMo($this->overlayBase('wtest', 'de'))); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Hallo, geändert']); + + $this->assertSame( + ['greeting' => 'Hallo, geändert'], + MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase('wtest', 'de'))) + ); + $this->assertSame( + 'Hallo, geändert', + $this->loadOverlayPo('wtest', 'de')->find(null, 'greeting')->getTranslation() + ); + $this->assertFileExists($this->fixture_directory . '/wtest_de.po'); + $this->assertSame( + 'Hallo', + $this->loadFixturePo('wtest', 'de')->find('wtest', 'greeting')->getTranslation() + ); + } + + public function testIsANoOpWhenTheModuleHasNoContributedDirectory(): void + { + $this->ensureClientDataDir(); + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', ['greeting' => ['value' => 'Hallo']]); + $this->registerDirectoryManager($directory); + + ilObjLanguage::replaceLangModule('de', 'other_module', ['x' => 'y']); + + $this->assertSame( + 'Hallo', + $this->loadFixturePo('wtest', 'de')->find('wtest', 'greeting')->getTranslation() + ); + $this->assertFileDoesNotExist($this->fixture_directory . '/other_module_de.po'); + $this->assertDirectoryDoesNotExist(rtrim(CLIENT_DATA_DIR, '/') . '/lang/other_module'); + } + + public function testIsANoOpWhenNoDirectoryManagerIsRegisteredAtAll(): void + { + $this->ensureClientDataDir(); + $this->stubDatabaseForReplaceLangModule(); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Hallo']); + + $this->addToAssertionCount(1); + } + + /** Without invalidateMigratedLanguageFileCache() ilLanguage would serve the value cached before the write; the overlay must exist for the "before" read. */ + public function testInvalidatesIlLanguagesCacheSoTheNewValueIsVisibleImmediately(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', ['greeting' => ['value' => 'Hallo']]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + MigratedPoFixture::build($GLOBALS['DIC'][LanguageFileDirectoryManager::class], ILIAS_ABSOLUTE_PATH); + + $before = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + (new ReflectionObject($before))->getProperty('lang_key')->setValue($before, 'de'); + $before->loadLanguageModule('wtest'); + $this->assertSame('Hallo', $before->txt('greeting')); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Hallo, geändert']); + + $after = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + (new ReflectionObject($after))->getProperty('lang_key')->setValue($after, 'de'); + $after->loadLanguageModule('wtest'); + $this->assertSame('Hallo, geändert', $after->txt('greeting')); + } + + /** Editing an entry away from its shipped "original" leaves a local_change timestamp in the overlay. */ + public function testWritingADifferentValueThanTheOriginalSetsALocalChangeTimestamp(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', [ + 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'], + ]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Hallo, geändert']); + + $translation = $this->loadOverlayPo('wtest', 'de')->find(null, 'greeting'); + $this->assertNotNull($translation); + $this->assertSame('Hallo', LocalChangeComments::getOriginal($translation)); + $this->assertNotNull(LocalChangeComments::getLocalChange($translation)); + + $this->assertSame( + 'Hallo', + $this->loadFixturePo('wtest', 'de')->find('wtest', 'greeting')->getTranslation() + ); + } + + /** Writing the shipped value back clears the local_change marker, the equivalent of "reset to default". */ + public function testWritingBackTheOriginalValueClearsAnExistingLocalChangeTimestamp(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', [ + 'greeting' => ['value' => 'Hallo'], + ]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Hallo, geändert']); + $this->assertNotNull(LocalChangeComments::getLocalChange( + $this->loadOverlayPo('wtest', 'de')->find(null, 'greeting') + )); + + ilObjLanguage::replaceLangModule('de', 'wtest', ['greeting' => 'Hallo']); + + $this->assertFileDoesNotExist(rtrim(CLIENT_DATA_DIR, '/') . '/lang/wtest/de/wtest_de.po'); + } + + /** A key without "original" (e.g. added after migration) always counts as locally changed. */ + public function testANewEntryWithNoOriginalCommentIsAlwaysMarkedAsLocallyChanged(): void + { + $this->stubDatabaseForReplaceLangModule(); + $directory = $this->seedFixtureModule('wtest', 'de', [ + 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'], + ]); + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->registerDirectoryManager($directory); + + ilObjLanguage::replaceLangModule('de', 'wtest', [ + 'greeting' => 'Hallo', + 'farewell' => 'Tschüss', + ]); + + $translation = $this->loadOverlayPo('wtest', 'de')->find(null, 'farewell'); + $this->assertNotNull($translation); + $this->assertNull(LocalChangeComments::getOriginal($translation)); + $this->assertNotNull(LocalChangeComments::getLocalChange($translation)); + } + + /** Must skip instead of writing into a foreign CLIENT_DATA_DIR (e.g. /var/iliasdata); needs its own process so setUp() has not defined it. */ + #[RunInSeparateProcess] + public function testRefusesToWriteIntoAClientDataDirOutsideSysTempDir(): void + { + $foreign_dir = __DIR__ . '/tmp-not-a-temp-dir-' . bin2hex(random_bytes(4)); + $this->assertFalse(str_starts_with($foreign_dir, sys_get_temp_dir() . '/')); + mkdir($foreign_dir, 0775, true); + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + $this->seedFixtureModule('wtest', 'de', ['greeting' => ['value' => 'Hallo']]); + try { + $this->bootstrapOverlayFromShipped('wtest', 'de'); + $this->fail('bootstrapOverlayFromShipped() must refuse to write into a CLIENT_DATA_DIR outside sys_get_temp_dir().'); + } catch (SkippedWithMessageException $e) { + $this->assertStringContainsString($foreign_dir, $e->getMessage()); + } + + $this->assertSame([], array_diff(scandir($foreign_dir) ?: [], ['.', '..'])); + } finally { + if (is_dir($foreign_dir)) { + array_map('unlink', glob($foreign_dir . '/*') ?: []); + rmdir($foreign_dir); + } + } + } +} diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php index 854f062b5c9a..03ffa8088735 100644 --- a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -38,7 +38,7 @@ private function host(\ilLogger $logger, \ilLanguage|\PHPUnit\Framework\MockObje private function languageMockReturningTopicAsIs(): \ilLanguage { - $lng = $this->createMock(\ilLanguage::class); + $lng = $this->createStub(\ilLanguage::class); $lng->method('txt')->willReturnArgument(0); return $lng; @@ -220,7 +220,7 @@ public function testAThreeDeepCauseChainLogsEveryLink(): void $outer = new \RuntimeException('database operation failed', 0, $middle); $logged = []; - $logger = $this->createMock(\ilLogger::class); + $logger = $this->createStub(\ilLogger::class); $logger->method('error')->willReturnCallback( static function (string $message) use (&$logged): void { $logged[] = $message; diff --git a/components/ILIAS/Language/tests/ResetMigratedLocalChangesTest.php b/components/ILIAS/Language/tests/ResetMigratedLocalChangesTest.php new file mode 100644 index 000000000000..93f14a6cc562 --- /dev/null +++ b/components/ILIAS/Language/tests/ResetMigratedLocalChangesTest.php @@ -0,0 +1,250 @@ + */ + private array $queries = []; + /** @var list */ + private array $invalidated = []; + private bool $expect_update = true; + + protected function setUp(): void + { + parent::setUp(); + + $this->root = sys_get_temp_dir() . '/ilias_reset_test_' . bin2hex(random_bytes(6)); + mkdir($this->root . '/lang/customizing', 0775, true); + file_put_contents($this->root . '/lang/ilias_de.lang', "\ncommon#:#yes#:#Ja\n"); + $this->client_data_dir = $this->root . '/client-data'; + mkdir($this->client_data_dir); + + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback( + static fn(mixed $value): string => $value === null ? 'NULL' : "'" . (string) $value . "'" + ); + $db->method('in')->willReturnCallback( + static fn(string $field, array $values): string => $field . " IN ('" . implode("','", $values) . "')" + ); + $db->method('manipulate')->willReturnCallback(function (string $query): int { + $this->queries[] = $query; + return 1; + }); + $db->method('query')->willReturn($this->createStub(ilDBStatement::class)); + $this->setGlobalVariable('ilDB', $db); + + MigratedPoFixture::resetRuntime(); + } + + protected function tearDown(): void + { + MigratedPoFixture::removeDirectory($this->root); + + parent::tearDown(); + } + + private function directory(string $module): LanguageFileDirectory + { + return MigratedPoFixture::directory($module, 'components/' . $module . '/lang/'); + } + + /** @param array> $entries */ + private function shipPo(string $module, array $entries): void + { + MigratedPoFixture::writePo( + $this->root . '/components/' . $module . '/lang/' . $module . '_de.po', + MigratedPoFixture::catalog($module, $entries) + ); + } + + /** @param array> $entries */ + private function seedOverlay(string $module, array $entries): void + { + MigratedPoFixture::writePair($this->overlayBase($module), MigratedPoFixture::catalog($module, $entries)); + } + + private function overlayBase(string $module): string + { + return $this->client_data_dir . '/lang/' . $module . '/de/' . $module . '_de'; + } + + private function languageObject( + string $status, + ?Closure $client_data_dir_resolver, + LanguageFileDirectory ...$directories + ): ilObjLanguage&MockObject { + $manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + ...$directories + ); + $db = $GLOBALS['ilDB']; + $repository = new InstalledLanguageDatabaseRepository($db, $manager, new FixedInstallationRoot($this->root)); + $installation_manager = new LanguageInstallationManager( + $db, + $manager, + $this->root, + $repository, + null, + new CallbackTranslationCache(function (string $lang_key): void { + $this->invalidated[] = $lang_key; + }), + $client_data_dir_resolver + ); + + $properties = $this->createStub(Properties::class); + $properties->method('getPropertyTitleAndDescription')->willReturn(new TitleAndDescription()); + $properties->method('withPropertyTitleAndDescription')->willReturnSelf(); + + $object = $this->createPartialMock(ilObjLanguage::class, ['update', 'getObjectProperties']); + $object->method('getObjectProperties')->willReturn($properties); + $object->expects(str_starts_with($status, 'installed') && $this->expect_update ? $this->once() : $this->never()) + ->method('update'); + $object->key = 'de'; + $object->status = $status; + foreach (['language_file_directory_manager' => $manager, 'repository' => $repository, 'manager' => $installation_manager] as $name => $value) { + (new ReflectionProperty(ilObjLanguage::class, $name))->setValue($object, $value); + } + + return $object; + } + + private function resolver(): Closure + { + $client_data_dir = $this->client_data_dir; + return static fn(): string => $client_data_dir; + } + + public function testResetsLocalValuesAndRemovesLocallyAddedVariablesFromTheOverlay(): void + { + $this->shipPo('rtest', [ + 'greeting' => 'Hallo', + 'hint' => ['value' => 'Hint', 'fuzzy' => true], + ]); + $this->seedOverlay('rtest', [ + 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'], + 'hint' => ['value' => 'Hinweis', 'original' => 'Hint', 'local_change' => '2020-01-01T00:00:00Z'], + 'added_locally' => ['value' => 'Eigene Variable', 'local_change' => '2020-01-01T00:00:00Z'], + ]); + $object = $this->languageObject('installed', $this->resolver(), $this->directory('rtest')); + + $this->assertTrue($object->removeLocalChanges()); + + $this->assertFileDoesNotExist($this->overlayBase('rtest') . '.po'); + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase('rtest'))); + $this->assertFileExists(MigratedPoFixture::overlayLock($this->overlayBase('rtest')), 'the lock file is only removed on uninstall'); + $this->assertSame('installed', $object->getDescription()); + } + + public function testFlushesTheWholeLanguageIncludingLocalChangesAndInvalidatesTheCache(): void + { + $this->shipPo('rtest', ['greeting' => 'Hallo']); + $object = $this->languageObject('installed', $this->resolver(), $this->directory('rtest')); + + $object->removeLocalChanges(); + + $this->assertContains("DELETE FROM lng_data WHERE lang_key = 'de'", $this->queries, 'local changes are deleted too'); + $this->assertContains("DELETE FROM lng_modules WHERE lang_key = 'de'", $this->queries); + $this->assertSame(['de'], $this->invalidated); + } + + public function testResetsEveryMigratedModuleIndependently(): void + { + $this->shipPo('rone', ['greeting' => 'Eins']); + $this->shipPo('rtwo', ['greeting' => 'Zwei']); + $this->seedOverlay('rone', ['greeting' => ['value' => 'Lokal 1', 'original' => 'Eins', 'local_change' => '2020-01-01T00:00:00Z']]); + $this->seedOverlay('rtwo', ['greeting' => ['value' => 'Lokal 2', 'original' => 'Zwei', 'local_change' => '2020-01-01T00:00:00Z']]); + $object = $this->languageObject('installed', $this->resolver(), $this->directory('rone'), $this->directory('rtwo')); + + $object->removeLocalChanges(); + + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase('rone'))); + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase('rtwo'))); + } + + /** A directory without a shipped `.po` for the language is not migrated for it: no overlay is created, the other module is still reset. */ + public function testSkipsAModuleWithoutShippedPoForTheLanguage(): void + { + $this->shipPo('rtest', ['greeting' => 'Hallo']); + $this->seedOverlay('rtest', ['greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z']]); + $object = $this->languageObject('installed', $this->resolver(), $this->directory('rtest'), $this->directory('nopo')); + + $this->assertTrue($object->removeLocalChanges()); + + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase('rtest'))); + $this->assertFileDoesNotExist($this->overlayBase('nopo') . '.po'); + } + + public function testWithoutClientDataDirTheDatabaseIsResetButNoOverlayIsWritten(): void + { + $this->shipPo('rtest', ['greeting' => 'Hallo']); + $object = $this->languageObject('installed', static fn(): ?string => null, $this->directory('rtest')); + + $this->assertTrue($object->removeLocalChanges()); + + $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang'); + $this->assertNotEmpty(array_filter($this->queries, static fn(string $q): bool => str_starts_with($q, /** @lang text */ 'INSERT INTO lng_modules'))); + } + + public function testDoesNothingForALanguageThatIsNotInstalled(): void + { + $this->shipPo('rtest', ['greeting' => 'Hallo']); + $this->seedOverlay('rtest', ['greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z']]); + $object = $this->languageObject('not_installed', $this->resolver(), $this->directory('rtest')); + + $this->assertFalse($object->removeLocalChanges()); + + $this->assertSame([], $this->queries); + $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase('rtest')))); + } + + public function testRefusesALanguageWithAnUnparsableShippedPoBeforeFlushingAnything(): void + { + mkdir($this->root . '/components/rtest/lang', 0775, true); + file_put_contents($this->root . '/components/rtest/lang/rtest_de.po', "msgid \"kaputt\n"); + $this->seedOverlay('rtest', ['greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z']]); + $this->expect_update = false; + $object = $this->languageObject('installed', $this->resolver(), $this->directory('rtest')); + + $this->assertFalse($object->removeLocalChanges()); + + $this->assertSame([], $this->queries); + $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase('rtest')))); + } +} diff --git a/components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php b/components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php new file mode 100644 index 000000000000..6842c2d4ba8b --- /dev/null +++ b/components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php @@ -0,0 +1,403 @@ +created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + MigratedPoFixture::resetRuntime(); + (new ReflectionClass(ilCachedLanguage::class))->getProperty('instances')->setValue(null, []); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . $this->fixture_directory); + MigratedPoFixture::removeDirectory(rtrim(CLIENT_DATA_DIR, '/') . '/lang'); + } + // never remove a foreign CLIENT_DATA_DIR + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** + * @param array $shipped_entries + * @param array|null $overlay_entries null => no overlay at all (not even a readable one) + */ + private function seedShippedAndOverlay( + string $module, + array $shipped_entries, + ?array $overlay_entries + ): LanguageFileDirectory { + $this->fixture_directory ??= 'tmp-savevalues-fixtures-' . bin2hex(random_bytes(4)); + $relative_path = 'components/ILIAS/Language/tests/' . $this->fixture_directory . '/'; + + MigratedPoFixture::writeShippedPo( + $relative_path, + $module, + 'de', + MigratedPoFixture::catalog($module, $shipped_entries) + ); + + if ($overlay_entries !== null) { + MigratedPoFixture::writePair( + rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/de/' . $module . '_de', + MigratedPoFixture::catalog($module, $overlay_entries) + ); + } + + return MigratedPoFixture::directory($module, $relative_path); + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed) + ); + } + + private function callCurrentModuleContent(string $lang_key, string $module): array + { + return (new ReflectionClass(ilObjLanguageExt::class)) + ->getMethod('currentModuleContent') + ->invoke(null, $lang_key, $module); + } + + /** @return array{0: string, 1: string} module/topic separator, an ilLanguage stand-in whose constructor never ran */ + private function stubLng(): ilLanguage + { + $lng = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $this->setGlobalVariable('lng', $lng); + + return $lng; + } + + /** + * $ilDB mock with a controllable lng_modules row and lng_data rows; the three SELECTs (lng_modules row, lng_data + * "remarks", lng_data values) are told apart by SQL text. + * + * @param array|null $lng_modules_row the (unserialized) content of the existing row; null = no row + * @param list $lng_data_rows rows of the plain lng_data values SELECT + */ + private function mockDatabaseForWrites(?array $lng_modules_row, array $lng_data_rows = []): ilDBInterface&Stub + { + $lng_modules_stmt = $this->createStub(ilDBStatement::class); + $remarks_stmt = $this->createStub(ilDBStatement::class); + $lng_data_stmt = $this->createStub(ilDBStatement::class); + $query_f_stmt = $this->createStub(ilDBStatement::class); + + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value, string $type = ''): string => "'" . (string) $value . "'"); + $db->method('in')->willReturn("module IN ('placeholder')"); + $db->method('query')->willReturnCallback( + static function (string $sql) use ($lng_modules_stmt, $remarks_stmt, $lng_data_stmt): ilDBStatement { + if (str_contains($sql, 'lng_modules')) { + return $lng_modules_stmt; + } + if (str_contains($sql, 'remarks')) { + return $remarks_stmt; + } + return $lng_data_stmt; + } + ); + $db->method('queryF')->willReturn($query_f_stmt); + + $rows = $lng_data_rows; + $db->method('fetchAssoc')->willReturnCallback( + function (ilDBStatement $statement) use ( + $lng_modules_stmt, + $remarks_stmt, + $lng_data_stmt, + $query_f_stmt, + $lng_modules_row, + &$rows + ) { + if ($statement === $query_f_stmt) { + return ['lang_array' => serialize([])]; + } + if ($statement === $lng_modules_stmt) { + return $lng_modules_row === null ? null : ['lang_array' => serialize($lng_modules_row)]; + } + if ($statement === $remarks_stmt) { + return null; + } + if ($statement === $lng_data_stmt) { + $row = array_shift($rows); + return $row ?? null; + } + return null; + } + ); + $db->method('manipulate')->willReturn(0); + $db->method('insert')->willReturn(1); + + $this->setGlobalVariable('ilDB', $db); + + return $db; + } + + private function seedEmptyGlobalLanguageFile(string $lang_key): void + { + $fake_global_file = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor(); + $fake_global_file->setAllValues([]); + $fake_global_file->setAllComments([]); + + $cache = (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects'); + $existing = $cache->isInitialized() ? $cache->getValue() : []; + $cache->setValue(null, $existing + [$lang_key => $fake_global_file]); + } + + private function callSaveValues( + string $lang_key, + array $values, + array $remarks, + bool $merge_onto_current_content + ): array { + return (new ReflectionClass(ilObjLanguageExt::class)) + ->getMethod('saveValues') + ->invoke(null, $lang_key, $values, $remarks, $merge_onto_current_content); + } + + /** `->never()` on query(): once a readable overlay is found the database must not be queried at all. */ + public function testOverlayValueIsUsedEvenWhenItDiffersFromTheLngModulesRowAndTheDatabaseIsNeverConsulted(): void + { + $directory = $this->seedShippedAndOverlay( + 'ctest', + ['greeting' => 'Shipped'], + ['greeting' => 'Aus dem Overlay'] + ); + $this->registerDirectoryManager($directory); + + $db = $this->createMock(ilDBInterface::class); + $db->expects($this->never())->method('query'); + $this->setGlobalVariable('ilDB', $db); + + $this->assertSame( + ['greeting' => 'Aus dem Overlay'], + $this->callCurrentModuleContent('de', 'ctest') + ); + } + + /** A migrated module with neither readable overlay nor lng_modules row is rebuilt from lng_data, minus the deleted key. */ + public function testDeleteValuesWithoutOverlayAndWithoutLngModulesRowFallsBackToLngDataExcludingDeletedKeys(): void + { + $directory = $this->seedShippedAndOverlay('ctest2', ['greeting' => 'Shipped'], ['greeting' => 'Lokal']); + file_put_contents( + rtrim(CLIENT_DATA_DIR, '/') . '/lang/ctest2/de/ctest2_de.po', + "msgid \"kaputt\n" + ); + $this->registerDirectoryManager($directory); + $lng = $this->stubLng(); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + + $db = $this->mockDatabaseForWrites(null, [ + ['identifier' => 'greeting', 'value' => 'Aus lng_data'], + ['identifier' => 'farewell', 'value' => 'Tschüss aus lng_data'], + ]); + + $captured = null; + $db->method('insert')->willReturnCallback( + static function (string $table, array $values) use (&$captured): int { + if ($table === 'lng_modules') { + $captured = unserialize($values['lang_array'][1] ?? '', ['allowed_classes' => false]); + } + return 1; + } + ); + + ilObjLanguageExt::_deleteValues('de', ['ctest2' . $lng->separator . 'greeting' => 'irrelevant']); + + $this->assertSame(['farewell' => 'Tschüss aus lng_data'], $captured); + } + + /** + * An overlay write failure never undoes the database write: the module is reported back in the returned list. + * Root-proof: a regular file sits where the overlay's language directory must be created. + */ + public function testDeleteValuesReportsTheModuleWhoseOverlayCouldNotBeWritten(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('A non-writable directory cannot be forced for root (no root-proof variant here).'); + } + // a real local change ("greeting") must survive, else sync() has an empty delta and writes nothing + $directory = $this->seedShippedAndOverlay( + 'ctest3', + ['greeting' => 'Shipped', 'farewell' => 'Tschüss'], + ['greeting' => 'Lokal'] + ); + $this->registerDirectoryManager($directory); + $lng = $this->stubLng(); + $logger = $this->createMock(ilLogger::class); + $logger->expects($this->atLeastOnce())->method('warning'); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + $this->mockDatabaseForWrites(['greeting' => 'Lokal', 'farewell' => 'Tschüss']); + + // the files stay readable, but their directory is non-writable, so the write itself fails + $overlay_directory = rtrim(CLIENT_DATA_DIR, '/') . '/lang/ctest3/de'; + chmod($overlay_directory, 0555); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $unwritten = ilObjLanguageExt::_deleteValues('de', ['ctest3' . $lng->separator . 'farewell' => 'irrelevant']); + } finally { + restore_error_handler(); + chmod($overlay_directory, 0775); + } + + $this->assertSame(['ctest3'], $unwritten); + } + + /** The expensive reads must not run when every value passes the plain markup check (an empty $GLOBALS['DIC'] would fatal). */ + public function testFindInvalidMarkupOfChangedValuesNeverReadsCurrentOrShippedValuesWhenNothingIsSuspicious(): void + { + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + + $result = ilObjLanguageExt::findInvalidMarkupOfChangedValues('zz_no_db_needed', [ + 'common#:#greeting' => 'Hallo', + 'common#:#formatting' => 'Hallo', + ]); + + $this->assertSame([], $result); + } + + /** A value failing the plain check is rejected only if it also differs from both the current and the shipped value. */ + public function testFindInvalidMarkupOfChangedValuesAppliesTheCurrentAndShippedComparison(): void + { + $lang_key = 'zz_changed_markup_test'; + $bad = ''; + $this->seedEmptyGlobalLanguageFile($lang_key); + $this->stubLng(); + + $rows = [ + ['module' => 'common', 'identifier' => 'unmodified', 'value' => $bad], + ['module' => 'common', 'identifier' => 'changed', 'value' => 'Hallo'], + ]; + $statement = $this->createStub(ilDBStatement::class); + $statement->method('fetchRow')->willReturnCallback(static function () use (&$rows) { + return array_shift($rows); + }); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value, string $type = ''): string => "'" . (string) $value . "'"); + $db->method('in')->willReturn("module IN ('placeholder')"); + $db->method('like')->willReturn('1=1'); + $db->method('query')->willReturn($statement); + $this->setGlobalVariable('ilDB', $db); + + $result = ilObjLanguageExt::findInvalidMarkupOfChangedValues($lang_key, [ + 'common#:#unmodified' => $bad, + 'common#:#changed' => $bad, + ]); + + $this->assertArrayNotHasKey('common#:#unmodified', $result, 'unchanged from the current lng_data value - not rejected'); + $this->assertArrayHasKey('common#:#changed', $result, 'genuinely changed to a disallowed value - rejected'); + } + + /** With $merge_onto_current_content=false ("delete"-mode import) $to_save is the complete new content: absent keys are gone. */ + public function testMergeOntoCurrentContentFalseDropsAKeyThatIsNotInTheNewEntries(): void + { + $lang_key = 'zz_merge_false_test'; + $this->seedEmptyGlobalLanguageFile($lang_key); + $lng = $this->stubLng(); + $db = $this->mockDatabaseForWrites(['greeting' => 'Alt', 'farewell' => 'Tschüss']); + + $captured = null; + $db->method('insert')->willReturnCallback( + static function (string $table, array $values) use (&$captured): int { + if ($table === 'lng_modules') { + $captured = unserialize($values['lang_array'][1] ?? '', ['allowed_classes' => false]); + } + return 1; + } + ); + + $this->callSaveValues( + $lang_key, + ['wtest' . $lng->separator . 'greeting' => 'Neu'], + [], + false + ); + + $this->assertSame(['greeting' => 'Neu'], $captured); + } + + /** The default (true) contrasted with the test above: the untouched "farewell" survives. */ + public function testMergeOntoCurrentContentTrueKeepsAKeyThatIsNotInTheNewEntries(): void + { + $lang_key = 'zz_merge_true_test'; + $this->seedEmptyGlobalLanguageFile($lang_key); + $lng = $this->stubLng(); + $db = $this->mockDatabaseForWrites(['greeting' => 'Alt', 'farewell' => 'Tschüss']); + + $captured = null; + $db->method('insert')->willReturnCallback( + static function (string $table, array $values) use (&$captured): int { + if ($table === 'lng_modules') { + $captured = unserialize($values['lang_array'][1] ?? '', ['allowed_classes' => false]); + } + return 1; + } + ); + + $this->callSaveValues( + $lang_key, + ['wtest' . $lng->separator . 'greeting' => 'Neu'], + [], + true + ); + + $this->assertSame(['greeting' => 'Neu', 'farewell' => 'Tschüss'], $captured); + } +} diff --git a/components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php b/components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php new file mode 100644 index 000000000000..1ee23dd867c3 --- /dev/null +++ b/components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php @@ -0,0 +1,389 @@ +created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + MigratedPoFixture::resetRuntime(); + (new ReflectionClass(ilCachedLanguage::class))->getProperty('instances')->setValue(null, []); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . $this->fixture_directory); + MigratedPoFixture::removeDirectory(rtrim(CLIENT_DATA_DIR, '/') . '/lang'); + } + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** Ships self::MODULE/self::LANG_KEY with a singular ("greeting") and a plural message ("item"/"items") and registers its directory. */ + private function seedShippedModule(): LanguageFileDirectory + { + $this->fixture_directory = 'tmp-savevalues-remarks-fixtures-' . bin2hex(random_bytes(4)); + $relative_path = 'components/ILIAS/Language/tests/' . $this->fixture_directory . '/'; + + $catalog = new TranslationCatalog(); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $greeting = new TranslationEntry(self::MODULE, 'greeting'); + $greeting->translate('Hallo'); + $catalog->add($greeting); + $item = new TranslationEntry(self::MODULE, 'item'); + $item->setPlural('items', ['Eintrag', 'Einträge']); + $catalog->add($item); + MigratedPoFixture::writeShippedPo($relative_path, self::MODULE, self::LANG_KEY, $catalog); + + $directory = MigratedPoFixture::directory(self::MODULE, $relative_path); + $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory); + $this->setGlobalVariable(LanguageFileDirectoryManager::class, $this->manager); + + return $directory; + } + + private function stubLng(): ilLanguage + { + $lng = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $this->setGlobalVariable('lng', $lng); + + return $lng; + } + + /** normalizedRemarks() reads $DIC->logger()->forComponent('lang') for an invalid remark and throws without it. */ + private function stubComponentLogger(): void + { + $factory = $this->createStub(ilLoggerFactory::class); + $factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); + $this->setGlobalVariable('ilLoggerFactory', $factory); + } + + private function seedEmptyGlobalLanguageFile(string $lang_key): void + { + $fake_global_file = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor(); + $fake_global_file->setAllValues([]); + $fake_global_file->setAllComments([]); + + $cache = (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects'); + $existing = $cache->isInitialized() ? $cache->getValue() : []; + $cache->setValue(null, $existing + [$lang_key => $fake_global_file]); + } + + private function overlayRemark(string $identifier): ?string + { + return (MigratedLanguageFileSync::loadRemarks($this->manager, self::LANG_KEY, self::MODULE, CLIENT_DATA_DIR) ?? [])[$identifier] ?? null; + } + + /** + * @param list}> $replace_calls + * @param list $manipulate_calls every manipulate() SQL string, in order + * @param list $existing_remarks rows + * _getRemarks()'s "remarks IS NOT NULL" SELECT returns + */ + private function mockDatabaseForWrites(array &$replace_calls, array &$manipulate_calls, array $existing_remarks = []): ilDBInterface&Stub + { + $lng_modules_stmt = $this->createStub(ilDBStatement::class); + $remarks_stmt = $this->createStub(ilDBStatement::class); + $lng_data_stmt = $this->createStub(ilDBStatement::class); + $query_f_stmt = $this->createStub(ilDBStatement::class); + + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value, string $type = ''): string => $value === null ? 'NULL' : "'" . (string) $value . "'"); + $db->method('in')->willReturn("module IN ('placeholder')"); + $db->method('query')->willReturnCallback( + static function (string $sql) use ($lng_modules_stmt, $remarks_stmt, $lng_data_stmt): ilDBStatement { + if (str_contains($sql, 'lng_modules')) { + return $lng_modules_stmt; + } + return str_contains($sql, 'remarks') ? $remarks_stmt : $lng_data_stmt; + } + ); + $db->method('queryF')->willReturn($query_f_stmt); + $remaining_remarks = $existing_remarks; + $db->method('fetchAssoc')->willReturnCallback( + static function (ilDBStatement $statement) use ($query_f_stmt, $remarks_stmt, &$remaining_remarks): ?array { + if ($statement === $query_f_stmt) { + return ['lang_array' => serialize([])]; + } + if ($statement === $remarks_stmt) { + return array_shift($remaining_remarks) ?: null; + } + return null; + } + ); + $db->method('manipulate')->willReturnCallback( + static function (string $sql) use (&$manipulate_calls): int { + $manipulate_calls[] = $sql; + return 1; + } + ); + $db->method('insert')->willReturn(1); + $db->method('replace')->willReturnCallback( + static function (string $table, array $keys, array $values) use (&$replace_calls): int { + if ($table === 'lng_data') { + $replace_calls[] = [$keys['identifier'][1] ?? null, [ + 'value' => $values['value'][1] ?? null, + 'local_change' => $values['local_change'][1] ?? null, + 'remarks' => $values['remarks'][1] ?? null, + ]]; + } + return 1; + } + ); + + $this->setGlobalVariable('ilDB', $db); + + return $db; + } + + /** A pure remark change on a singular identifier goes through updateRemark() (UPDATE of "remarks" only), not replace(). */ + public function testPureRemarkChangeOnASingularIdentifierUpdatesRemarksAloneAndWritesTheOverlay(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'greeting' => 'Hallo'], + [self::MODULE . $lng->separator . 'greeting' => 'Bitte prüfen'] + ); + + $greeting_replace_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'greeting')); + $this->assertSame([], $greeting_replace_calls, 'no fake value write via replace()'); + $remark_updates = array_values(array_filter( + $manipulate_calls, + static fn(string $sql): bool => str_contains($sql, 'UPDATE lng_data') && str_contains($sql, 'remarks') + )); + $this->assertNotEmpty($remark_updates, 'lng_data.remarks must be updated'); + $this->assertStringContainsString("'Bitte prüfen'", $remark_updates[0]); + $this->assertSame('Bitte prüfen', $this->overlayRemark('greeting'), 'mirrored into the overlay'); + } + + /** A remark that is not valid UTF-8 is dropped with a warning; the save still succeeds. */ + public function testSaveValuesDropsAnInvalidUtf8RemarkWithoutSavingItAnywhere(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $this->stubComponentLogger(); + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls); + + $unwritten = ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'greeting' => 'Hallo'], + [self::MODULE . $lng->separator . 'greeting' => "Ung\xFCltig"] + ); + + $this->assertSame([], $unwritten, 'the save itself must still succeed'); + $this->assertNull($this->overlayRemark('greeting')); + $remark_updates = array_values(array_filter( + $manipulate_calls, + static fn(string $sql): bool => str_contains($sql, 'UPDATE lng_data') && str_contains($sql, 'remarks') + )); + $this->assertSame([], $remark_updates, 'nothing must be written for the dropped remark'); + } + + /** A remark longer than 250 characters is cut to 250 (lng_data's column limit) before the comparison and the write. */ + public function testSaveValuesCutsALongRemarkToTwoHundredAndFiftyCharacters(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls); + $long_remark = str_repeat('x', 300); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'greeting' => 'Hallo'], + [self::MODULE . $lng->separator . 'greeting' => $long_remark] + ); + + $this->assertSame(str_repeat('x', 250), $this->overlayRemark('greeting')); + } + + public function testDeleteValuesOfASingularKeyRemovesItsOverlayRemark(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls); + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'greeting' => 'Hallo'], + [self::MODULE . $lng->separator . 'greeting' => 'Bitte prüfen'] + ); + $this->assertSame('Bitte prüfen', $this->overlayRemark('greeting'), 'precondition'); + + $replace_calls_2 = []; + $manipulate_calls_2 = []; + $this->mockDatabaseForWrites($replace_calls_2, $manipulate_calls_2); + ilObjLanguageExt::_deleteValues(self::LANG_KEY, [self::MODULE . $lng->separator . 'greeting' => '']); + + $this->assertNull($this->overlayRemark('greeting')); + } + + /** Deleting one form of a plural message keeps the message's overlay remark: it belongs to the whole message. */ + public function testDeleteValuesOfOnePluralFormKeepsTheMessagesOverlayRemark(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls); + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge', + ], + [ + self::MODULE . $lng->separator . 'item [0]' => '', + self::MODULE . $lng->separator . 'item [1]' => 'Bitte prüfen', + ] + ); + $this->assertSame('Bitte prüfen', $this->overlayRemark('item'), 'precondition'); + + $replace_calls_2 = []; + $manipulate_calls_2 = []; + $this->mockDatabaseForWrites($replace_calls_2, $manipulate_calls_2); + ilObjLanguageExt::_deleteValues(self::LANG_KEY, [self::MODULE . $lng->separator . 'item [1]' => '']); + + $this->assertSame('Bitte prüfen', $this->overlayRemark('item'), 'the plural message\'s remark must survive deleting one form'); + } + + public function testGetRemarksShowsALngDataOnlyRemarkWithoutAnOverlayOne(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls, [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'DB-Bemerkung'], + ]); + + $remarks = ilObjLanguageExt::_getRemarks(self::LANG_KEY); + + $this->assertSame('DB-Bemerkung', $remarks[self::MODULE . $lng->separator . 'greeting'] ?? null); + } + + /** An overlay remark wins over lng_data's: the overlay is the source of truth for a PO module. */ + public function testGetRemarksOverlayRemarkWinsOverAStaleLngDataOne(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $save_calls = []; + $save_manipulate_calls = []; + $this->mockDatabaseForWrites($save_calls, $save_manipulate_calls); + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'greeting' => 'Hallo'], + [self::MODULE . $lng->separator . 'greeting' => 'Overlay-Bemerkung'] + ); + + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls, [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'Veraltete DB-Bemerkung'], + ]); + + $remarks = ilObjLanguageExt::_getRemarks(self::LANG_KEY); + + $this->assertSame('Overlay-Bemerkung', $remarks[self::MODULE . $lng->separator . 'greeting'] ?? null); + } + + /** A shipped "fuzzy" entry gets the "not translated" marker (lng_not_translated) appended to its comment. */ + public function testGetShippedMigratedCommentsAppendsTheFuzzyMarkerForAFuzzyEntryOnly(): void + { + $this->fixture_directory = 'tmp-savevalues-remarks-fuzzy-fixtures-' . bin2hex(random_bytes(4)); + $relative_path = 'components/ILIAS/Language/tests/' . $this->fixture_directory . '/'; + $lng = $this->stubLng(); + + $catalog = new TranslationCatalog(); + $greeting = new TranslationEntry(self::MODULE, 'greeting'); + $greeting->translate('Hallo'); + $catalog->add($greeting); + $fuzzy = new TranslationEntry(self::MODULE, 'fuzzy_one'); + $fuzzy->translate('Noch nicht übersetzt'); + $fuzzy->addFlag('fuzzy'); + $catalog->add($fuzzy); + MigratedPoFixture::writeShippedPo($relative_path, self::MODULE, self::LANG_KEY, $catalog); + $this->manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory(self::MODULE, $relative_path) + ); + $this->setGlobalVariable(LanguageFileDirectoryManager::class, $this->manager); + + $marker_lng = $this->createStub(ilLanguage::class); + $marker_lng->method('txt')->willReturnArgument(0); + $this->setGlobalVariable('lng', $marker_lng); + + $comments = ilObjLanguageExt::_getShippedMigratedComments(self::LANG_KEY); + + $this->assertSame('lng_not_translated', $comments[self::MODULE . $lng->separator . 'fuzzy_one'] ?? null); + $this->assertArrayNotHasKey(self::MODULE . $lng->separator . 'greeting', $comments); + } +} diff --git a/components/ILIAS/Language/tests/SaveValuesRecreatesLngModulesAfterDeleteModeImportTest.php b/components/ILIAS/Language/tests/SaveValuesRecreatesLngModulesAfterDeleteModeImportTest.php new file mode 100644 index 000000000000..644c87362c4a --- /dev/null +++ b/components/ILIAS/Language/tests/SaveValuesRecreatesLngModulesAfterDeleteModeImportTest.php @@ -0,0 +1,191 @@ +getProperty('instances'); + $instances->setValue(null, []); + } + + /** Seeds the static cache of ilLanguageFile::_getGlobalLanguageFile() with an empty fake, so no real lang file is read. */ + private function seedEmptyGlobalLanguageFile(string $lang_key): void + { + $fake_global_file = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor(); + $fake_global_file->setAllValues([]); + $fake_global_file->setAllComments([]); + + $cache = (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects'); + $existing = $cache->isInitialized() ? $cache->getValue() : []; + $cache->setValue(null, $existing + [$lang_key => $fake_global_file]); + } + + /** + * ilDBInterface mock for _saveValues() -> replaceLangModule(): the three query() calls (lng_data values, lng_data + * remarks, per-module lng_modules SELECT) are told apart by SQL text. What matters is insert() on "lng_modules", which + * only replaceLangModule() issues. + */ + private function mockDatabaseForSaveValues(): \PHPUnit\Framework\MockObject\MockObject + { + $empty_row_statement = $this->createStub(ilDBStatement::class); + $empty_row_statement->method('fetchRow')->willReturn(null); + + $remarks_statement = $this->createStub(ilDBStatement::class); + $lng_modules_select_statement = $this->createStub(ilDBStatement::class); + $query_f_statement = $this->createStub(ilDBStatement::class); + + $db = $this->createMock(ilDBInterface::class); + + $db->method('quote')->willReturnCallback( + static fn($value, string $type): string => "'" . (string) $value . "'" + ); + + $db->method('query')->willReturnCallback( + static function (string $sql) use ( + $empty_row_statement, + $remarks_statement, + $lng_modules_select_statement + ) { + if (str_contains($sql, 'lng_modules')) { + return $lng_modules_select_statement; + } + if (str_contains($sql, 'remarks')) { + return $remarks_statement; + } + return $empty_row_statement; + } + ); + + // used by ilCachedLanguage::readFromDB() and replaceLangModule()'s self-check; told apart via fetchAssoc()/fetchObject() + $db->method('queryF')->willReturn($query_f_statement); + + // no pre-existing lng_modules row (as after the "delete" wipe) and no remarks + $db->method('fetchAssoc')->willReturnCallback( + static function (ilDBStatement $statement) use ( + $remarks_statement, + $lng_modules_select_statement, + $query_f_statement + ) { + if ($statement === $query_f_statement) { + // the self-check must unserialize to an array or the write counts as failed (mantis #20046/#19140) + return ['lang_array' => serialize(['greeting' => 'Hallo, neu'])]; + } + if ($statement === $remarks_statement || $statement === $lng_modules_select_statement) { + return null; + } + return null; + } + ); + + // fetchObject() stays unstubbed (null ends ilCachedLanguage::readFromDB()'s loop) + $db->method('manipulate')->willReturn(0); + $db->method('replace')->willReturn(1); + + $this->setGlobalVariable('ilDB', $db); + + return $db; + } + + /** A module without lng_modules row but with entries to save must get its row (re-)created via replaceLangModule(), not skipped. */ + public function testRecreatesTheLngModulesRowForAModulePresentInTheImport(): void + { + $lang_key = 'zz_delete_import_test'; + $this->seedEmptyGlobalLanguageFile($lang_key); + $db = $this->mockDatabaseForSaveValues(); + + $lng = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $this->setGlobalVariable('lng', $lng); + + $db->expects($this->once()) + ->method('insert') + ->with( + 'lng_modules', + $this->callback(static function (array $values) use ($lang_key): bool { + if (($values['lang_key'][1] ?? null) !== $lang_key) { + return false; + } + if (($values['module'][1] ?? null) !== 'testmodule') { + return false; + } + $unserialized = unserialize($values['lang_array'][1] ?? '', ['allowed_classes' => false]); + return $unserialized === ['greeting' => 'Hallo, neu']; + }) + ) + ->willReturn(1); + + ilObjLanguageExt::_saveValues( + $lang_key, + ['testmodule' . $lng->separator . 'greeting' => 'Hallo, neu'], + [] + ); + } + + /** With two modules each row is (re-)created independently, not only the first one of the loop. */ + public function testRecreatesTheLngModulesRowForEveryModulePresentInTheImport(): void + { + $lang_key = 'zz_delete_import_test_multi'; + $this->seedEmptyGlobalLanguageFile($lang_key); + $db = $this->mockDatabaseForSaveValues(); + + $lng = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $this->setGlobalVariable('lng', $lng); + + $inserted_modules = []; + $db->expects($this->exactly(2)) + ->method('insert') + ->with( + 'lng_modules', + $this->callback(function (array $values) use (&$inserted_modules): bool { + $inserted_modules[] = $values['module'][1] ?? null; + return true; + }) + ) + ->willReturn(1); + + ilObjLanguageExt::_saveValues( + $lang_key, + [ + 'moduleone' . $lng->separator . 'greeting' => 'Hallo', + 'moduletwo' . $lng->separator . 'farewell' => 'Tschüss', + ], + [] + ); + + sort($inserted_modules); + $this->assertSame(['moduleone', 'moduletwo'], $inserted_modules); + } +} diff --git a/components/ILIAS/Language/tests/SaveValuesWritesPluralFormsTest.php b/components/ILIAS/Language/tests/SaveValuesWritesPluralFormsTest.php new file mode 100644 index 000000000000..8ff84a8525fb --- /dev/null +++ b/components/ILIAS/Language/tests/SaveValuesWritesPluralFormsTest.php @@ -0,0 +1,508 @@ +created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + MigratedPoFixture::resetRuntime(); + (new ReflectionClass(ilCachedLanguage::class))->getProperty('instances')->setValue(null, []); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . $this->fixture_directory); + MigratedPoFixture::removeDirectory(rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE); + } + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** Ships self::MODULE/self::LANG_KEY with one plural message ("item"/"items") and registers its directory. */ + private function seedShippedPluralModule(): LanguageFileDirectory + { + $this->fixture_directory = 'tmp-savevalues-plural-fixtures-' . bin2hex(random_bytes(4)); + $relative_path = 'components/ILIAS/Language/tests/' . $this->fixture_directory . '/'; + + $catalog = new TranslationCatalog(); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $entry = new TranslationEntry(self::MODULE, 'item'); + $entry->setPlural('items', ['Eintrag', 'Einträge']); + $catalog->add($entry); + MigratedPoFixture::writeShippedPo($relative_path, self::MODULE, self::LANG_KEY, $catalog); + + $directory = MigratedPoFixture::directory(self::MODULE, $relative_path); + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $directory) + ); + + return $directory; + } + + private function stubLng(): ilLanguage + { + $lng = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $this->setGlobalVariable('lng', $lng); + + return $lng; + } + + private function seedEmptyGlobalLanguageFile(string $lang_key): void + { + $fake_global_file = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor(); + $fake_global_file->setAllValues([]); + $fake_global_file->setAllComments([]); + + $cache = (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects'); + $existing = $cache->isInitialized() ? $cache->getValue() : []; + $cache->setValue(null, $existing + [$lang_key => $fake_global_file]); + } + + /** + * @param list}> $replace_calls filled with every replace("lng_data", ...) + * call: [identifier, ['value' => ..., 'local_change' => ..., 'remarks' => ...]]; pass an empty array + * @param list $existing_remarks rows + * _getRemarks() (the "remarks IS NOT NULL" SELECT) returns + */ + private function mockDatabaseForWrites(array &$replace_calls, array $existing_remarks = []): ilDBInterface&Stub + { + $lng_modules_stmt = $this->createStub(ilDBStatement::class); + $remarks_stmt = $this->createStub(ilDBStatement::class); + $lng_data_stmt = $this->createStub(ilDBStatement::class); + $query_f_stmt = $this->createStub(ilDBStatement::class); + + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value, string $type = ''): string => "'" . (string) $value . "'"); + $db->method('in')->willReturn("module IN ('placeholder')"); + $db->method('query')->willReturnCallback( + static function (string $sql) use ($lng_modules_stmt, $remarks_stmt, $lng_data_stmt): ilDBStatement { + if (str_contains($sql, 'lng_modules')) { + return $lng_modules_stmt; + } + return str_contains($sql, 'remarks') ? $remarks_stmt : $lng_data_stmt; + } + ); + $db->method('queryF')->willReturn($query_f_stmt); + $remaining_remarks = $existing_remarks; + $db->method('fetchAssoc')->willReturnCallback( + static function (ilDBStatement $statement) use ($query_f_stmt, $remarks_stmt, &$remaining_remarks): ?array { + if ($statement === $query_f_stmt) { + return ['lang_array' => serialize([])]; + } + if ($statement === $remarks_stmt) { + return array_shift($remaining_remarks) ?: null; + } + return null; + } + ); + $db->method('manipulate')->willReturn(0); + $db->method('insert')->willReturn(1); + + $db->method('replace')->willReturnCallback( + static function (string $table, array $keys, array $values) use (&$replace_calls): int { + if ($table === 'lng_data') { + $replace_calls[] = [$keys['identifier'][1] ?? null, [ + 'value' => $values['value'][1] ?? null, + 'local_change' => $values['local_change'][1] ?? null, + 'remarks' => $values['remarks'][1] ?? null, + ]]; + } + return 1; + } + ); + + $this->setGlobalVariable('ilDB', $db); + + return $db; + } + + /** Only form 1 changed: one merged lng_data row under the plain identifier with the default form's value, not one per form. */ + public function testSaveValuesWritesOneMergedLngDataRowForAChangedPluralForm(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + $unwritten = ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU'], + [] + ); + + $this->assertSame([], $unwritten, 'the overlay of the migrated module must be written'); + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls, 'exactly one lng_data row for the plural message, not one per form'); + $this->assertSame('Einträge NEU', $item_calls[0][1]['value']); + $this->assertNotNull($item_calls[0][1]['local_change'], 'marked as a local change'); + } + + /** A plain key of a plural message maps to its default (last) form: only that form changes in the overlay. */ + public function testSaveValuesMapsAPlainPluralKeyToItsDefaultFormInTheOverlayAndLngData(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + $unwritten = ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'item' => 'X'], + [self::MODULE . $lng->separator . 'item' => 'Bitte prüfen'] + ); + + $this->assertSame([], $unwritten); + $overlay = TranslationCatalog::fromPoFile( + rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE . '/' . self::LANG_KEY . '/' . self::MODULE . '_' . self::LANG_KEY . '.po' + ); + $entry = $overlay->find(null, 'item'); + $this->assertNotNull($entry); + $this->assertSame(['Eintrag', 'X'], $entry->getPluralTranslations(), 'form [0] unchanged, form [1] (default) is the plain value'); + $this->assertSame( + 'Bitte prüfen', + \ILIAS\Language\ComponentTranslation\LocalChangeComments::getRemark($entry) + ); + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('X', $item_calls[0][1]['value']); + $this->assertNotNull($item_calls[0][1]['local_change']); + } + + /** If one resolved form is submitted twice (plain and explicit key) the later one in $a_values wins; here the explicit one. */ + public function testSaveValuesPlainKeyThenExplicitFormKeyTheLaterFormKeyWins(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item' => 'Von Plain-Key', + self::MODULE . $lng->separator . 'item [1]' => 'Von Formzeile', + ], + [] + ); + + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('Von Formzeile', $item_calls[0][1]['value']); + } + + /** Reverse order: the plain identifier comes later and wins. */ + public function testSaveValuesExplicitFormKeyThenPlainKeyTheLaterPlainKeyWins(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [1]' => 'Von Formzeile', + self::MODULE . $lng->separator . 'item' => 'Von Plain-Key', + ], + [] + ); + + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('Von Plain-Key', $item_calls[0][1]['value']); + } + + /** Resubmitting every form with its shipped value writes the shipped default value and no local change. */ + public function testSaveValuesResetsThePluralRowWhenEveryFormMatchesTheShippedValueAgain(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls_1 = []; + $this->mockDatabaseForWrites($replace_calls_1); + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU'], + [] + ); + + $replace_calls_2 = []; + $this->mockDatabaseForWrites($replace_calls_2); + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge', + ], + [] + ); + + $item_calls = array_values(array_filter($replace_calls_2, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('Einträge', $item_calls[0][1]['value']); + $this->assertNull($item_calls[0][1]['local_change'], 'reset to the shipped value is not a local change'); + } + + /** The remark of the merged row is the first posted remark that differs from the stored one, not the first non-empty one. */ + public function testSaveValuesRemarkOfThePluralRowIsTheFirstFormRemarkThatDiffersFromStored(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU', + ], + [self::MODULE . $lng->separator . 'item [0]' => '', self::MODULE . $lng->separator . 'item [1]' => 'Bemerkung zu Form 1'], + ); + + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('Bemerkung zu Form 1', $item_calls[0][1]['remarks']); + } + + /** An empty remark for a form with a non-empty stored remark deletes it (written as NULL). */ + public function testSaveValuesAnEmptyPostedRemarkThatDiffersFromStoredDeletesIt(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls, [ + ['module' => self::MODULE, 'identifier' => 'item', 'remarks' => 'Alte Bemerkung'], + ]); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge', + ], + [self::MODULE . $lng->separator . 'item [0]' => ''], + ); + + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertNull($item_calls[0][1]['remarks']); + } + + /** If no posted remark differs from the stored one, the stored remark is kept. */ + public function testSaveValuesKeepsTheStoredRemarkWhenNoPostedRemarkDiffersFromIt(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls, [ + ['module' => self::MODULE, 'identifier' => 'item', 'remarks' => 'Bestehende Bemerkung'], + ]); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU', + ], + [self::MODULE . $lng->separator . 'item [1]' => 'Bestehende Bemerkung'], + ); + + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('Bestehende Bemerkung', $item_calls[0][1]['remarks']); + } + + /** A pure remark change still writes the lng_data row, with local_change staying null. */ + public function testSaveValuesAPureRemarkChangeWithNoValueChangeStillWritesTheRow(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge', + ], + [self::MODULE . $lng->separator . 'item [1]' => 'Neue Bemerkung'], + ); + + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls, 'the row must be written for the remark alone'); + $this->assertSame('Einträge', $item_calls[0][1]['value']); + $this->assertNull($item_calls[0][1]['local_change'], 'the value itself did not change'); + $this->assertSame('Neue Bemerkung', $item_calls[0][1]['remarks']); + } + + /** Form 0's value changes and form 1's remark changes: the row is written once, with form 1's remark. */ + public function testSaveValuesFormZeroValueChangedFormOneOnlyRemarkChangedTakesTheRemarkFromFormOne(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge', + ], + [self::MODULE . $lng->separator . 'item [1]' => 'Bemerkung aus Form 1'], + ); + + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('Einträge', $item_calls[0][1]['value'], 'the default form (1) itself did not change'); + $this->assertNull($item_calls[0][1]['local_change']); + $this->assertSame('Bemerkung aus Form 1', $item_calls[0][1]['remarks']); + } + + /** A form index the shipped message does not declare (e.g. stale after the Plural-Forms rule shrank) is dropped, not written as a phantom row. */ + public function testSaveValuesDropsAFormKeyWithAnIndexBeyondTheShippedFormCount(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $this->mockDatabaseForWrites($replace_calls); + + $unwritten = ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU', + self::MODULE . $lng->separator . 'item [5]' => 'Geister-Form', + ], + [] + ); + + $this->assertSame([], $unwritten); + $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls, 'exactly one row for the real message - the orphan form must not create its own'); + $this->assertSame('Einträge NEU', $item_calls[0][1]['value']); + } + + /** Deleting a form of a locally changed plural message (a missing form counts as shipped) removes the overlay entry entirely. */ + public function testDeleteValuesOfOneFormRevertsThePluralRowToTheShippedDefaultAndDropsTheOverlayEntry(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $save_calls = []; + $this->mockDatabaseForWrites($save_calls); + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU'], + [] + ); + $overlay = TranslationCatalog::fromPoFile( + rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE . '/' . self::LANG_KEY . '/' . self::MODULE . '_' . self::LANG_KEY . '.po' + ); + $this->assertNotNull($overlay->find(null, 'item'), 'precondition: the overlay holds the local change'); + + $delete_calls = []; + $this->mockDatabaseForWrites($delete_calls); + $unwritten = ilObjLanguageExt::_deleteValues(self::LANG_KEY, [self::MODULE . $lng->separator . 'item [1]' => '']); + + $this->assertSame([], $unwritten); + $item_calls = array_values(array_filter($delete_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls, 'exactly one merged row, not a separate row per form'); + $this->assertSame('Einträge', $item_calls[0][1]['value'], 'back to the shipped default form value'); + $this->assertNull($item_calls[0][1]['local_change']); + + $overlay_after_path = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE . '/' . self::LANG_KEY . '/' . self::MODULE . '_' . self::LANG_KEY . '.po'; + $this->assertFalse(is_file($overlay_after_path), 'no local change left at all - the whole overlay is removed'); + } + + /** The existing remark (from lng_data, a delete posts none) must survive the rewrite of the plural row. */ + public function testDeleteValuesKeepsTheExistingRemarkOfThePluralRow(): void + { + $this->seedShippedPluralModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $save_calls = []; + $this->mockDatabaseForWrites($save_calls); + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [ + self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU', + self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU', + ], + [] + ); + + $delete_calls = []; + $this->mockDatabaseForWrites($delete_calls, [ + ['module' => self::MODULE, 'identifier' => 'item', 'remarks' => 'Alte Bemerkung'], + ]); + ilObjLanguageExt::_deleteValues(self::LANG_KEY, [self::MODULE . $lng->separator . 'item [1]' => '']); + + $item_calls = array_values(array_filter($delete_calls, static fn(array $call): bool => $call[0] === 'item')); + $this->assertCount(1, $item_calls); + $this->assertSame('Alte Bemerkung', $item_calls[0][1]['remarks']); + } +} diff --git a/components/ILIAS/Language/tests/bootstrap.php b/components/ILIAS/Language/tests/Setup/CallbackTranslationCache.php old mode 100755 new mode 100644 similarity index 59% rename from components/ILIAS/Language/tests/bootstrap.php rename to components/ILIAS/Language/tests/Setup/CallbackTranslationCache.php index 74cba2315b78..bdad4e49c06b --- a/components/ILIAS/Language/tests/bootstrap.php +++ b/components/ILIAS/Language/tests/Setup/CallbackTranslationCache.php @@ -16,6 +16,23 @@ * *********************************************************************/ + declare(strict_types=1); -require_once 'vendor/composer/vendor/autoload.php'; -require_once 'ilLanguageBaseTestCase.php'; + +use ILIAS\Language\Setup\TranslationCache; + +/** + * Lets a test observe or influence the invalidation. + */ +final readonly class CallbackTranslationCache implements TranslationCache +{ + public function __construct( + private \Closure $invalidate + ) { + } + + public function invalidate(string $lang_key): void + { + ($this->invalidate)($lang_key); + } +} diff --git a/components/ILIAS/Language/tests/Setup/InstallLanguagesRemovesOverlaysTest.php b/components/ILIAS/Language/tests/Setup/InstallLanguagesRemovesOverlaysTest.php new file mode 100644 index 000000000000..d08011499622 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/InstallLanguagesRemovesOverlaysTest.php @@ -0,0 +1,148 @@ +root = sys_get_temp_dir() . '/ilias_lim_overlay_test_' . bin2hex(random_bytes(6)); + mkdir($this->root . '/lang/customizing', 0775, true); + mkdir($this->root . '/client-data', 0775, true); + $this->directory = MigratedPoFixture::directory(self::MODULE, 'components/pilot/lang/'); + } + + protected function tearDown(): void + { + MigratedPoFixture::removeDirectory($this->root); + } + + private function overlayBase(string $lang_key): string + { + return $this->root . '/client-data/lang/pilot/' . $lang_key . '/pilot_' . $lang_key; + } + + private function seedOverlay(string $lang_key): void + { + MigratedPoFixture::writePair( + $this->overlayBase($lang_key), + MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hallo']) + ); + } + + private function createDatabaseStub(): ilDBInterface + { + $db = $this->createStub(ilDBInterface::class); + $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(InstalledLanguageRepository $repository): LanguageInstallationManager + { + return new LanguageInstallationManager( + $this->createDatabaseStub(), + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + $this->directory + ), + $this->root, + $repository, + null, + new NullTranslationCache(), + fn(): string => $this->root . '/client-data' + ); + } + + /** + * The "no longer requested" branch must call removeOverlays(), and for the dropped key. + */ + public function testDeselectingAPreviouslyInstalledLanguageRemovesItsOverlayFiles(): void + { + $this->seedOverlay('de'); + $this->assertFileExists($this->overlayBase('de') . '.po'); + $this->assertFileExists(MigratedPoFixture::overlayMo($this->overlayBase('de'))); + + $repository = $this->createStub(InstalledLanguageRepository::class); + $repository->method('getAvailableLanguages')->willReturn([ + 'de' => ['obj_id' => 3, 'status' => 'installed'], + ]); + $repository->method('getLocalLanguages')->willReturn([]); + + $manager = $this->createManager($repository); + + // 'de' is no longer requested: uninstalled, including its overlay + $result = $manager->installLanguages([]); + + $this->assertTrue($result); + $this->assertFileDoesNotExist($this->overlayBase('de') . '.po'); + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase('de'))); + } + + /** + * A language that is still requested keeps its overlay: removeOverlays() runs only for the "no longer requested" branch. + */ + public function testStillRequestedLanguageKeepsItsOverlayFiles(): void + { + $this->seedOverlay('de'); + + $repository = $this->createStub(InstalledLanguageRepository::class); + $repository->method('checkLanguage')->willReturn(true); + $repository->method('getAvailableLanguages')->willReturn([ + 'de' => ['obj_id' => 3, 'status' => 'installed'], + ]); + $repository->method('getLocalLanguages')->willReturn([]); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = $this->createManager($repository); + + $result = $manager->installLanguages(['de']); + + $this->assertTrue($result); + $this->assertFileExists($this->overlayBase('de') . '.po'); + $this->assertFileExists(MigratedPoFixture::overlayMo($this->overlayBase('de'))); + } +} diff --git a/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php index 0685d89609b9..c8346b7a50da 100644 --- a/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php +++ b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php @@ -23,7 +23,8 @@ use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\FixedInstallationRoot; -use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; /** @@ -32,9 +33,9 @@ */ class InstalledLanguageDatabaseRepositoryTest extends TestCase { - private function createReadDatabaseMock(): MockObject&ilDBInterface + private function createReadDatabaseMock(): Stub&ilDBInterface { - $db = $this->createMock(ilDBInterface::class); + $db = $this->createStub(ilDBInterface::class); $db->method('quote')->willReturnCallback( static fn(mixed $value, string $type): string => "'" . (string) $value . "'" ); @@ -52,7 +53,7 @@ private function createReadDatabaseMock(): MockObject&ilDBInterface * * @param list> $rows each row as an associative array of column => value */ - private function configureDbToReturnObjectRows(MockObject&ilDBInterface $db, array $rows): void + private function configureDbToReturnObjectRows(Stub&ilDBInterface $db, array $rows): void { $objects = array_map(static fn(array $row): \stdClass => (object) $row, $rows); $objects[] = null; // terminates the while ($row = ...) loop @@ -62,9 +63,9 @@ private function configureDbToReturnObjectRows(MockObject&ilDBInterface $db, arr /** * @param list> $rows each row as an associative array of column => value */ - private function createStatementReturningAssocRows(array $rows): MockObject&ilDBStatement + private function createStatementReturningAssocRows(array $rows): Stub&ilDBStatement { - $statement = $this->createMock(ilDBStatement::class); + $statement = $this->createStub(ilDBStatement::class); $rows[] = null; // terminates the while ($row = ...) loop $statement->method('fetchRow')->willReturn(...$rows); @@ -85,7 +86,7 @@ private function createRepository( public function testGetAvailableLanguagesMapsObjIdAndStatusToTheCorrectLanguageNotSwapped(): void { $db = $this->createReadDatabaseMock(); - $db->method('query')->willReturn($this->createMock(ilDBStatement::class)); + $db->method('query')->willReturn($this->createStub(ilDBStatement::class)); $this->configureDbToReturnObjectRows($db, [ ['title' => 'de', 'description' => 'installed', 'obj_id' => 5], ['title' => 'en', 'description' => 'not_installed', 'obj_id' => 8], @@ -105,7 +106,7 @@ public function testGetAvailableLanguagesMapsObjIdAndStatusToTheCorrectLanguageN public function testGetAvailableLanguagesReturnsEmptyArrayWhenNoLanguagesAreKnown(): void { $db = $this->createReadDatabaseMock(); - $db->method('query')->willReturn($this->createMock(ilDBStatement::class)); + $db->method('query')->willReturn($this->createStub(ilDBStatement::class)); $this->configureDbToReturnObjectRows($db, []); $this->assertSame([], $this->createRepository($db)->getAvailableLanguages()); @@ -118,7 +119,7 @@ public function testGetInstalledLanguagesQueriesForInstalledPrefixAndReturnsTitl $db->method('query')->willReturnCallback( function (string $query) use (&$captured_query): ilDBStatement { $captured_query = $query; - return $this->createMock(ilDBStatement::class); + return $this->createStub(ilDBStatement::class); } ); $this->configureDbToReturnObjectRows($db, [ @@ -140,7 +141,7 @@ public function testGetInstalledLocalLanguagesQueriesForExactlyInstalledLocalAnd $db->method('query')->willReturnCallback( function (string $query) use (&$captured_query): ilDBStatement { $captured_query = $query; - return $this->createMock(ilDBStatement::class); + return $this->createStub(ilDBStatement::class); } ); $this->configureDbToReturnObjectRows($db, [ @@ -333,6 +334,28 @@ public function testGetInstallableLanguagesIgnoresFilesWithAnInvalidLanguageKeyS } } + /** + * The regex ends in `\z`, not `$`, which in PCRE also matches before a trailing newline; file names may + * legally contain "\n". + */ + public function testGetInstallableLanguagesIgnoresAFileNameWithATrailingNewline(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/ilias_de.lang', 'irrelevant content'); + // distinct key "zz": a wrongly accepted trailing "\n" would show up as an extra entry, not collapse into "de" + file_put_contents($root . "/lang/ilias_zz.lang\n", '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(); @@ -433,7 +456,7 @@ public function testCheckLocalLanguageFileReturnsFalseForMalformedLine(): void */ public function testCheckLocalLanguageFileReturnsFalseWhenNoCustomizingDirectoryIsConfiguredAtAll(): void { - $manager = $this->createMock(LanguageFileDirectoryManager::class); + $manager = $this->createStub(LanguageFileDirectoryManager::class); $manager->method('getCustomizingDirectories')->willReturnCallback( static function (): \Generator { return; @@ -450,6 +473,154 @@ static function (): \Generator { $this->assertFalse($repository->checkLocalLanguageFile('de')); } + /** + * checkLanguage() for a migrated module: the shipped `.po` is the only source of its values, so an unparsable one + * invalidates the language. Builds a root with a valid main lang file and a "pilot" component whose shipped de + * `.po` has $po_content (null = no `.po`). + */ + private function checkLanguageWithShippedPo(?string $po_content, bool $local_directory = false): bool + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/ilias_de.lang', "\ncommon#:#yes#:#Ja\n"); + mkdir($root . '/components/pilot/lang', 0777, true); + if ($po_content !== null) { + file_put_contents($root . '/components/pilot/lang/pilot_de.po', $po_content); + } + $pilot = MigratedPoFixture::directory('pilot', 'components/pilot/lang/', $local_directory); + + try { + $repository = new InstalledLanguageDatabaseRepository( + $this->createReadDatabaseMock(), + $local_directory + ? new LanguageFileDirectoryManager($pilot, new MainLanguageFileDirectory()) + : new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory(), $pilot), + new FixedInstallationRoot($root) + ); + return $repository->checkLanguage('de'); + } finally { + $this->removeDirectory($root); + } + } + + public function testCheckLanguageAcceptsAParsableShippedPo(): void + { + $this->assertTrue($this->checkLanguageWithShippedPo("msgctxt \"pilot\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n")); + } + + /** + * Everything gettext/gettext's StrictPoLoader rejects invalidates the language, including what the old parser + * tolerated (a duplicate, a message without msgstr). + */ + #[DataProvider('unparsableShippedPo')] + public function testCheckLanguageRejectsAnUnparsableShippedPo(string $po_content): void + { + $this->assertFalse($this->checkLanguageWithShippedPo($po_content)); + } + + public static function unparsableShippedPo(): array + { + $message = "msgctxt \"pilot\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n"; + + return [ + 'unterminated string' => ["msgctxt \"pilot\"\nmsgid \"abc\nmsgstr \"Hallo\"\n"], + 'duplicate message' => [$message . "\n" . $message], + 'message without msgstr' => ["msgctxt \"pilot\"\nmsgid \"greeting\"\n"], + 'truncated inside a string' => [substr($message, 0, -4)], + 'binary data' => ["\xde\x12\x04\x95\x00\x00\x00\x00"], + 'header value with a line break' => ["msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n\n" . $message], + ]; + } + + /** + * A BOM and CRLF line endings are no reason to refuse the language. + */ + public function testCheckLanguageAcceptsAShippedPoWithBomAndCrLf(): void + { + $this->assertTrue($this->checkLanguageWithShippedPo( + "\xEF\xBB\xBFmsgid \"\"\r\nmsgstr \"\"\r\n\"Language: de\\n\"\r\n\r\nmsgctxt \"pilot\"\r\nmsgid \"greeting\"\r\nmsgstr \"Hallo\"\r\n" + )); + } + + public function testCheckLanguageAcceptsAModuleWithoutShippedPoForTheLanguage(): void + { + $this->assertTrue($this->checkLanguageWithShippedPo(null)); + } + + public function testCheckLanguageDoesNotParsePoFilesOfTheLocalDirectory(): void + { + $this->assertTrue($this->checkLanguageWithShippedPo("msgid \"abc\n", true)); + } + + /** + * An empty `.po` is a valid, empty catalog. + */ + public function testCheckLanguageAcceptsAnEmptyShippedPo(): void + { + $this->assertTrue($this->checkLanguageWithShippedPo('')); + } + + /** + * checkShippedLanguage() is checkLanguage() without the customizing directory: an invalid "ilias_de.lang.local" + * must not block it. + */ + public function testCheckShippedLanguageIgnoresAnInvalidCustomizingFileThatMakesCheckLanguageFail(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/ilias_de.lang', "\ncommon#:#yes#:#Ja\n"); + file_put_contents($root . '/lang/customizing/ilias_de.lang.local', "\nonly_one_field\n"); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + + $this->assertTrue($repository->checkShippedLanguage('de'), 'the invalid customizing file is never consulted'); + $this->assertFalse($repository->checkLanguage('de'), 'precondition: the same file makes checkLanguage() fail'); + } finally { + $this->removeDirectory($root); + } + } + + /** + * An invalid global "ilias_de.lang" is part of getDirectories(), so checkShippedLanguage() fails like checkLanguage(). + */ + public function testCheckShippedLanguageRejectsAnInvalidGlobalLanguageFile(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/ilias_de.lang', "common#:#yes#:#Ja\n"); + + try { + $repository = $this->createRepository($this->createReadDatabaseMock(), $root); + + $this->assertFalse($repository->checkShippedLanguage('de')); + } finally { + $this->removeDirectory($root); + } + } + + /** + * An unparsable shipped `.po` of a PO-maintained module is part of getDirectories() too, so + * checkShippedLanguage() fails like checkLanguage(). + */ + public function testCheckShippedLanguageRejectsAnUnparsableShippedPo(): void + { + $root = $this->createTempInstallationRoot(); + file_put_contents($root . '/lang/ilias_de.lang', "\ncommon#:#yes#:#Ja\n"); + mkdir($root . '/components/pilot/lang', 0777, true); + file_put_contents($root . '/components/pilot/lang/pilot_de.po', "msgctxt \"pilot\"\nmsgid \"abc\nmsgstr \"Hallo\"\n"); + $pilot = MigratedPoFixture::directory('pilot', 'components/pilot/lang/'); + + try { + $repository = new InstalledLanguageDatabaseRepository( + $this->createReadDatabaseMock(), + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory(), $pilot), + new FixedInstallationRoot($root) + ); + + $this->assertFalse($repository->checkShippedLanguage('de')); + } finally { + $this->removeDirectory($root); + } + } + private function createTempInstallationRoot(): string { $dir = sys_get_temp_dir() . '/ilias_lang_repo_test_' . bin2hex(random_bytes(8)); @@ -468,7 +639,7 @@ private function removeDirectory(string $dir): void continue; } $path = $dir . '/' . $item; - if (is_dir($path)) { + if (is_dir($path) && !is_link($path)) { $this->removeDirectory($path); } else { unlink($path); diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerDatabaseRowsOfMigratedModuleTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerDatabaseRowsOfMigratedModuleTest.php new file mode 100644 index 000000000000..771ed1208103 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerDatabaseRowsOfMigratedModuleTest.php @@ -0,0 +1,227 @@ + $rows + * @param array $final_entries + * @param array $shipped_entries + * @param array $remarks identifier => remark (see migratedModuleRemarks()), + * written into the row instead of the buffered rows' own remark + * @return array + */ + private function call(array $rows, array $final_entries, array $shipped_entries, array $remarks = []): array + { + $method = (new ReflectionClass(LanguageInstallationManager::class))->getMethod('databaseRowsOfMigratedModule'); + + return $method->invoke(null, $rows, $final_entries, $shipped_entries, $remarks); + } + + private const SHIPPED = [ + 'poll_population [0]' => 'Eine Stimmabgabe', + 'poll_population [1]' => '%s Stimmabgaben', + 'poll_population_singular' => 'Eine Stimmabgabe', + ]; + + /** + * Two buffered form rows fold into one row under the plain identifier, with the default form's (msgstr[1], + * count >= 2) value of the module's final content. + */ + public function testTwoFormRowsFoldIntoOneRowWithTheDefaultFormsValue(): void + { + $rows = [ + 'poll_population [0]' => ['Eine Stimmabgabe', null, null], + 'poll_population [1]' => ['%s NEUE Stimmabgaben', null, null], + ]; + $final_entries = [ + 'poll_population [0]' => 'Eine Stimmabgabe', + 'poll_population [1]' => '%s NEUE Stimmabgaben', + ]; + + $result = $this->call($rows, $final_entries, self::SHIPPED); + + $this->assertSame(['poll_population' => ['%s NEUE Stimmabgaben', null, null]], $result); + } + + /** + * A plain row of an identifier that is not a plural message of the shipped state (here the separately shipped + * "poll_population_singular", see pluralMessageOf()) is kept untouched. + */ + public function testAPlainRowOfAnUnrelatedIdentifierIsKeptUnchanged(): void + { + $rows = ['poll_population_singular' => ['Eine Stimmabgabe', '2024-01-01 00:00:00', 'Anmerkung']]; + + $result = $this->call($rows, ['poll_population_singular' => 'Eine Stimmabgabe'], self::SHIPPED); + + $this->assertSame( + ['poll_population_singular' => ['Eine Stimmabgabe', '2024-01-01 00:00:00', 'Anmerkung']], + $result + ); + } + + /** + * $remarks (from migratedModuleRemarks()) overrides the remark of the buffered rows, for plain identifiers and + * merged plural rows alike. + */ + public function testRemarksParameterOverridesTheRowsOwnRemarkForAPlainIdentifier(): void + { + $rows = ['poll_population_singular' => ['Eine Stimmabgabe', '2024-01-01 00:00:00', 'Alte Anmerkung']]; + + $result = $this->call( + $rows, + ['poll_population_singular' => 'Eine Stimmabgabe'], + self::SHIPPED, + ['poll_population_singular' => 'Neue Anmerkung'] + ); + + $this->assertSame( + ['poll_population_singular' => ['Eine Stimmabgabe', '2024-01-01 00:00:00', 'Neue Anmerkung']], + $result + ); + } + + public function testRemarksParameterOverridesTheMergedRemarkOfAPluralMessage(): void + { + $rows = [ + 'poll_population [0]' => ['Eine Stimmabgabe', null, null], + 'poll_population [1]' => ['%s Stimmabgaben', null, 'Alte Anmerkung'], + ]; + $final_entries = ['poll_population [0]' => 'Eine Stimmabgabe', 'poll_population [1]' => '%s Stimmabgaben']; + + $result = $this->call($rows, $final_entries, self::SHIPPED, ['poll_population' => 'Neue Anmerkung']); + + $this->assertSame('Neue Anmerkung', $result['poll_population'][2]); + } + + /** + * An identifier not named in $remarks keeps the rows' own remark. + */ + public function testAnIdentifierNotInRemarksKeepsTheRowsOwnRemark(): void + { + $rows = ['poll_population_singular' => ['Eine Stimmabgabe', null, 'Eigene Anmerkung']]; + + $result = $this->call($rows, ['poll_population_singular' => 'Eine Stimmabgabe'], self::SHIPPED, []); + + $this->assertSame('Eigene Anmerkung', $result['poll_population_singular'][2]); + } + + /** + * local_change depends on the merged default-form VALUE differing from the shipped default, not on a stale + * local_change timestamp of some row. Remarks are independent of that: the first non-null one wins, here from + * the second row. + */ + public function testLocalChangeIsNullWhenTheMergedDefaultFormValueMatchesTheShippedOne(): void + { + $rows = [ + 'poll_population [0]' => ['Eine Stimmabgabe', null, null], + 'poll_population [1]' => ['%s Stimmabgaben', '2024-01-01 00:00:00', 'lokal geändert'], + ]; + // identical to self::SHIPPED - the message did not actually change + $final_entries = ['poll_population [0]' => 'Eine Stimmabgabe', 'poll_population [1]' => '%s Stimmabgaben']; + + $result = $this->call($rows, $final_entries, self::SHIPPED); + + $this->assertSame( + ['poll_population' => ['%s Stimmabgaben', null, 'lokal geändert']], + $result + ); + } + + /** + * The first non-null local_change/remarks among the form rows wins once the merged value differs; a later null + * must not erase it. + */ + public function testTheFirstNonNullLocalChangeAndRemarksOfTheFormRowsAreKeptWhenTheValueActuallyChanged(): void + { + $rows = [ + 'poll_population [0]' => ['Eine Stimmabgabe', null, null], + 'poll_population [1]' => ['%s NEUE Stimmabgaben', '2024-01-01 00:00:00', 'lokal geändert'], + ]; + $final_entries = ['poll_population [0]' => 'Eine Stimmabgabe', 'poll_population [1]' => '%s NEUE Stimmabgaben']; + + $result = $this->call($rows, $final_entries, self::SHIPPED); + + $this->assertSame( + ['poll_population' => ['%s NEUE Stimmabgaben', '2024-01-01 00:00:00', 'lokal geändert']], + $result + ); + } + + /** + * Same, with the local_change-carrying row processed first. + */ + public function testAnEarlierNonNullLocalChangeSurvivesALaterRowsNullOnesWhenTheValueActuallyChanged(): void + { + $rows = [ + 'poll_population [1]' => ['%s NEUE Stimmabgaben', '2024-01-01 00:00:00', 'lokal geändert'], + 'poll_population [0]' => ['Eine Stimmabgabe', null, null], + ]; + $final_entries = ['poll_population [0]' => 'Eine Stimmabgabe', 'poll_population [1]' => '%s NEUE Stimmabgaben']; + + $result = $this->call($rows, $final_entries, self::SHIPPED); + + $this->assertSame( + ['poll_population' => ['%s NEUE Stimmabgaben', '2024-01-01 00:00:00', 'lokal geändert']], + $result + ); + } + + /** + * A form row whose message has no value in the final content is dropped instead of written with a bogus value. + */ + public function testFormRowsWithNoValueInTheFinalContentAreDroppedEntirely(): void + { + $rows = [ + 'poll_population [0]' => ['Eine Stimmabgabe', null, null], + 'poll_population [1]' => ['%s Stimmabgaben', null, null], + 'poll_population_singular' => ['Eine Stimmabgabe', null, null], + ]; + // final content no longer mentions "poll_population" at all + $final_entries = ['poll_population_singular' => 'Eine Stimmabgabe']; + + $result = $this->call($rows, $final_entries, self::SHIPPED); + + $this->assertArrayNotHasKey('poll_population', $result); + $this->assertSame(['poll_population_singular' => ['Eine Stimmabgabe', null, null]], $result); + } + + /** + * A single-form language (nplurals=1) still folds into one row keyed by its identifier. + */ + public function testASingleFormLanguageStillFoldsIntoOneRow(): void + { + $shipped = ['poll_population [0]' => 'ある投票']; + $rows = ['poll_population [0]' => ['ある投票NEU', null, null]]; + $final_entries = ['poll_population [0]' => 'ある投票NEU']; + + $result = $this->call($rows, $final_entries, $shipped); + + $this->assertSame(['poll_population' => ['ある投票NEU', null, null]], $result); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedAfterWriteTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedAfterWriteTest.php new file mode 100644 index 000000000000..84086c3340ce --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedAfterWriteTest.php @@ -0,0 +1,289 @@ +shipPo(['greeting' => 'Hallo']); + $this->collation_rows = [ + ['module' => 'common', 'lang_array' => serialize(['yes' => 'Ja'])], + ['module' => self::MODULE, 'lang_array' => 'a:1:{s:8:"greeting";s:5:"Hal'], + ]; + $invalidated = []; + $cwd = getcwd(); + + try { + $this->manager(null, static function (string $lang_key) use (&$invalidated): void { + $invalidated[] = $lang_key; + })->insertLanguageForInstallation('de'); + $this->fail('Expected a LanguageDataNotSavedException'); + } catch (LanguageDataNotSavedException $e) { + $this->assertSame(409, $e->getCode()); + $this->assertStringContainsString("'pilot'", $e->getMessage()); + $this->assertStringContainsString("'de'", $e->getMessage()); + } + + $this->assertSame([], $invalidated, 'no cache invalidation for data that was not saved correctly'); + $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'no overlay for data that was not saved correctly'); + $this->assertSame($cwd, getcwd(), 'the working directory is restored on the exception path'); + // counts only the collation query of the lng_modules write; migratedModuleRemarks() issues its own lng_data SELECT beforehand + $collation_query = array_values(array_filter( + $this->queries, + static fn(string $q): bool => str_starts_with($q, 'SELECT') && str_contains($q, 'lang_array') && str_contains($q, 'lng_modules') + )); + $this->assertCount(1, $collation_query); + $this->assertStringContainsString("module IN ('pilot')", $collation_query[0]); + } + + public function testASerializedFalseIsAlsoDetectedAsNotSaved(): void + { + $this->writeLang('lang/ilias_de.lang', ['common#:#yes#:#Ja']); + $this->collation_rows = [['module' => 'common', 'lang_array' => serialize(false)]]; + + $this->expectException(LanguageDataNotSavedException::class); + $this->manager()->insertLanguageForInstallation('de'); + } + + public function testTheLanguageCacheIsInvalidatedWithTheLanguageKeyBeforeTheOverlayIsSynced(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $overlay_po = $this->overlayBase() . '.po'; + $calls = []; + + $this->manager(null, function (string $lang_key) use (&$calls, $overlay_po): void { + $calls[] = [$lang_key, count(array_filter($this->queries, static fn(string $q): bool => str_starts_with($q, /** @lang text */ 'INSERT INTO lng_modules'))), is_file($overlay_po)]; + })->insertLanguageForInstallation('de'); + + $this->assertSame([['de', 1, false]], $calls, 'once, after the lng_modules write, before the overlay sync'); + } + + public function testAFailingCacheInvalidatorIsLoggedAndSwallowed(): void + { + $this->expectErrorLog(); + $this->shipPo(['greeting' => 'Hallo']); + $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]); + + $this->manager(null, static function (): void { + throw new RuntimeException('cache is down'); + })->insertLanguageForInstallation('de'); + + $this->assertSame(['greeting' => 'Hallo'], $this->lngModules()[self::MODULE]); + // a shipped value writes no file; the sync still running shows in the removal of an outdated overlay + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase()), 'the overlay sync still ran: it removed the outdated overlay'); + $this->assertStringContainsString('cache is down', $this->errorLog()); + } + + /** + * Root-proof overlay failure (a file where the overlay's "lang" directory must be created): logged, + * the database write stays. + */ + public function testAnOverlaySyncFailureIsLoggedAndTheDatabaseWriteStays(): void + { + $this->expectErrorLog(); + $this->shipPo(['greeting' => 'Hallo']); + // a local change: only then an overlay has to be written (delta) + $this->db_local_changes = [self::MODULE => ['greeting' => 'Servus']]; + rmdir($this->root . '/client-data'); + mkdir($this->root . '/client-data'); + file_put_contents($this->root . '/client-data/lang', 'not a directory'); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $unwritten = $this->manager()->insertLanguageForInstallation('de'); + } finally { + restore_error_handler(); + } + + $this->assertSame(['greeting' => 'Servus'], $this->lngModules()[self::MODULE]); + $this->assertStringContainsString('Could not sync migrated language file for module "pilot"', $this->errorLog()); + $this->assertSame([self::MODULE], $unwritten, 'the failed module is returned so callers can report it'); + } + + /** + * The same failure is returned by all three write paths - and only the migrated module whose + * overlay failed is listed, never a non-migrated one written in the same run. + */ + #[DataProvider('writePaths')] + public function testEveryWritePathReturnsOnlyTheModulesWhoseOverlayCouldNotBeWritten(string $method, bool $writes_overlay): void + { + if ($writes_overlay) { + $this->expectErrorLog(); + } + $this->shipPo(['greeting' => 'Hallo']); + $this->writeLang('lang/ilias_de.lang', ['common#:#yes#:#Ja']); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['pilot#:#greeting#:#Servus']); + $this->db_entries = [self::MODULE => ['greeting' => 'Hallo'], 'common' => ['yes' => 'Ja']]; + file_put_contents($this->root . '/client-data/lang', 'not a directory'); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $unwritten = $this->manager()->$method('de'); + } finally { + restore_error_handler(); + } + + // "remove local changes" leaves only shipped values: no overlay to write, nothing can fail + $this->assertSame($writes_overlay ? [self::MODULE] : [], $unwritten); + } + + #[DataProvider('writePaths')] + public function testEveryWritePathReturnsNothingWhenTheOverlayWasWritten(string $method, bool $writes_overlay): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->writeLang('lang/ilias_de.lang', ['common#:#yes#:#Ja']); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['pilot#:#greeting#:#Servus']); + $this->db_entries = [self::MODULE => ['greeting' => 'Hallo'], 'common' => ['yes' => 'Ja']]; + + $this->assertSame([], $this->manager()->$method('de')); + // only a path that keeps the customizing value writes an overlay + $this->assertSame($writes_overlay, is_file(MigratedPoFixture::overlayMo($this->overlayBase()))); + } + + /** + * @return array> + */ + public static function writePaths(): array + { + return [ + 'install/update' => ['insertLanguageForInstallation', true], + 'remove local changes' => ['insertLanguageForRemovingLocalChanges', false], + 'apply local changes' => ['insertLanguageForApplyingLocalChanges', true], + ]; + } + + /** + * No overlay is maintained without client data directory - that is not a failure to report. + */ + public function testWithoutClientDataDirNothingIsReturnedAsUnwritten(): void + { + $this->shipPo(['greeting' => 'Hallo']); + + $this->assertSame([], $this->manager(static fn(): ?string => null)->insertLanguageForInstallation('de')); + } + + /** + * Checked against the client data directory the instance maintains the overlay in (resolved at + * call time), for the migrated modules of the given languages only. + */ + public function testFindUnwritableOverlayDirectoriesChecksTheResolvedClientDataDir(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $blocked = $this->root . '/blocked'; + mkdir($blocked); + file_put_contents($blocked . '/lang', 'not a directory'); + $target = $this->root . '/client-data'; + $manager = $this->manager(static function () use (&$target): ?string { + return $target; + }); + + $this->assertSame([], $manager->findUnwritableOverlayDirectories(['de'])); + + $target = $blocked; + $this->assertSame([$blocked . '/lang/pilot/de'], $manager->findUnwritableOverlayDirectories(['de'])); + $this->assertSame([], $manager->findUnwritableOverlayDirectories(['en']), 'pilot is not migrated for en'); + + $target = null; + $this->assertSame([], $manager->findUnwritableOverlayDirectories(['de'])); + } + + public function testTheResolversClientDataDirIsUsedAndResolvedAtCallTime(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $elsewhere = $this->root . '/elsewhere'; + mkdir($elsewhere); + $target = null; + $manager = $this->manager(static function () use (&$target): ?string { + return $target; + }); + + $target = $elsewhere; + // a local change, so there is an overlay to write (delta) + $this->db_local_changes = [self::MODULE => ['greeting' => 'Servus']]; + $manager->insertLanguageForInstallation('de'); + + $this->assertFileExists(MigratedPoFixture::overlayMo($elsewhere . '/lang/pilot/de/pilot_de')); + $this->assertDirectoryDoesNotExist($this->root . '/client-data/lang'); + } + + #[DataProvider('noClientDataDir')] + public function testWithoutClientDataDirTheDatabaseIsWrittenButNoOverlay(bool $with_resolver): void + { + $this->shipPo(['greeting' => 'Hallo']); + + $this->manager(static fn(): ?string => null, null, $with_resolver)->insertLanguageForInstallation('de'); + + $this->assertSame(['greeting' => 'Hallo'], $this->lngModules()[self::MODULE]); + $this->assertDirectoryDoesNotExist($this->root . '/client-data/lang'); + } + + /** + * @return array> + */ + public static function noClientDataDir(): array + { + return ['resolver returns null' => [true], 'no resolver' => [false]]; + } + + /** + * Shipped ilias_fa.lang/ilias_nl.lang contain duplicates: the first occurrence wins, the update + * does not abort, and the duplicates are reported. + */ + public function testADuplicateInOneFileKeepsTheFirstOccurrenceAndIsReported(): void + { + $this->expectErrorLog(); + $this->writeLang('lang/ilias_de.lang', [ + 'common#:#yes#:#Ja', + 'common#:#yes#:#Doppelt', + 'common#:#no#:#Nein', + ]); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['yes' => 'Ja', 'no' => 'Nein'], $this->lngModules()['common']); + $this->assertSame(['value' => 'Ja', 'local_change' => null], $this->lngData()['common|yes']); + $this->assertStringContainsString('Duplicate language entries for language "de"', $this->errorLog()); + $this->assertStringContainsString('common#:#yes', $this->errorLog()); + } + + public function testTheSameEntryInTwoDifferentFilesIsNotADuplicate(): void + { + $this->writeLang('lang/ilias_de.lang', ['common#:#yes#:#Ja']); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['common#:#yes#:#Jawohl']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['yes' => 'Jawohl'], $this->lngModules()['common']); + $this->assertStringNotContainsString('Duplicate', $this->errorLog()); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedDroppedKeysTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedDroppedKeysTest.php new file mode 100644 index 000000000000..56561780c123 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedDroppedKeysTest.php @@ -0,0 +1,200 @@ +shipPo(['farewell' => 'Tschüss']); // 'greeting' is no longer shipped + $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]); + $this->db_local_changes = [self::MODULE => ['greeting' => 'Hallo']]; + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertArrayNotHasKey('greeting', $this->lngModules()[self::MODULE]); + $this->assertSame(['greeting'], $this->droppedIdentifiers(self::MODULE)); + // nothing local is left, so the overlay is removed completely + $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'dropped from the overlay as well'); + } + + /** + * A real local change must survive its key disappearing from the shipped `.po`. + */ + public function testAKeyDroppedFromTheShippedPoButWithARealLocalChangeIsKept(): void + { + $this->shipPo(['farewell' => 'Tschüss']); + $this->seedOverlay(['greeting' => [ + 'value' => 'Servus', + 'original' => 'Hallo', + 'local_change' => '2025-05-05T05:05:05Z', + ]]); + $this->db_local_changes = [self::MODULE => ['greeting' => 'Servus']]; + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame('Servus', $this->lngModules()[self::MODULE]['greeting']); + $this->assertSame([], $this->droppedIdentifiers(self::MODULE), 'a real local change is never dropped'); + $this->assertSame('Servus', $this->overlayPo()->find(null, 'greeting')->getTranslation()); + } + + /** + * A corrupt overlay must not be treated as L === O: no three-way decision is possible, so the entry stays. + */ + public function testAKeyDroppedFromTheShippedPoWithACorruptOverlayIsKept(): void + { + $this->expectErrorLog(); + $this->shipPo(['farewell' => 'Tschüss']); + $this->seedOverlay(['farewell' => ['value' => 'Tschüss', 'original' => 'Tschüss']]); + file_put_contents($this->overlayBase() . '.po', "msgid \"kaputt\n"); + $this->db_local_changes = [self::MODULE => ['greeting' => 'Hallo']]; + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame('Hallo', $this->lngModules()[self::MODULE]['greeting']); + $this->assertSame([], $this->droppedIdentifiers(self::MODULE)); + } + + /** + * A customizing value equal to the previously shipped one must not be dropped as an unchanged entry once its + * key disappears from the shipped `.po`. + */ + public function testACustomizingValueForAKeyDroppedFromTheShippedPoStaysLocal(): void + { + $this->shipPo(['farewell' => 'Tschüss']); + $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['pilot#:#greeting#:#Hallo']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame('Hallo', $this->lngModules()[self::MODULE]['greeting']); + $this->assertSame(['value' => 'Hallo', 'local_change' => self::NOW], $this->lngData()['pilot|greeting']); + $this->assertSame([], $this->droppedIdentifiers(self::MODULE)); + } + + /** + * MigratedLanguageFileSync::sync() must preserve "original" for an entry the `.po` currently lacks; without O, + * the three-way decision once the key ships again would keep the stale local value. + */ + public function testAKeyThatReturnsAfterBeingUnshippedUsesThePreservedOriginalOnUpdate(): void + { + $this->shipPo(['farewell' => 'Tschüss']); // 'greeting' not shipped this run + $this->seedOverlay(['greeting' => [ + 'value' => 'Angepasst', + 'original' => 'Hallo', + 'local_change' => '2025-05-05T05:05:05Z', + ]]); + $this->db_local_changes = [self::MODULE => ['greeting' => 'Angepasst']]; + + $this->manager()->insertLanguageForInstallation('de'); + $this->assertSame( + 'Angepasst', + $this->lngModules()[self::MODULE]['greeting'], + 'sanity: kept in run 1 because L != O' + ); + + $this->queries = []; + $this->shipPo(['greeting' => 'Neu', 'farewell' => 'Tschüss']); // the key ships again, with a new value + $this->db_local_changes = [self::MODULE => ['greeting' => 'Hallo']]; // reverted to the (preserved) original + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame( + 'Neu', + $this->lngModules()[self::MODULE]['greeting'], + 'L === preserved O, only S changed -> S wins, thanks to the original surviving the unshipped run' + ); + $this->assertSame(['value' => 'Neu', 'local_change' => null], $this->lngData()['pilot|greeting']); + } + + /** + * The whole shipped `.po` missing and later back: meanwhile the module is not migrated and falls back to the + * plain `.lang` file, and the overlay stays byte-for-byte untouched even though an update runs. + */ + public function testAWholeMissingShippedPoIsBridgedByThePreservedOverlayAcrossTwoUpdates(): void + { + $this->seedOverlay([ + 'A' => ['value' => 'A1', 'original' => 'A1'], + 'B' => ['value' => 'B1', 'original' => 'B1'], + 'C' => ['value' => 'C_custom', 'original' => 'C1', 'local_change' => '2025-01-01T00:00:00Z'], + ]); + $po_before_the_gap = file_get_contents($this->overlayBase() . '.po'); + $mo_before_the_gap = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())); + // no shipped `.po` for the whole gap: the module falls back to its plain `.lang` file + $this->writeLang('components/pilot/lang/ilias_de.lang', ['A#:#A1', 'B#:#B1', 'C#:#C_custom']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame( + ['A' => 'A1', 'B' => 'B1', 'C' => 'C_custom'], + $this->lngModules()[self::MODULE], + 'sanity: the .lang fallback is used while the .po is missing' + ); + $this->assertSame( + ['value' => 'C_custom', 'local_change' => null], + $this->lngData()['pilot|C'], + 'the DB no longer sees C as a local change while the module is not migrated' + ); + $this->assertSame($po_before_the_gap, file_get_contents($this->overlayBase() . '.po'), 'the overlay is untouched during the gap'); + $this->assertSame($mo_before_the_gap, file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())), 'the overlay is untouched during the gap'); + + // the module is migrated again - A's shipped value changed, B's did not, C is not shipped + // with the (never-shipped) customized value at all + $this->queries = []; + $this->shipPo(['A' => 'A2', 'B' => 'B1', 'C' => 'C1']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame( + ['C' => 'C_custom', 'A' => 'A2', 'B' => 'B1'], + $this->lngModules()[self::MODULE] + ); + $this->assertSame(['value' => 'A2', 'local_change' => null], $this->lngData()['pilot|A'], 'L === preserved O -> S wins'); + $this->assertSame(['value' => 'B1', 'local_change' => null], $this->lngData()['pilot|B'], 'unchanged'); + $this->assertSame( + ['value' => 'C_custom', 'local_change' => '2025-01-01 00:00:00'], + $this->lngData()['pilot|C'], + 'the overlay-only local change is taken over again - the known limitation above' + ); + // A and B carry the shipped values and leave the full legacy overlay, only the local change C stays + $overlay = $this->overlayPo(); + $this->assertNull($overlay->find(null, 'A')); + $this->assertNull($overlay->find(null, 'B')); + $this->assertSame('A2', $this->effective()['A']['value']); + $this->assertSame('C_custom', $overlay->find(null, 'C')->getTranslation()); + $this->assertNotNull(LocalChangeComments::getLocalChange($overlay->find(null, 'C'))); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedLocalChangesActionsTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedLocalChangesActionsTest.php new file mode 100644 index 000000000000..a9beec5147f0 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedLocalChangesActionsTest.php @@ -0,0 +1,194 @@ +shipPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss']); + $this->seedOverlay([ + 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2025-05-05T05:05:05Z'], + 'farewell' => ['value' => 'Tschüss', 'original' => 'Tschüss'], + 'added_locally' => ['value' => 'Eigene Variable', 'local_change' => '2025-06-06T06:06:06Z'], + ]); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['pilot#:#greeting#:#Aus Customizing']); + + $this->manager()->insertLanguageForRemovingLocalChanges('de'); + + $this->assertSame( + [ + 'pilot|greeting' => ['value' => 'Hallo', 'local_change' => null], + 'pilot|farewell' => ['value' => 'Tschüss', 'local_change' => null], + ], + $this->lngData() + ); + $this->assertSame(['greeting' => 'Hallo', 'farewell' => 'Tschüss'], $this->lngModules()[self::MODULE]); + // "remove local changes" leaves no local change: the overlay is removed + $this->assertFileDoesNotExist($this->overlayBase() . '.po'); + $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase())); + $effective = $this->effective(); + $this->assertSame(['greeting', 'farewell'], array_keys($effective), 'the locally added variable is gone'); + $this->assertSame('Hallo', $effective['greeting']['value']); + $this->assertFalse($effective['greeting']['local_change']); + } + + /** + * Applying local changes reconciles a migrated module with its shipped `.po` like an update: + * the not locally changed entries are rewritten from the shipped `.po` (lng_data rows without + * local_change), the customizing file goes on top as local change and the overlay follows. + */ + public function testApplyingLocalChangesPutsTheCustomizingValueOnTopOfTheStoredEntries(): void + { + $this->shipPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss']); + $this->seedOverlay([ + 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'], + 'farewell' => ['value' => 'Tschüss', 'original' => 'Tschüss'], + ]); + $this->db_entries = [self::MODULE => ['greeting' => 'Hallo', 'farewell' => 'Tschüss'], 'common' => ['yes' => 'Ja']]; + $this->writeLang('lang/customizing/ilias_de.lang.local', ['pilot#:#greeting#:#Grüß Gott']); + + $this->manager()->insertLanguageForApplyingLocalChanges('de'); + + $this->assertSame( + [ + 'pilot|greeting' => ['value' => 'Grüß Gott', 'local_change' => self::NOW], + 'pilot|farewell' => ['value' => 'Tschüss', 'local_change' => null], + ], + $this->lngData() + ); + $this->assertSame( + [self::MODULE => ['greeting' => 'Grüß Gott', 'farewell' => 'Tschüss'], 'common' => ['yes' => 'Ja']], + $this->lngModules() + ); + $greeting = $this->overlayPo()->find(null, 'greeting'); + $this->assertSame('Grüß Gott', $greeting->getTranslation()); + $this->assertSame('Hallo', LocalChangeComments::getOriginal($greeting)); + $this->assertNotNull(LocalChangeComments::getLocalChange($greeting)); + } + + /** + * The pre-INSERT "DELETE FROM lng_data WHERE ... local_change IS NULL AND module IN (...)" + * ($delete_unchanged_migrated_rows) replaces flushing first: without it a shipped key dropped from the `.po` + * between two "apply local changes" runs keeps its stale row. + */ + public function testApplyingLocalChangesReconcilesEveryCaseInOneSweepAndPurgesTheStaleUnchangedRow(): void + { + $this->shipPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss', 'obsolete' => 'Alt']); + $this->manager()->insertLanguageForInstallation('de'); + $this->assertSame( + ['value' => 'Alt', 'local_change' => null], + $this->lngData()['pilot|obsolete'], + 'sanity: the row exists after the first install' + ); + + $this->shipPo(['greeting' => 'Servus', 'farewell' => 'Pfiat di']); // 'obsolete' dropped from the shipped po + $this->seedOverlay([ + 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'], + 'farewell' => ['value' => 'Auf Wiedersehen', 'original' => 'Tschüss', 'local_change' => '2025-04-04T04:04:04Z'], + 'added_locally' => ['value' => 'Eigene Var', 'local_change' => '2025-03-03T03:03:03Z'], + ]); + $this->db_local_changes = [self::MODULE => ['farewell' => 'Auf Wiedersehen']]; + $this->db_entries = [ + 'common' => ['yes' => 'Ja', 'no' => 'Nein'], + self::MODULE => ['greeting' => 'Hallo', 'farewell' => 'Tschüss', 'obsolete' => 'Alt'], + ]; + + $this->manager()->insertLanguageForApplyingLocalChanges('de'); + + $this->assertSame( + 'Servus', + $this->lngModules()[self::MODULE]['greeting'], + 'stored "Hallo", shipped now "Servus" -> "Servus"' + ); + $this->assertSame( + 'Auf Wiedersehen', + $this->lngModules()[self::MODULE]['farewell'], + 'a real local change (L != S != O) is kept' + ); + $this->assertSame( + 'Eigene Var', + $this->lngModules()[self::MODULE]['added_locally'], + 'an overlay-only local change is taken over' + ); + $this->assertArrayNotHasKey('obsolete', $this->lngModules()[self::MODULE]); + $this->assertSame( + ['yes' => 'Ja', 'no' => 'Nein'], + $this->lngModules()['common'], + 'a non-migrated module in the seed stays complete' + ); + + $this->assertSame(['value' => 'Servus', 'local_change' => null], $this->lngData()['pilot|greeting']); + $this->assertSame( + ['value' => 'Eigene Var', 'local_change' => '2025-03-03 03:03:03'], + $this->lngData()['pilot|added_locally'] + ); + $this->assertArrayNotHasKey( + 'pilot|obsolete', + $this->lngData(), + 'the DELETE ... local_change IS NULL bulk purge took effect: the stale unchanged row is gone' + ); + $this->assertNotEmpty( + array_filter( + $this->queries, + static fn(string $q): bool => str_starts_with($q, 'DELETE FROM lng_data') + && str_contains($q, 'local_change IS NULL') + && str_contains($q, "identifier NOT IN ('") + ), + 'the bulk delete of unchanged migrated rows was actually issued' + ); + } + + /** + * Written first, purged after: an aborted write must leave the former rows, not a gap. Both + * happen under one table lock of lng_data. + */ + public function testApplyingLocalChangesWritesTheRowsBeforeItPurgesTheStaleOnes(): void + { + $this->shipPo(['greeting' => 'Hallo', 'obsolete' => 'Alt']); + $this->manager()->insertLanguageForInstallation('de'); + $this->shipPo(['greeting' => 'Servus']); + $already_recorded = count($this->queries); + + $this->manager()->insertLanguageForApplyingLocalChanges('de'); + + $statements = []; + foreach (array_slice($this->queries, $already_recorded) as $query) { + if (str_starts_with($query, 'INSERT INTO lng_data')) { + $statements[] = 'write'; + } elseif (str_starts_with($query, 'DELETE FROM lng_data') && str_contains($query, 'local_change IS NULL')) { + $statements[] = 'purge'; + } + } + $this->assertSame(['write', 'purge'], array_values(array_unique($statements))); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedModulesTestCase.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedModulesTestCase.php new file mode 100644 index 000000000000..04f1f774ae2d --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedModulesTestCase.php @@ -0,0 +1,413 @@ + */ + protected array $quoted = []; + /** @var list */ + protected array $queries = []; + /** @var list>|null rows the collation check reads, null = what was written */ + protected ?array $collation_rows = null; + /** @var list> */ + protected array $pending_rows = []; + /** @var list> rows of "SELECT module, identifier, local_change FROM lng_data" */ + protected array $local_change_rows = []; + + /** @var array> */ + protected array $db_local_changes = []; + /** @var array> */ + protected array $db_entries = []; + + protected function setUp(): void + { + // never the build of the installation, see MigratedPoFixture::resetRuntime() + MigratedPoFixture::resetRuntime(); + $this->root = sys_get_temp_dir() . '/ilias_lim_test_' . bin2hex(random_bytes(6)); + mkdir($this->root . '/lang/customizing', 0775, true); + mkdir($this->root . '/client-data', 0775, true); + $this->directory = MigratedPoFixture::directory(self::MODULE, 'components/pilot/lang/'); + } + + protected function tearDown(): void + { + MigratedPoFixture::removeDirectory($this->root); + MigratedPoFixture::resetRuntime(); + } + + protected function db(): ilDBInterface + { + $statement = $this->createStub(ilDBStatement::class); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(function (mixed $value, string $type): string { + $this->quoted[] = $value; + return 'Q' . (count($this->quoted) - 1) . 'Q'; + }); + $db->method('in')->willReturnCallback( + static fn(string $field, array $values, bool $negate = false): string => + $field . ($negate ? " NOT IN ('" : " IN ('") . implode("','", $values) . "')" + ); + // the "apply local changes" write runs under a table lock: the callable runs right away + $db->method('buildAtomQuery')->willReturnCallback(function () use ($db): ilAtomQuery { + $atom_query = $this->createStub(ilAtomQuery::class); + $atom_query->method('addQueryCallable')->willReturnCallback( + static function (callable $query) use ($db): void { + $query($db); + } + ); + return $atom_query; + }); + $db->method('manipulate')->willReturnCallback(function (string $query): int { + $this->queries[] = $query; + return 1; + }); + $db->method('query')->willReturnCallback(function (string $query) use ($statement): ilDBStatement { + $this->queries[] = $query; + if (str_starts_with($query, 'SELECT module, identifier, local_change FROM lng_data')) { + $this->pending_rows = $this->local_change_rows; + return $statement; + } + $this->pending_rows = $this->collation_rows ?? array_map( + static fn(string $module, array $lang_array): array => ['module' => $module, 'lang_array' => serialize($lang_array)], + array_keys($this->lngModules()), + $this->lngModules() + ); + return $statement; + }); + $db->method('fetchAssoc')->willReturnCallback(fn(): ?array => array_shift($this->pending_rows)); + $db->method('now')->willReturn('NOW()'); + $db->method('nextId')->willReturn(1); + + return $db; + } + + protected function repository(): InstalledLanguageRepository + { + $repository = $this->createStub(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturnCallback( + fn(string $lang_key, string $min_date = ''): array => $min_date === '' ? $this->db_local_changes : [] + ); + $repository->method('getLanguageEntries')->willReturnCallback(fn(): array => $this->db_entries); + + return $repository; + } + + protected function manager( + ?\Closure $client_data_dir_resolver = null, + ?\Closure $language_cache_invalidator = null, + bool $with_resolver = true + ): LanguageInstallationManager { + $client_data_dir = $this->root . '/client-data'; + return new LanguageInstallationManager( + $this->db(), + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + $this->directory + ), + $this->root, + $this->repository(), + static fn(): \DateTimeImmutable => new \DateTimeImmutable(self::NOW, new \DateTimeZone('UTC')), + $language_cache_invalidator === null + ? new NullTranslationCache() + : new CallbackTranslationCache($language_cache_invalidator), + $with_resolver ? ($client_data_dir_resolver ?? static fn(): string => $client_data_dir) : null + ); + } + + /** + * @param array> $entries + */ + protected function shipPo(array $entries, string $lang_key = 'de'): void + { + MigratedPoFixture::writePo( + $this->root . '/components/pilot/lang/pilot_' . $lang_key . '.po', + MigratedPoFixture::catalog(self::MODULE, $entries) + ); + } + + /** + * @param array> $entries + */ + protected function seedOverlay(array $entries): void + { + MigratedPoFixture::writePair($this->overlayBase(), MigratedPoFixture::catalog(self::MODULE, $entries)); + } + + protected function overlayBase(): string + { + return $this->root . '/client-data/lang/pilot/de/pilot_de'; + } + + protected function overlayPo(): TranslationCatalog + { + return MigratedPoFixture::readPo($this->overlayBase() . '.po'); + } + + /** + * Shipped `.po` plus overlay delta, as served (MigratedLanguageFileSync::loadModuleTranslations()) - + * the overlay itself only holds the entries that differ from the shipped `.po`. + * + * @return array + */ + protected function effective(): array + { + return MigratedLanguageFileSync::loadModuleTranslations( + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory), + 'de', + self::MODULE, + $this->root . '/client-data', + $this->root + ) ?? []; + } + + /** + * @param list $lines raw lines after the header marker + */ + protected function writeLang(string $relative_file, array $lines): void + { + $file = $this->root . '/' . $relative_file; + if (!is_dir(dirname($file))) { + mkdir(dirname($file), 0775, true); + } + file_put_contents($file, "header\n\n" . implode("\n", $lines) . "\n"); + } + + protected function resolveToken(string $token): mixed + { + return $this->quoted[(int) trim($token, 'Q')]; + } + + /** + * @return array "module|identifier" => row + * (last write wins, like ON DUPLICATE KEY UPDATE) + */ + protected function lngData(): array + { + $rows = []; + foreach ($this->queries as $query) { + if (str_starts_with($query, /** @lang text */ 'INSERT INTO lng_data')) { + preg_match_all('/\((Q\d+Q),(Q\d+Q),(Q\d+Q),(Q\d+Q),(Q\d+Q),(Q\d+Q)\)/', $query, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + $key = $this->resolveToken($match[1]) . '|' . $this->resolveToken($match[2]); + $rows[$key] = ['value' => $this->resolveToken($match[4]), 'local_change' => $this->resolveToken($match[5])]; + } + } elseif (str_starts_with($query, /** @lang text */ 'DELETE FROM lng_data')) { + $rows = $this->applyLngDataDelete($query, $rows); + } + } + + return $rows; + } + + /** + * Applies one recorded "DELETE FROM lng_data" to $rows, in the two shapes LanguageInstallationManager issues: + * the bulk delete of unchanged rows of a migrated module ("... local_change IS NULL AND module IN (...)") and the + * per-module delete of dropped identifiers ("... AND module = ? AND identifier IN (...)"). Both use $ilDB->in(), + * whose stub embeds the raw values literally instead of quote() tokens. + * + * @param array $rows + * @return array + */ + protected function applyLngDataDelete(string $query, array $rows): array + { + // the purge after "apply local changes" wrote a migrated module: its unchanged rows that + // were not written + if (preg_match( + '/^DELETE FROM lng_data WHERE lang_key = Q\d+Q AND local_change IS NULL AND module = (Q\d+Q)(?: AND identifier NOT IN \(\'(.*)\'\))?$/', + $query, + $matches + ) === 1) { + $module = $this->resolveToken($matches[1]); + $written = isset($matches[2]) ? explode("','", $matches[2]) : []; + foreach ($rows as $key => $row) { + [$row_module, $identifier] = explode('|', $key, 2); + if ($row_module === $module && $row['local_change'] === null && !in_array($identifier, $written, true)) { + unset($rows[$key]); + } + } + return $rows; + } + + if (preg_match( + '/^DELETE FROM lng_data WHERE lang_key = Q\d+Q AND local_change IS NULL AND module IN \(\'(.*)\'\)$/', + $query, + $matches + ) === 1) { + $modules = explode("','", $matches[1]); + foreach ($rows as $key => $row) { + [$module] = explode('|', $key, 2); + if (in_array($module, $modules, true) && $row['local_change'] === null) { + unset($rows[$key]); + } + } + return $rows; + } + + if (preg_match( + '/^DELETE FROM lng_data WHERE lang_key = Q\d+Q AND module = (Q\d+Q) AND identifier IN \(\'(.*)\'\)$/', + $query, + $matches + ) === 1) { + $module = $this->resolveToken($matches[1]); + foreach (explode("','", $matches[2]) as $identifier) { + unset($rows[$module . '|' . $identifier]); + } + return $rows; + } + + return $rows; + } + + /** + * Identifiers of $module dropped via the per-module "DELETE FROM lng_data ... AND identifier IN (...)", as + * opposed to keys merely absent because they were never touched. + * + * @return list + */ + protected function droppedIdentifiers(string $module): array + { + $dropped = []; + foreach ($this->queries as $query) { + if ( + preg_match( + '/^DELETE FROM lng_data WHERE lang_key = Q\d+Q AND module = (Q\d+Q) AND identifier IN \(\'(.*)\'\)$/', + $query, + $matches + ) === 1 + && $this->resolveToken($matches[1]) === $module + ) { + $dropped = array_merge($dropped, explode("','", $matches[2])); + } + } + + return $dropped; + } + + /** + * @return array> module => identifier => value + */ + protected function lngModules(): array + { + $modules = []; + foreach ($this->queries as $query) { + if (!str_starts_with($query, /** @lang text */ 'INSERT INTO lng_modules')) { + continue; + } + preg_match_all('/\((Q\d+Q),(Q\d+Q),(Q\d+Q)\)/', $query, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + $modules[$this->resolveToken($match[1])] = unserialize($this->resolveToken($match[3])); + } + } + + return $modules; + } + + /** + * What error_log() wrote during this test so far - PHPUnit redirects error_log to a capture file + * per test (call expectErrorLog() in tests that expect logging, so it is not echoed). + */ + protected function errorLog(): string + { + $file = (string) ini_get('error_log'); + return $file !== '' && is_file($file) ? (string) file_get_contents($file) : ''; + } + + protected const string TWO_FORMS = 'nplurals=2; plural=(n != 1);'; + protected const string THREE_FORMS = 'nplurals=3; plural=(n == 1 ? 0 : n >= 2 && n <= 4 ? 1 : 2);'; + + /** + * @param array|string> $entries + */ + protected function shipPluralPo(array $entries, string $header): void + { + $catalog = new TranslationCatalog(); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + $catalog->setHeader('Plural-Forms', $header); + foreach ($entries as $identifier => $value) { + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry(self::MODULE, (string) $identifier); + if (is_array($value)) { + $entry->setPlural($identifier . '_plural', $value); + } else { + $entry->translate($value); + } + $catalog->add($entry); + } + MigratedPoFixture::writePo($this->root . '/components/pilot/lang/pilot_de.po', $catalog); + } + + /** + * @param array $flat_values + */ + protected function writeLocalOverlay(array $flat_values): void + { + MigratedLanguageFileSync::sync( + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory), + $this->root, + 'de', + self::MODULE, + $flat_values, + $this->root . '/client-data' + ); + } + + /** + * @param array $entries identifier => local value (original "Hallo") + * @param list $merged identifiers marked as merged into the shipped file + */ + protected function seedOverlayMarkedAsMerged(array $entries, array $merged): void + { + $details = []; + foreach ($entries as $identifier => $value) { + $details[$identifier] = ['value' => $value, 'original' => 'Hallo', 'local_change' => '2025-04-04T04:04:04Z']; + } + $catalog = MigratedPoFixture::catalog(self::MODULE, $details); + foreach ($merged as $identifier) { + LocalChangeComments::setMergedIntoShipped($catalog->find(self::MODULE, $identifier), true); + } + MigratedPoFixture::writePair($this->overlayBase(), $catalog); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedPluralFormsTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedPluralFormsTest.php new file mode 100644 index 000000000000..90a84fdd4472 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedPluralFormsTest.php @@ -0,0 +1,159 @@ + 3 forms, e.g. CLDR 49), the overlay is served as written until + * the next reconciling write (its `.mo` is consistent with the old rule), while the admin view already has the new + * number of forms, with the local value on every form that serves the old quantities. + */ + public function testAnExistingOverlayIsServedWithItsOwnRuleUntilTheNextReconcilingWrite(): void + { + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge']], self::TWO_FORMS); + $this->writeLocalOverlay(['item [0]' => 'Eintrag', 'item [1]' => 'LOKAL-VIELE']); + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge-few', 'Einträge-viele']], self::THREE_FORMS); + + $served = (string) file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())); + $this->assertSame(self::TWO_FORMS, MigratedPoFixture::moPluralFormsHeader($served)); + $this->assertSame(['item' => ['Eintrag', 'LOKAL-VIELE']], MigratedPoFixture::readMoPluralForms($served)); + + $effective = $this->effective(); + $this->assertSame(['item [0]', 'item [1]', 'item [2]'], array_keys($effective)); + $this->assertSame('LOKAL-VIELE', $effective['item [1]']['value']); + $this->assertTrue($effective['item [1]']['local_change']); + $this->assertSame('LOKAL-VIELE', $effective['item [2]']['value'], 'by meaning: n = 0 and 5+ were served by the local form'); + $this->assertTrue($effective['item [2]']['local_change']); + $this->assertSame('Eintrag', $effective['item [0]']['value']); + $this->assertFalse($effective['item [0]']['local_change']); + } + + /** + * After the write the overlay follows the new rule: new header, exactly the new number of forms, local value and + * unrelated local change kept; lng_data and lng_modules agree; a second run changes nothing. + */ + public function testInstallAfterTheNumberOfPluralFormsGrewRewritesTheOverlayWithTheNewRuleAndKeepsLocalValues(): void + { + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge'], 'plain' => 'X'], self::TWO_FORMS); + $this->writeLocalOverlay(['item [0]' => 'Eintrag', 'item [1]' => 'LOKAL-VIELE', 'plain' => 'LOKAL-PLAIN']); + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge-few', 'Einträge-viele'], 'plain' => 'X'], self::THREE_FORMS); + + $this->assertSame([], $this->manager()->insertLanguageForInstallation('de')); + + $overlay = $this->overlayPo(); + $this->assertSame(self::THREE_FORMS, $overlay->getHeader('Plural-Forms')); + $item = $overlay->find(null, 'item'); + $this->assertNotNull($item); + $this->assertCount(3, $item->getPluralTranslations()); + $this->assertContains('LOKAL-VIELE', $item->getPluralTranslations(), 'the local value must not be lost'); + $this->assertSame('LOKAL-PLAIN', $overlay->find(null, 'plain')?->getTranslation()); + $served = (string) file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())); + $this->assertSame(self::THREE_FORMS, MigratedPoFixture::moPluralFormsHeader($served)); + $this->assertCount(3, MigratedPoFixture::readMoPluralForms($served)['item']); + + $data = $this->lngData(); + $this->assertSame('LOKAL-PLAIN', $data['pilot|plain']['value']); + $this->assertNotNull($data['pilot|plain']['local_change']); + $this->assertSame($data['pilot|item']['value'], $this->lngModules()[self::MODULE]['item']); + $this->assertSame('LOKAL-PLAIN', $this->lngModules()[self::MODULE]['plain']); + + $forms = $item->getPluralTranslations(); + $local_change = LocalChangeComments::getLocalChange($item); + $this->manager()->insertLanguageForInstallation('de'); + $again = $this->overlayPo()->find(null, 'item'); + $this->assertSame($forms, $again?->getPluralTranslations(), 'the second run changes no form'); + $this->assertSame($local_change, LocalChangeComments::getLocalChange($again), 'nor the local change'); + } + + /** + * 1 -> 2 forms (CLDR 49 change of "vi"): both new forms carry the local value. + */ + public function testInstallAfterASingleFormLanguageGotASecondFormGivesTheLocalValueToBothForms(): void + { + $this->shipPluralPo(['item' => ['Eintrag']], 'nplurals=1; plural=0;'); + $this->writeLocalOverlay(['item [0]' => 'LOKAL']); + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge']], self::TWO_FORMS); + + $this->assertSame([], $this->manager()->insertLanguageForInstallation('de')); + + $overlay = $this->overlayPo(); + $this->assertSame(self::TWO_FORMS, $overlay->getHeader('Plural-Forms')); + $this->assertSame(['LOKAL', 'LOKAL'], $overlay->find(null, 'item')?->getPluralTranslations()); + } + + /** + * 3 -> 2 forms: the local default form survives, the middle form's local value has no equivalent form and is + * replaced by the shipped value, with a log entry; a second run neither changes it nor warns again. + */ + public function testInstallAfterTheNumberOfPluralFormsShrankKeepsTheLocalDefaultFormAndLogsTheLostValue(): void + { + $this->expectErrorLog(); + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge-few', 'Einträge-viele'], 'plain' => 'X'], self::THREE_FORMS); + $this->writeLocalOverlay([ + 'item [0]' => 'Eintrag', + 'item [1]' => 'LOKAL-FEW', + 'item [2]' => 'LOKAL-VIELE', + 'plain' => 'LOKAL-PLAIN', + ]); + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge'], 'plain' => 'X'], self::TWO_FORMS); + + $this->assertSame([], $this->manager()->insertLanguageForInstallation('de')); + + $overlay = $this->overlayPo(); + $this->assertSame(self::TWO_FORMS, $overlay->getHeader('Plural-Forms')); + $item = $overlay->find(null, 'item'); + $this->assertSame(['Eintrag', 'LOKAL-VIELE'], $item?->getPluralTranslations(), 'the local default form is not lost'); + $this->assertSame('LOKAL-PLAIN', $overlay->find(null, 'plain')?->getTranslation()); + $this->assertSame('Einträge', LocalChangeComments::getOriginal($item, 1), 'the original is the shipped value of the new rule'); + + $served = (string) file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())); + $this->assertSame(self::TWO_FORMS, MigratedPoFixture::moPluralFormsHeader($served)); + $this->assertSame(['item' => ['Eintrag', 'LOKAL-VIELE']], MigratedPoFixture::readMoPluralForms($served)); + + $data = $this->lngData(); + $this->assertSame('LOKAL-VIELE', $data['pilot|item']['value']); + $this->assertNotNull($data['pilot|item']['local_change']); + $this->assertSame('LOKAL-VIELE', $this->lngModules()[self::MODULE]['item']); + $this->assertSame('LOKAL-PLAIN', $this->lngModules()[self::MODULE]['plain']); + + $log = $this->errorLog(); + $this->assertStringContainsString('item [1]', $log, 'the value that found no form is logged'); + $this->assertStringNotContainsString('item [2]', $log); + $this->assertStringNotContainsString('item [0]', $log); + + $po_before = (string) file_get_contents($this->overlayBase() . '.po'); + $this->manager()->insertLanguageForInstallation('de'); + $this->assertSame($po_before, (string) file_get_contents($this->overlayBase() . '.po'), 'the second run changes nothing'); + $this->assertSame($log, $this->errorLog(), 'and does not warn again'); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedReconciliationTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedReconciliationTest.php new file mode 100644 index 000000000000..980cc5be16a4 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedReconciliationTest.php @@ -0,0 +1,378 @@ +shipPo(['greeting' => $shipped]); + if ($original !== null) { + $this->seedOverlay(['greeting' => [ + 'value' => $local ?? $original, + 'original' => $original, + 'local_change' => $local !== null && $local !== $original ? '2025-05-05T05:05:05Z' : null, + ]]); + } + if ($local !== null) { + $this->db_local_changes = [self::MODULE => ['greeting' => $local]]; + } + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame($expected_value, $this->lngModules()[self::MODULE]['greeting']); + if ($expected_row_written_as_shipped) { + $this->assertSame(['value' => $shipped, 'local_change' => null], $this->lngData()['pilot|greeting']); + } else { + $this->assertArrayNotHasKey('pilot|greeting', $this->lngData(), 'the local lng_data row is kept as it is'); + } + // S is served from the shipped .po; only a winning L stays in the overlay, with "original" moved to S + $this->assertSame($expected_value, $this->effective()['greeting']['value']); + if ($expected_value === $shipped) { + $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'no local change - no overlay'); + return; + } + $overlay_entry = $this->overlayPo()->find(null, 'greeting'); + $this->assertSame($expected_value, $overlay_entry->getTranslation()); + $this->assertSame($shipped, LocalChangeComments::getOriginal($overlay_entry), 'original always moves to S'); + $this->assertNotNull(LocalChangeComments::getLocalChange($overlay_entry)); + } + + /** + * @return array> + */ + public static function threeWayCases(): array + { + return [ + 'no L -> S' => [null, 'Alt', 'Neu', 'Neu', true], + 'no L, no overlay -> S' => [null, null, 'Neu', 'Neu', true], + 'L === S -> S, no longer local' => ['Neu', 'Alt', 'Neu', 'Neu', true], + 'L === O, only S changed -> S' => ['Alt', 'Alt', 'Neu', 'Neu', true], + 'L differs from O and S -> L wins' => ['Eigen', 'Alt', 'Neu', 'Eigen', false], + 'L differs, S unchanged -> L wins' => ['Eigen', 'Alt', 'Alt', 'Eigen', false], + 'L without overlay -> L wins' => ['Eigen', null, 'Neu', 'Eigen', false], + ]; + } + + /** + * A customizing-file value always stays local, even when it equals the previously shipped value. + */ + public function testAValueFromTheCustomizingFileAlwaysStaysLocal(): void + { + $this->shipPo(['greeting' => 'Neu']); + $this->seedOverlay(['greeting' => ['value' => 'Alt', 'original' => 'Alt']]); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['pilot#:#greeting#:#Alt']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['value' => 'Alt', 'local_change' => self::NOW], $this->lngData()['pilot|greeting']); + $this->assertSame('Alt', $this->lngModules()[self::MODULE]['greeting']); + $this->assertNotNull(LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'greeting'))); + } + + /** + * The overlay holds only the identifiers the customizing file mentions, not other shipped entries of the module. + */ + public function testACustomizingFileOnlyPutsItsOwnKeysIntoTheDelta(): void + { + $this->shipPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss']); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['pilot#:#greeting#:#Aus Customizing']); + + $this->manager()->insertLanguageForInstallation('de'); + + $overlay_identifiers = array_map( + static fn($entry): string => $entry->getId(), + $this->overlayPo()->getEntries() + ); + $this->assertSame(['greeting'], $overlay_identifiers); + $this->assertSame('Tschüss', $this->lngModules()[self::MODULE]['farewell']); + } + + /** + * A customizing entry with markup that TranslationMarkupPolicy disallows and that really changes something is + * left out and reported via getSkippedInvalidMarkupEntries(); the rest of the run is installed. Uses the + * non-migrated module "common": the check is independent of the PO/MO pilot. + */ + public function testACustomizingEntryWithDisallowedMarkupIsSkippedButTheRestIsInstalled(): void + { + $this->expectErrorLog(); + $this->writeLang('lang/ilias_de.lang', ['common#:#yes#:#Ja', 'common#:#bad#:#Shipped harmless']); + $this->writeLang('lang/customizing/ilias_de.lang.local', [ + 'common#:#yes#:#Jawohl', + 'common#:#bad#:#', + ]); + + $manager = $this->manager(); + $unwritten = $manager->insertLanguageForInstallation('de'); + + $this->assertSame([], $unwritten, 'a skipped customizing entry must not itself count as an unwritten overlay'); + $this->assertSame('Jawohl', $this->lngModules()['common']['yes'], 'the valid customizing entry is applied'); + $this->assertSame( + 'Shipped harmless', + $this->lngModules()['common']['bad'], + 'the invalid customizing entry is left out - the shipped value stays in effect' + ); + $skipped = $manager->getSkippedInvalidMarkupEntries(); + $this->assertArrayHasKey('de', $skipped); + $this->assertArrayHasKey('common#:#bad', $skipped['de']); + $this->assertNotEmpty($skipped['de']['common#:#bad']); + $this->assertArrayNotHasKey('common#:#yes', $skipped['de']); + } + + /** + * getSkippedInvalidMarkupEntries() reports only what the LAST insertLanguage...() call left out; a fixed or + * removed customizing file must empty the report again. + */ + public function testGetSkippedInvalidMarkupEntriesIsResetOnEveryInsertLanguageCall(): void + { + $this->expectErrorLog(); + $this->writeLang('lang/ilias_de.lang', ['common#:#bad#:#Shipped harmless']); + $this->writeLang('lang/customizing/ilias_de.lang.local', ['common#:#bad#:#']); + $manager = $this->manager(); + $manager->insertLanguageForInstallation('de'); + $this->assertNotEmpty($manager->getSkippedInvalidMarkupEntries()['de'] ?? []); + + // the customizing file is fixed (no more invalid markup) and the language is installed again + $this->writeLang('lang/customizing/ilias_de.lang.local', ['common#:#bad#:#Jetzt gültig']); + $manager->insertLanguageForInstallation('de'); + + $this->assertArrayNotHasKey('de', $manager->getSkippedInvalidMarkupEntries()); + $this->assertSame('Jetzt gültig', $this->lngModules()['common']['bad']); + } + + /** + * An overlay-only local change (e.g. lng_data lost) is taken over on install/update, with its overlay timestamp. + */ + public function testAnOverlayOnlyLocalChangeIsTakenOverOnInstall(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->seedOverlay([ + 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2025-05-05T05:05:05Z'], + 'added_locally' => ['value' => 'Eigene Variable', 'local_change' => '2025-06-06T06:06:06Z'], + 'orphan' => ['value' => 'Weder geshippt noch lokal', 'original' => 'Weder geshippt noch lokal'], + ]); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['value' => 'Servus', 'local_change' => '2025-05-05 05:05:05'], $this->lngData()['pilot|greeting']); + $this->assertSame( + ['value' => 'Eigene Variable', 'local_change' => '2025-06-06 06:06:06'], + $this->lngData()['pilot|added_locally'] + ); + $this->assertSame( + ['greeting' => 'Servus', 'added_locally' => 'Eigene Variable'], + $this->lngModules()[self::MODULE] + ); + $this->assertNull($this->overlayPo()->find(null, 'orphan'), 'neither shipped nor local: dropped'); + $this->assertSame( + '2025-05-05T05:05:05Z', + LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'greeting')), + 'the original timestamp of the local change survives' + ); + } + + /** + * An overlay local change with an unparsable timestamp is still a local change, recorded with the current time. + */ + public function testAnOverlayOnlyLocalChangeWithAnUnparsableTimestampIsRecordedWithTheCurrentTime(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->seedOverlay([ + 'greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => 'yesterday'], + ]); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['value' => 'Servus', 'local_change' => self::NOW], $this->lngData()['pilot|greeting']); + } + + /** + * An entry entering the overlay with this run keeps the time of its local change from lng_data.local_change (UTC). + */ + public function testANewOverlayEntryCarriesTheLocalChangeDateOfTheDatabaseRow(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->db_local_changes = [self::MODULE => ['greeting' => 'Servus']]; + $this->local_change_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'local_change' => '2024-03-04 05:06:07'], + ]; + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame('Servus', $this->overlayPo()->find(null, 'greeting')?->getTranslation()); + $this->assertSame( + '2024-03-04T05:06:07Z', + LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'greeting')) + ); + $this->assertNotSame( + self::NOW, + LocalChangeComments::getLocalChangeAsDatabaseTimestamp($this->overlayPo()->find(null, 'greeting')) + ); + } + + public function testALocalEntryWithoutShippedCounterpartIsKept(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->db_local_changes = [self::MODULE => ['custom' => 'Eigene Variable']]; + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['custom' => 'Eigene Variable', 'greeting' => 'Hallo'], $this->lngModules()[self::MODULE]); + $custom = $this->overlayPo()->find(null, 'custom'); + $this->assertNull(LocalChangeComments::getOriginal($custom)); + $this->assertNotNull(LocalChangeComments::getLocalChange($custom)); + } + + /** + * An overlay entry marked as merged holds the shipped value now: a new shipped value wins and the entry leaves + * the overlay. + */ + public function testANewShippedValueWinsOverAnOverlayEntryMarkedAsMerged(): void + { + $this->shipPo(['greeting' => 'Grüß Gott']); + $this->seedOverlayMarkedAsMerged(['greeting' => 'Servus'], ['greeting']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['value' => 'Grüß Gott', 'local_change' => null], $this->lngData()['pilot|greeting']); + $this->assertSame('Grüß Gott', $this->lngModules()[self::MODULE]['greeting']); + $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'no overlay left'); + } + + public function testWithoutTheMarkTheLocalValueStaysALocalChange(): void + { + $this->shipPo(['greeting' => 'Grüß Gott']); + $this->seedOverlayMarkedAsMerged(['greeting' => 'Servus'], []); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame('Servus', $this->lngData()['pilot|greeting']['value']); + $this->assertNotNull($this->lngData()['pilot|greeting']['local_change']); + } + + public function testAMarkedEntryEqualToTheShippedValueIsNoLocalChange(): void + { + $this->shipPo(['greeting' => 'Servus']); + $this->seedOverlayMarkedAsMerged(['greeting' => 'Servus'], ['greeting']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['value' => 'Servus', 'local_change' => null], $this->lngData()['pilot|greeting']); + } + + /** + * The mark of a plural message holds for all its forms. + */ + public function testNewShippedFormsWinOverAPluralMessageMarkedAsMerged(): void + { + $this->shipPluralPo(['item' => ['Eintrag', 'Einträge']], self::TWO_FORMS); + $this->writeLocalOverlay(['item [0]' => 'Servus-Eintrag', 'item [1]' => 'Servus-Einträge']); + $overlay = $this->overlayPo(); + LocalChangeComments::setMergedIntoShipped($overlay->find(null, 'item') ?? $overlay->find(self::MODULE, 'item'), true); + MigratedPoFixture::writePair($this->overlayBase(), $overlay); + $this->shipPluralPo(['item' => ['Neu-Eintrag', 'Neu-Einträge']], self::TWO_FORMS); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['value' => 'Neu-Einträge', 'local_change' => null], $this->lngData()['pilot|item']); + $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'no overlay left'); + } + + /** + * With a multi-line shipped value the overlay "original" (O) is read back losslessly, so L equal to O counts as + * "only S changed" and S wins; a flattened O would keep L as a false local change. + */ + public function testAMultiLineLocalValueEqualToTheOriginalIsReplacedByTheNewShippedValue(): void + { + $this->shipPo(['greeting' => "Neu\nZeile 2"]); + $this->seedOverlay(['greeting' => ['value' => "Alt\r\nZeile 2", 'original' => "Alt\r\nZeile 2"]]); + $this->db_local_changes = [self::MODULE => ['greeting' => "Alt\r\nZeile 2"]]; + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame("Neu\nZeile 2", $this->lngModules()[self::MODULE]['greeting']); + $this->assertSame(['value' => "Neu\nZeile 2", 'local_change' => null], $this->lngData()['pilot|greeting']); + // S wins, so the legacy overlay entry is removed + $this->assertFileDoesNotExist($this->overlayBase() . '.po'); + $this->assertFalse($this->effective()['greeting']['local_change']); + } + + /** + * An unchanged multi-line shipped value is no local change after an update, in lng_data and overlay, also on a + * second run. + */ + public function testAnUnchangedMultiLineShippedValueStaysUnchangedAcrossUpdates(): void + { + $this->shipPo(['greeting' => "Zeile 1\nZeile 2 mit C:\\pfad"]); + + $this->manager()->insertLanguageForInstallation('de'); + $this->queries = []; + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['value' => "Zeile 1\nZeile 2 mit C:\\pfad", 'local_change' => null], $this->lngData()['pilot|greeting']); + // recognised as the shipped value: no overlay on either run + $this->assertFileDoesNotExist($this->overlayBase() . '.po'); + $this->assertFalse($this->effective()['greeting']['local_change']); + } + + /** + * A corrupt overlay `.po` must not block an update: it is ignored (logged) and rebuilt afterwards. + */ + public function testACorruptOverlayIsIgnoredAndRebuiltOnUpdate(): void + { + $this->expectErrorLog(); + $this->shipPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss']); + $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]); + file_put_contents($this->overlayBase() . '.po', "msgid \"kaputt\n"); + $this->db_local_changes = [self::MODULE => ['farewell' => 'Pfiat di']]; + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['farewell' => 'Pfiat di', 'greeting' => 'Hallo'], $this->lngModules()[self::MODULE]); + $overlay = $this->overlayPo(); + $this->assertSame('Pfiat di', $overlay->find(null, 'farewell')->getTranslation()); + $this->assertSame('Tschüss', LocalChangeComments::getOriginal($overlay->find(null, 'farewell'))); + // "greeting" carries the shipped value and is not written + $this->assertSame(['farewell' => 'Pfiat di'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase()))); + $this->assertStringContainsString('Could not read the overlay of migrated module "pilot"', $this->errorLog()); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php new file mode 100644 index 000000000000..2e26ea129ce9 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php @@ -0,0 +1,246 @@ +> $shipped_migrated_modules + * @param array> $customized_remarks + * @param array> $shipped_line_comments + * @return array> + */ + private function callMigratedModuleRemarks( + array $shipped_migrated_modules, + array $customized_remarks = [], + array $shipped_line_comments = [] + ): array { + $manager = $this->manager(); + $method = new ReflectionMethod(LanguageInstallationManager::class, 'migratedModuleRemarks'); + + return $method->invoke( + $manager, + 'de', + $shipped_migrated_modules, + $customized_remarks, + $shipped_line_comments, + $this->root . '/client-data' + ); + } + + private function seedOverlayWithRemark(string $identifier, string $value, string $remark): void + { + $catalog = new TranslationCatalog(); + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry(self::MODULE, $identifier); + $entry->translate($value); + LocalChangeComments::setRemark($entry, $remark); + $catalog->add($entry); + MigratedPoFixture::writePair($this->overlayBase(), $catalog); + } + + /** + * Merge precedence: lng_data is the base, the overlay overrides it, the customizing file overrides both. + */ + public function testMigratedModuleRemarksMergesLngDataOverlayAndCustomizingWithCustomizingWinning(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->seedOverlayWithRemark('greeting', 'Hallo', 'Overlay-Bemerkung'); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'DB-Bemerkung'], + ]; + + $remarks = $this->callMigratedModuleRemarks( + [self::MODULE => ['greeting' => 'Hallo']], + [self::MODULE => ['greeting' => 'Customizing-Bemerkung']] + ); + + $this->assertSame(['greeting' => 'Customizing-Bemerkung'], $remarks[self::MODULE]); + } + + public function testMigratedModuleRemarksOverlayWinsOverLngDataWithoutACustomizingRemark(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->seedOverlayWithRemark('greeting', 'Hallo', 'Overlay-Bemerkung'); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'DB-Bemerkung'], + ]; + + $remarks = $this->callMigratedModuleRemarks([self::MODULE => ['greeting' => 'Hallo']]); + + $this->assertSame(['greeting' => 'Overlay-Bemerkung'], $remarks[self::MODULE]); + } + + public function testMigratedModuleRemarksFallsBackToLngDataWithoutAnOverlayOrCustomizingRemark(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'DB-Bemerkung'], + ]; + + $remarks = $this->callMigratedModuleRemarks([self::MODULE => ['greeting' => 'Hallo']]); + + $this->assertSame(['greeting' => 'DB-Bemerkung'], $remarks[self::MODULE]); + } + + public function testMigratedModuleRemarksIsEmptyWithoutAnyRemarkAnywhere(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->collation_rows = []; + + $remarks = $this->callMigratedModuleRemarks([self::MODULE => ['greeting' => 'Hallo']]); + + $this->assertSame([], $remarks[self::MODULE] ?? []); + } + + /** + * A lng_data remark that is only the carried-over "###" comment of the pre-migration `.lang` line is no remark. + */ + public function testMigratedModuleRemarksExcludesALngDataRemarkThatMatchesTheShippedLineComment(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'Alter Zeilenkommentar'], + ]; + + $remarks = $this->callMigratedModuleRemarks( + [self::MODULE => ['greeting' => 'Hallo']], + [], + [self::MODULE => ['greeting' => 'Alter Zeilenkommentar']] + ); + + $this->assertArrayNotHasKey('greeting', $remarks[self::MODULE] ?? []); + } + + /** + * Same exclusion for the shipped `.po`'s own "#." note of the identifier. + */ + public function testMigratedModuleRemarksExcludesALngDataRemarkThatMatchesTheShippedPoNote(): void + { + $catalog = new TranslationCatalog(); + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry(self::MODULE, 'greeting'); + $entry->translate('Hallo'); + $entry->addExtractedComment('PO-Notiz'); + $catalog->add($entry); + MigratedPoFixture::writePo($this->root . '/components/pilot/lang/pilot_de.po', $catalog); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'PO-Notiz'], + ]; + + $remarks = $this->callMigratedModuleRemarks([self::MODULE => ['greeting' => 'Hallo']]); + + $this->assertArrayNotHasKey('greeting', $remarks[self::MODULE] ?? []); + } + + /** + * A remark taken over by "merge" is an extracted comment (`#.`) of the shipped entry now: an overlay remark equal + * to it (trimmed) is dropped, a differing one is kept. + */ + public function testMigratedModuleRemarksDropsAnOverlayRemarkTheShippedPoHasAsNoteNow(): void + { + $catalog = new TranslationCatalog(); + foreach (['greeting' => 'Merk', 'other' => 'Notiz'] as $identifier => $note) { + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry(self::MODULE, $identifier); + $entry->translate('Wert'); + $entry->addExtractedComment($note); + $catalog->add($entry); + } + MigratedPoFixture::writePo($this->root . '/components/pilot/lang/pilot_de.po', $catalog); + $overlay = MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Wert', 'other' => 'Wert']); + LocalChangeComments::setRemark($overlay->find(self::MODULE, 'greeting'), ' Merk '); + LocalChangeComments::setRemark($overlay->find(self::MODULE, 'other'), 'Eigene Bemerkung'); + MigratedPoFixture::writePair($this->overlayBase(), $overlay); + + $remarks = $this->callMigratedModuleRemarks([self::MODULE => ['greeting' => 'Wert', 'other' => 'Wert']]); + + $this->assertSame(['other' => 'Eigene Bemerkung'], $remarks[self::MODULE]); + } + + /** + * Whole way of a local change with a remark: "merge" takes value and remark into the shipped `.po`, "setup build" + * builds it, "setup update" leaves neither entry nor remark in the overlay (file gone). + */ + public function testAMergedValueAndRemarkLeaveTheOverlayWithTheUpdateAfterTheBuild(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $directories = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory); + MigratedLanguageFileSync::sync($directories, $this->root, 'de', self::MODULE, ['greeting' => 'Servus'], $this->root . '/client-data', false, null, ['greeting' => 'Merk']); + $this->assertSame('Merk', MigratedLanguageFileSync::loadRemarks($directories, 'de', self::MODULE, $this->root . '/client-data', $this->root)['greeting'] ?? null, 'precondition'); + + mkdir($this->root . '/backup'); + $result = \ILIAS\Language\ComponentTranslation\ShippedPoMerger::merge($directories, $this->root, 'de', $this->root . '/client-data', $this->root . '/backup'); + $this->assertSame([], $result['skipped'], 'precondition'); + MigratedPoFixture::build($directories, $this->root); + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertFileDoesNotExist($this->overlayBase() . '.po'); + $shipped = MigratedPoFixture::readPo($this->root . '/components/pilot/lang/pilot_de.po')->find(self::MODULE, 'greeting'); + $this->assertSame('Servus', $shipped?->getTranslation()); + $this->assertContains('Merk', array_map('trim', $shipped->getExtractedComments())); + $this->assertSame('Servus', $this->lngModules()[self::MODULE]['greeting']); + $this->assertSame('Servus', \ILIAS\Language\ComponentTranslation\MigratedTranslations::text(self::MODULE, 'de', 'greeting', $this->root . '/client-data')); + } + + /** + * A dated "... new variable" placeholder (the legacy "not translated yet" marker) is never an administrator's + * remark, whether or not it matches a shipped comment. + */ + public function testMigratedModuleRemarksExcludesADatedNewVariableMarkerRemark(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => '28 08 2012 new variable'], + ]; + + $remarks = $this->callMigratedModuleRemarks([self::MODULE => ['greeting' => 'Hallo']]); + + $this->assertArrayNotHasKey('greeting', $remarks[self::MODULE] ?? []); + } + + /** + * A lng_data remark that differs from every shipped comment is a real edit and is taken over. + */ + public function testMigratedModuleRemarksKeepsALngDataRemarkThatDiffersFromEveryShippedComment(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'Echte Admin-Bemerkung'], + ]; + + $remarks = $this->callMigratedModuleRemarks( + [self::MODULE => ['greeting' => 'Hallo']], + [], + [self::MODULE => ['greeting' => 'Ganz anderer Zeilenkommentar']] + ); + + $this->assertSame('Echte Admin-Bemerkung', $remarks[self::MODULE]['greeting'] ?? null); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php new file mode 100644 index 000000000000..f4a69fc7c7ef --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php @@ -0,0 +1,197 @@ +shipPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss']); + $this->writeLang('lang/ilias_de.lang', ['common#:#yes#:#Ja']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame( + [ + 'common|yes' => ['value' => 'Ja', 'local_change' => null], + 'pilot|greeting' => ['value' => 'Hallo', 'local_change' => null], + 'pilot|farewell' => ['value' => 'Tschüss', 'local_change' => null], + ], + $this->lngData() + ); + $this->assertSame(['greeting' => 'Hallo', 'farewell' => 'Tschüss'], $this->lngModules()[self::MODULE]); + } + + public function testLangLinesOfAMigratedModuleInNonLocalFilesAreIgnored(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->writeLang('lang/ilias_de.lang', ['pilot#:#greeting#:#Alt aus lang/', 'pilot#:#stale#:#Veraltet']); + $this->writeLang('components/pilot/lang/ilias_de.lang', ['greeting#:#Alt aus Komponente']); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertSame(['greeting' => 'Hallo'], $this->lngModules()[self::MODULE]); + $this->assertArrayNotHasKey('pilot|stale', $this->lngData()); + } + + /** + * Only languages that ship a `.po` are migrated: for another language the `.lang` lines are used. + */ + public function testAModuleIsOnlyMigratedForLanguagesWithAShippedPo(): void + { + $this->shipPo(['greeting' => 'Hallo'], 'de'); + $this->writeLang('components/pilot/lang/ilias_en.lang', ['greeting#:#Hello']); + + $this->manager()->insertLanguageForInstallation('en'); + + $this->assertSame(['greeting' => 'Hello'], $this->lngModules()[self::MODULE]); + $this->assertDirectoryDoesNotExist($this->root . '/client-data/lang'); + } + + /** + * An installation without local changes writes no overlay file: the shipped state is served as is. + */ + public function testInstallWithoutLocalChangesWritesNoOverlay(): void + { + $this->shipPo(['greeting' => ['value' => 'Hello', 'fuzzy' => true]]); + + $this->manager()->insertLanguageForInstallation('de'); + + $this->assertDirectoryDoesNotExist($this->root . '/client-data/lang'); + $this->assertSame( + ['greeting' => ['value' => 'Hello', 'local_change' => false, 'local_change_date' => null, 'original' => 'Hello']], + $this->effective() + ); + } + + /** + * A module that already has an overlay when insertLanguage() decides what to lock is locked + * (MigratedLanguageFileSync::withOverlayLock()) for the whole run, not just its final sync(), so a concurrent + * writer cannot interleave with the read-modify-write in between. + */ + public function testAModuleWithAnExistingOverlayIsLockedForTheWholeRunNotJustItsOwnSync(): void + { + $this->shipPo(['greeting' => 'Hallo']); + $this->seedOverlay(['greeting' => ['value' => 'Servus', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z']]); + $lock_file = MigratedPoFixture::overlayLock($this->overlayBase()); + $held_locks = new ReflectionProperty(MigratedLanguageFileSync::class, 'held_locks'); + + $observed = null; + $manager = $this->manager(null, function () use (&$observed, $held_locks, $lock_file): void { + $observed = array_key_exists($lock_file, $held_locks->getValue()); + }); + + $manager->insertLanguageForInstallation('de'); + + $this->assertTrue($observed, 'the module\'s overlay lock must already be held at this point in the run'); + // released again once the whole run is done + $this->assertSame([], $held_locks->getValue()); + } + + /** + * Deadlock protection: modules with an overlay are locked in one global order (module name, byte-wise), + * whatever order getDirectories() hands them out in. + */ + public function testLocksAreAcquiredInSortedOrderRegardlessOfTheContributedDirectoryOrder(): void + { + $first_module = MigratedPoFixture::directory('zmod', 'components/pilot/lang/'); + $second_module = MigratedPoFixture::directory('amod', 'components/pilot/lang/'); + MigratedPoFixture::writePo( + $this->root . '/components/pilot/lang/zmod_de.po', + MigratedPoFixture::catalog('zmod', ['greeting' => 'Hallo']) + ); + MigratedPoFixture::writePo( + $this->root . '/components/pilot/lang/amod_de.po', + MigratedPoFixture::catalog('amod', ['greeting' => 'Hallo']) + ); + MigratedPoFixture::writePair( + $this->root . '/client-data/lang/zmod/de/zmod_de', + MigratedPoFixture::catalog('zmod', ['greeting' => ['value' => 'Servus', 'original' => 'Hallo']]) + ); + MigratedPoFixture::writePair( + $this->root . '/client-data/lang/amod/de/amod_de', + MigratedPoFixture::catalog('amod', ['greeting' => ['value' => 'Servus', 'original' => 'Hallo']]) + ); + $held_locks = new ReflectionProperty(MigratedLanguageFileSync::class, 'held_locks'); + $observed_order = null; + + $manager = new LanguageInstallationManager( + $this->db(), + // "zmod" contributed before "amod", the opposite of sorted order + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $first_module, $second_module), + $this->root, + $this->repository(), + static fn(): \DateTimeImmutable => new \DateTimeImmutable(self::NOW, new \DateTimeZone('UTC')), + new CallbackTranslationCache(function () use (&$observed_order, $held_locks): void { + $observed_order = array_map( + static fn(string $lock_file): string => basename(dirname($lock_file, 2)) . '/' . basename($lock_file), + array_keys($held_locks->getValue()) + ); + }), + fn(): string => $this->root . '/client-data' + ); + + $manager->insertLanguageForInstallation('de'); + + $this->assertSame(['amod/lock', 'zmod/lock'], $observed_order); + } + + /** + * A module without an overlay at that point is never locked (no lock file is created). + */ + public function testAModuleWithoutAnOverlayThatGetsOneCreatedDuringTheRunIsReportedAndTheOverlaySurvives(): void + { + $this->expectErrorLog(); + $this->shipPo(['greeting' => 'Hallo']); + $concurrent_edit = MigratedPoFixture::catalog(self::MODULE, [ + 'greeting' => ['value' => 'Von einem parallelen Admin-Edit', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'], + ]); + + $manager = $this->manager(null, function () use ($concurrent_edit): void { + MigratedPoFixture::writePair($this->overlayBase(), $concurrent_edit); + }); + + $unwritten = $manager->insertLanguageForInstallation('de'); + + $this->assertSame([self::MODULE], $unwritten); + $this->assertSame( + 'Von einem parallelen Admin-Edit', + // untouched, so still as the fixture wrote it: with the module as msgctxt + $this->overlayPo()->find(self::MODULE, 'greeting')->getTranslation(), + 'the concurrently-written overlay must survive untouched' + ); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php index 830e5342bdc5..767b50e9413d 100644 --- a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php @@ -26,17 +26,13 @@ use ILIAS\Language\Setup\LanguageInstallationManager; use ILIAS\Language\Setup\TranslationCache; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; /** - * Focused coverage for LanguageInstallationManager. In particular, these - * tests pin down last_update handling for object_data bookkeeping rows: the - * UPDATE path (registerInstalledLanguage()'s update branch, and - * installLanguages()' uninstall branch) sets last_update via the manager's - * injectable clock, so a test can assert the exact value instead of only - * that *some* value was set - while the INSERT path deliberately keeps using - * $db->now() instead, which is asserted explicitly as well. The clock exists - * for testability, not because the date was ever computed incorrectly. + * Pins last_update handling of the object_data bookkeeping rows: the UPDATE path sets it via the manager's injectable + * clock (exact value assertable), the INSERT path deliberately uses $db->now(). + * Modules migrated to PO/MO are covered in LanguageInstallationManagerMigratedModulesTest. */ class LanguageInstallationManagerTest extends TestCase { @@ -53,6 +49,23 @@ private function createDatabaseMock(): MockObject&ilDBInterface return $db; } + /** + * Stub variant of createDatabaseMock() for tests without ->expects(): a plain createMock() there triggers + * PHPUnit's "no expectations configured" notice. + */ + private function createDatabaseStub(): Stub&ilDBInterface + { + $db = $this->createStub(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, @@ -82,7 +95,7 @@ public function testRegisterInstalledLanguageUpdateUsesInjectedClockForLastUpdat $manager = $this->createManager( $db, - $this->createMock(InstalledLanguageRepository::class), + $this->createStub(InstalledLanguageRepository::class), new \DateTimeImmutable('2026-01-02 03:04:05', new \DateTimeZone('UTC')) ); @@ -105,7 +118,7 @@ public function testRegisterInstalledLanguageInsertUsesDbNowNotTheClock(): void $manager = $this->createManager( $db, - $this->createMock(InstalledLanguageRepository::class), + $this->createStub(InstalledLanguageRepository::class), new \DateTimeImmutable('2026-01-02 03:04:05', new \DateTimeZone('UTC')) ); @@ -114,14 +127,14 @@ public function testRegisterInstalledLanguageInsertUsesDbNowNotTheClock(): void public function testInstallLanguagesSetsLastUpdateOnUninstallUsingInjectedClock(): void { - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $calls = []; $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { $calls[] = $query; return 1; }); - $repository = $this->createMock(InstalledLanguageRepository::class); + $repository = $this->createStub(InstalledLanguageRepository::class); $repository->method('getAvailableLanguages')->willReturn([ 'fr' => ['obj_id' => 3, 'status' => 'installed'], ]); @@ -156,21 +169,21 @@ public function testFlushLanguageForInstallationKeepsLocalChanges(): void $this->stringContains('local_change IS NULL') )); - $manager = $this->createManager($db, $this->createMock(InstalledLanguageRepository::class)); + $manager = $this->createManager($db, $this->createStub(InstalledLanguageRepository::class)); $manager->flushLanguageForInstallation('de'); } public function testFlushLanguageForUninstallationDeletesEverything(): void { - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createManager($db, $this->createStub(InstalledLanguageRepository::class)); $manager->flushLanguageForUninstallation('de'); @@ -192,7 +205,7 @@ public function testInsertLanguageForRemovingLocalChangesIgnoresCustomizingDirec try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $db->method('in')->willReturn("module IN ('common')"); $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { $calls[] = $query; @@ -235,14 +248,14 @@ public function testInsertLanguageForInstallationStillMergesCustomizingDirectory try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createStub(InstalledLanguageRepository::class); $repository->method('getLocalChanges')->willReturn([]); $manager = new LanguageInstallationManager( @@ -289,7 +302,7 @@ public function testLocalChangesAreLookedUpOncePerDirectoryNotPerEntry(): void try { $calls = 0; $inserts = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $db->method('in')->willReturn("module IN ('common')"); $db->method('manipulate')->willReturnCallback( static function (string $query) use (&$inserts): int { @@ -300,7 +313,7 @@ static function (string $query) use (&$inserts): int { } ); - $repository = $this->createMock(InstalledLanguageRepository::class); + $repository = $this->createStub(InstalledLanguageRepository::class); $repository->method('getLocalChanges')->willReturnCallback( static function (string $lang_key, string $min_date = '') use (&$calls): array { $calls++; @@ -350,7 +363,7 @@ public function testInsertLanguageForApplyingLocalChangesOnlyReadsCustomizingDir try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $db->method('in')->willReturn("module IN ('common')"); $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { $calls[] = $query; @@ -409,19 +422,21 @@ public function testInsertLanguageForApplyingLocalChangesPreservesUnrelatedSeedE try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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', - ], + $repository = $this->createStub(InstalledLanguageRepository::class); + $repository->method('getLanguageEntries')->willReturnMap([ + ['de', [ + 'common' => [ + 'overridden' => 'Old Value', + 'untouched_by_customizing' => 'Still Here', + ], + ]], ]); $manager = new LanguageInstallationManager( @@ -459,15 +474,17 @@ public function testInstallLanguagesRegistersBrandNewLanguage(): void try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createStub(InstalledLanguageRepository::class); + $repository->method('checkLanguage')->willReturnMap([ + ['de', true], + ]); $repository->method('getAvailableLanguages')->willReturn([]); $repository->method('getLocalLanguages')->willReturn([]); $repository->method('getLocalChanges')->willReturn([]); @@ -514,14 +531,14 @@ public function testInstallLanguagesReportsFailedLanguageAndLeavesItUntouched(): try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createStub(InstalledLanguageRepository::class); $repository->method('checkLanguage')->willReturnMap([ ['de', true], ['xx', false], @@ -572,14 +589,14 @@ public function testInstallLanguagesResyncsAlreadyKnownRequestedLanguage(): void try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createStub(InstalledLanguageRepository::class); $repository->method('checkLanguage')->willReturn(true); $repository->method('getAvailableLanguages')->willReturn([ 'fr' => ['obj_id' => 9, 'status' => 'installed'], @@ -629,14 +646,14 @@ public function testInstallLanguagesResyncsAlreadyKnownRequestedLanguage(): void */ public function testInstallLanguagesDoesNotReUpdateAlreadyNotInstalledLanguage(): void { - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $calls = []; $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { $calls[] = $query; return 1; }); - $repository = $this->createMock(InstalledLanguageRepository::class); + $repository = $this->createStub(InstalledLanguageRepository::class); $repository->method('getAvailableLanguages')->willReturn([ 'de' => ['obj_id' => 4, 'status' => 'not_installed'], ]); @@ -677,14 +694,14 @@ public function testInsertLanguageStoresCommentSuffixInRemarksColumn(): void try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createStub(InstalledLanguageRepository::class); $repository->method('getLocalChanges')->willReturn([]); $manager = new LanguageInstallationManager( @@ -727,14 +744,14 @@ public function testInsertLanguageLeavesRemarksNullWhenLineHasNoCommentSuffix(): try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createStub(InstalledLanguageRepository::class); $repository->method('getLocalChanges')->willReturn([]); $manager = new LanguageInstallationManager( @@ -779,14 +796,14 @@ public function testInsertLanguageUsesDirectoryPrefixAsModuleForComponentFiles() try { $calls = []; - $db = $this->createDatabaseMock(); + $db = $this->createDatabaseStub(); $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 = $this->createStub(InstalledLanguageRepository::class); $repository->method('getLocalChanges')->willReturn([]); $manager = new LanguageInstallationManager( @@ -814,6 +831,48 @@ public function testInsertLanguageUsesDirectoryPrefixAsModuleForComponentFiles() } } + /** + * A module read from the *.lang data but never contributed as a LanguageFileDirectory (most modules, outside the + * PO/MO pilot) must cause no error and create no file. + */ + public function testInsertLanguageDoesNotErrorForModuleWithoutContributedDirectory(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'hello', 'Welt']]); + + try { + $calls = []; + $db = $this->createDatabaseStub(); + $db->method('in')->willReturn("module IN ('common')"); + $db->method('manipulate')->willReturnCallback(static function (string $query) use (&$calls): int { + $calls[] = $query; + return 1; + }); + + $repository = $this->createStub(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturn([]); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository + ); + + $manager->insertLanguageForInstallation('de'); + + // the DB write of the non-migrated module still happens + $inserts = array_values(array_filter( + $calls, + static fn(string $query): bool => str_starts_with($query, /** @lang text */ 'INSERT INTO lng_modules') + )); + $this->assertCount(1, $inserts); + $this->assertStringContainsString("'common'", $inserts[0]); + } finally { + $this->removeDirectory($root); + } + } + private function createPrefixedComponentDirectory(string $path, string $prefix): LanguageFileDirectory { return new class ($path, $prefix) implements LanguageFileDirectory { diff --git a/components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php b/components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php new file mode 100644 index 000000000000..b6ab39c740d7 --- /dev/null +++ b/components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php @@ -0,0 +1,584 @@ +root = sys_get_temp_dir() . '/ilias_shipped_build_' . bin2hex(random_bytes(6)); + mkdir($this->root, 0775, true); + } + + protected function tearDown(): void + { + MigratedPoFixture::removeDirectory($this->root); + } + + /** + * @param array $entries identifier => value + */ + private function writeShippedPo(string $relative_path, string $module, string $lang_key, array $entries): void + { + $directory = $this->root . '/' . trim($relative_path, '/'); + MigratedPoFixture::writePo($directory . '/' . $module . '_' . $lang_key . '.po', MigratedPoFixture::catalog($module, $entries)); + } + + private function directoryManager(\ILIAS\Language\ComponentTranslation\LanguageFileDirectory ...$directories): LanguageFileDirectoryManager + { + return new LanguageFileDirectoryManager(new \ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory(), ...$directories); + } + + /** + * @param list $messages filled with every message inform() receives, in order + */ + private function informer(array &$messages): AdminInteraction + { + $messages = []; + $io = $this->createStub(AdminInteraction::class); + $io->method('inform')->willReturnCallback(function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + return $io; + } + + private function achieve(LanguageFileDirectoryManager $manager, ?AdminInteraction $io = null): void + { + $objective = new ShippedLanguageFilesCompiledObjective($manager, $this->root); + $environment = $this->createStub(Environment::class); + $environment->method('getResource')->willReturn($io); + $objective->achieve($environment); + } + + /** + * The catalog of $module/$lang_key in the build current.json names; a nonexistent path without one. + */ + private function artifactPath(string $lang_key, string $module): string + { + $artifact_directory = MigratedLanguageFilePaths::shippedArtifactDirectory($this->root); + $build = ShippedTranslationsBuild::readPointer($artifact_directory)['build'] ?? null; + if ($build === null) { + return $artifact_directory . '/no-build.mo'; + } + + return MigratedLanguageFilePaths::buildMoFile(MigratedLanguageFilePaths::buildDirectory($artifact_directory, $build), $module, $lang_key); + } + + public function testCompilesAShippedPoIntoAnArtifact(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + + $this->achieve($this->directoryManager($directory)); + + $this->assertFileExists($this->artifactPath('de', 'itest')); + $this->assertSame( + ['greeting' => 'Hallo'], + MigratedPoFixture::readMo($this->artifactPath('de', 'itest')) + ); + } + + /** + * The main `lang/` directory (empty prefix) holds no `.po` and is skipped without error. + */ + public function testSkipsADirectoryWithoutAPrefix(): void + { + $directory = new \ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory(); + mkdir($this->root . '/lang', 0775, true); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($this->directoryManager($directory), $io); + + $this->assertStringContainsString('no migrated module, 0 compiled', $messages[array_key_last($messages)]); + } + + /** + * A second run with nothing changed does not recompile (same hash, source, artifact present): "unchanged". + */ + public function testASecondRunWithNoChangesReportsTheArtifactAsUnchanged(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $manager = $this->directoryManager($directory); + $this->achieve($manager); + $first_mtime = filemtime($this->artifactPath('de', 'itest')); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertStringContainsString('unchanged, 0 compiled', $messages[array_key_last($messages)]); + $this->assertSame($first_mtime, filemtime($this->artifactPath('de', 'itest'))); + } + + /** + * A changed `.po` gives a new build (every `.po` compiled into a new directory, since a running process never + * reloads a catalog file); the previous build is kept. + */ + public function testAChangedPoGivesANewBuildAndKeepsThePreviousOne(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $this->writeShippedPo('utest/', 'utest', 'de', ['greeting' => 'Servus']); + $manager = $this->directoryManager( + MigratedPoFixture::directory('itest', 'itest/'), + MigratedPoFixture::directory('utest', 'utest/') + ); + $this->achieve($manager); + $previous = dirname($this->artifactPath('de', 'utest'), 3); + + // change only "itest"'s shipped .po - the incremental decision is by content hash, not mtime + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo geändert']); + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertStringContainsString('2 compiled', $messages[array_key_last($messages)]); + $this->assertNotSame($previous, dirname($this->artifactPath('de', 'utest'), 3)); + $this->assertDirectoryExists($previous, 'the previous build is kept'); + $this->assertSame( + ['greeting' => 'Hallo geändert'], + MigratedPoFixture::readMo($this->artifactPath('de', 'itest')) + ); + } + + /** + * A `.po` that no longer exists has its artifact removed on the next build. + */ + public function testRemovesAnArtifactWhoseShippedPoNoLongerExists(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $manager = $this->directoryManager($directory); + $this->achieve($manager); + $this->assertFileExists($this->artifactPath('de', 'itest')); + + unlink($this->root . '/itest/itest_de.po'); + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertFileDoesNotExist($this->artifactPath('de', 'itest')); + $this->assertStringContainsString('no migrated module, 0 compiled', $messages[array_key_last($messages)]); + } + + /** + * Disallowed markup is compiled cleaned and warned about; the build does not abort. + */ + public function testWarnsAboutDisallowedMarkupButStillCompilesTheCleanedArtifact(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($this->directoryManager($directory), $io); + + $warning = implode("\n", $messages); + $this->assertStringContainsString('WARNING: Markup not allowed in a language value', $warning); + $this->assertStringContainsString('itest', $warning); + $this->assertStringContainsString('greeting', $warning); + $this->assertFileExists($this->artifactPath('de', 'itest')); + $translations = MigratedPoFixture::readMo($this->artifactPath('de', 'itest')); + $this->assertStringNotContainsString('writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $manager = $this->directoryManager($directory); + $this->achieve($manager); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertStringContainsString('WARNING: Markup not allowed', implode("\n", $messages)); + } + + /** + * A syntactically broken `.po` is reported and its artifact removed; other modules are still compiled. + */ + public function testABrokenPoIsReportedAndDoesNotAbortTheBuild(): void + { + mkdir($this->root . '/broken', 0775, true); + file_put_contents($this->root . '/broken/broken_de.po', "msgid \"kaputt\n"); + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $manager = $this->directoryManager( + MigratedPoFixture::directory('broken', 'broken/'), + MigratedPoFixture::directory('itest', 'itest/') + ); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $warning = implode("\n", $messages); + $this->assertStringContainsString('WARNING: Could not compile', $warning); + $this->assertStringContainsString('broken', $warning); + $this->assertFileDoesNotExist($this->artifactPath('de', 'broken')); + $this->assertFileExists($this->artifactPath('de', 'itest')); + } + + /** + * Writes stay confined to the artifact directory (AtomicFileWriter::write() gets it as $confine_to_directory): + * a symlink swapped into the artifact tree must not redirect a write; proven with a symlinked language directory. + */ + public function testNeverWritesOutsideTheArtifactDirectoryEvenViaASymlinkedLanguageDirectory(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $outside = $this->root . '/outside-de'; + mkdir($outside, 0775, true); + mkdir(MigratedLanguageFilePaths::shippedArtifactDirectory($this->root), 0775, true); + symlink($outside, MigratedLanguageFilePaths::shippedArtifactDirectory($this->root) . '/de'); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($this->directoryManager($directory), $io); + + $this->assertSame([], array_values(array_diff(scandir($outside) ?: [], ['.', '..']))); + } + + public function testGetHashChangesWhenAContributedDirectoryChanges(): void + { + $manager_one = $this->directoryManager(MigratedPoFixture::directory('itest', 'itest/')); + $manager_two = $this->directoryManager(MigratedPoFixture::directory('other', 'other/')); + + $objective_one = new ShippedLanguageFilesCompiledObjective($manager_one, $this->root); + $objective_two = new ShippedLanguageFilesCompiledObjective($manager_two, $this->root); + + $this->assertNotSame($objective_one->getHash(), $objective_two->getHash()); + } + + /** + * The index also records size and hash of the artifact itself: a truncated or corrupted artifact (e.g. crash + * without fsync()) must be rebuilt, not kept as "unchanged" because its .po did not change. + */ + public function testATruncatedArtifactIsRebuiltEvenThoughItsShippedPoDidNotChange(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $manager = $this->directoryManager($directory); + $this->achieve($manager); + + // simulate a crash that left a truncated artifact behind - same source .po, damaged artifact + // (not the file this process has just read through native gettext, see the fixture) + MigratedPoFixture::moveArtifactsToFreshDirectory(); + $full = (string) file_get_contents($this->artifactPath('de', 'itest')); + file_put_contents($this->artifactPath('de', 'itest'), substr($full, 0, 10)); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertStringContainsString(', 1 compiled', $messages[array_key_last($messages)]); + $this->assertSame( + ['greeting' => 'Hallo'], + MigratedPoFixture::readMo($this->artifactPath('de', 'itest')) + ); + } + + /** + * A silently changed artifact (same size, e.g. a bit flip) is caught by the hash check and rebuilt. + */ + public function testAnArtifactWithTheSameSizeButDifferentContentIsRebuilt(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $manager = $this->directoryManager($directory); + $this->achieve($manager); + + MigratedPoFixture::moveArtifactsToFreshDirectory(); + $original = (string) file_get_contents($this->artifactPath('de', 'itest')); + $tampered = substr($original, 0, -1) . ($original[-1] === "\x00" ? "\x01" : "\x00"); + file_put_contents($this->artifactPath('de', 'itest'), $tampered); + $this->assertSame(strlen($original), strlen($tampered), 'precondition: same size, different content'); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertStringContainsString(', 1 compiled', $messages[array_key_last($messages)]); + } + + /** + * A build whose manifest cannot be read is never trusted: it is rebuilt. + */ + public function testABrokenManifestCausesARebuild(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $manager = $this->directoryManager($directory); + $this->achieve($manager); + + file_put_contents(dirname($this->artifactPath('de', 'itest'), 3) . '/manifest.json', '{not valid json'); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertStringContainsString(', 1 compiled', $messages[array_key_last($messages)]); + } + + /** + * The index.json of the former layout (even a broken one) does not abort the build; it is removed as obsolete. + */ + public function testAnIndexJsonOfTheFormerLayoutIsRemoved(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + mkdir(MigratedLanguageFilePaths::shippedArtifactDirectory($this->root), 0775, true); + file_put_contents(MigratedLanguageFilePaths::shippedArtifactDirectory($this->root) . '/index.json', '{not valid json'); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($this->directoryManager($directory), $io); + + $this->assertStringContainsString(', 1 compiled', $messages[array_key_last($messages)]); + $this->assertFileExists($this->artifactPath('de', 'itest')); + $this->assertFileDoesNotExist(MigratedLanguageFilePaths::shippedArtifactDirectory($this->root) . '/index.json'); + } + + /** + * Identical content but a newer .po (e.g. a checkout reset its mtime): decided by content, not rewritten. + */ + public function testATouchedButOtherwiseIdenticalPoKeepsTheBuild(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $manager = $this->directoryManager($directory); + $this->achieve($manager); + $artifact_inode_before = fileinode($this->artifactPath('de', 'itest')); + + // achieved by moving the ARTIFACT into the past, never the .po into the future (clock skew) + touch($this->artifactPath('de', 'itest'), time() - 1000); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $this->assertStringContainsString('unchanged, 0 compiled', $messages[array_key_last($messages)]); + clearstatcache(true, $this->artifactPath('de', 'itest')); + $this->assertSame($artifact_inode_before, fileinode($this->artifactPath('de', 'itest')), 'the artifact is not rewritten (same inode)'); + } + + /** + * A .po that cannot even be hashed (unreadable) is a generic \Throwable and must not abort the build either. + */ + public function testAnUnreadablePoIsReportedAndDoesNotAbortTheBuild(): void + { + if (posix_getuid() === 0) { + $this->markTestSkipped('Cannot make a file unreadable while running as root; skipping.'); + } + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $this->writeShippedPo('unreadable/', 'unreadable', 'de', ['greeting' => 'Hallo']); + chmod($this->root . '/unreadable/unreadable_de.po', 0000); + $manager = $this->directoryManager( + MigratedPoFixture::directory('itest', 'itest/'), + MigratedPoFixture::directory('unreadable', 'unreadable/') + ); + + try { + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + } finally { + chmod($this->root . '/unreadable/unreadable_de.po', 0664); + } + + $warning = implode("\n", $messages); + $this->assertStringContainsString('WARNING: Could not compile', $warning); + $this->assertStringContainsString('unreadable', $warning); + $this->assertFileExists($this->artifactPath('de', 'itest')); + } + + /** + * Leftover temporary files of AtomicFileWriter ("..") from a crashed build are cleaned up on the + * next build. + */ + public function testRemovesLeftoverTemporaryFilesFromAnAbortedPreviousBuild(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $target_directory = MigratedLanguageFilePaths::shippedArtifactDirectory($this->root); + mkdir($target_directory . '/de', 0775, true); + file_put_contents($target_directory . '/.index.json.ab12cd', 'leftover'); + file_put_contents($target_directory . '/de/.itest.mo.ab12cd', 'leftover'); + + $this->achieve($this->directoryManager($directory)); + + $this->assertFileDoesNotExist($target_directory . '/.index.json.ab12cd'); + $this->assertFileDoesNotExist($target_directory . '/de/.itest.mo.ab12cd'); + } + + /** + * removeObsoleteArtifacts() must not delete through a symlinked language directory pointing outside the artifact + * tree: isBelow()'s realpath() check applies to deletions as to writes. + */ + public function testDoesNotDeleteAnObsoleteArtifactReachedOnlyThroughASymlinkedLanguageDirectory(): void + { + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + $target_directory = MigratedLanguageFilePaths::shippedArtifactDirectory($this->root); + mkdir($target_directory, 0775, true); + $outside = $this->root . '/outside-language-dir'; + mkdir($outside, 0775, true); + file_put_contents($outside . '/obsolete.mo', 'not tracked by the index at all'); + symlink($outside, $target_directory . '/xx'); + + $this->achieve($this->directoryManager($directory)); + + $this->assertFileExists($outside . '/obsolete.mo'); + } + + /** + * The build log must not pass raw control characters (ANSI escape, bidi override in an identifier) to the + * terminal: withoutControlCharacters() replaces them with "?". + */ + public function testControlCharactersInABuildLogMessageAreReplaced(): void + { + $catalog = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + $entry = \MigratedPoFixture::entry('itest', "bad\x1bkey", 'Hallo'); + $catalog->add($entry); + MigratedPoFixture::writePo($this->root . '/itest/itest_de.po', $catalog); + $directory = MigratedPoFixture::directory('itest', 'itest/'); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($this->directoryManager($directory), $io); + + $warning = implode("\n", $messages); + $this->assertStringNotContainsString("\x1b", $warning); + $this->assertStringContainsString('bad?key', $warning); + } + + private function directoryWithPattern(string $prefix, string $path, string $pattern): \ILIAS\Language\ComponentTranslation\LanguageFileDirectory + { + return new class ($prefix, $path, $pattern) implements + \ILIAS\Language\ComponentTranslation\LanguageFileDirectory, + \ILIAS\Language\ComponentTranslation\NamesShippedLanguageFiles { + public function __construct( + private string $prefix, + private string $path, + private string $pattern + ) { + } + + 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 getShippedFileNamePattern(): string + { + return $this->pattern; + } + }; + } + + /** + * A module using the "ilias_%s" schema has its shipped `.po` found and compiled like any other. + */ + public function testFindsAndCompilesAShippedPoNamedByTheIliasSchema(): void + { + mkdir($this->root . '/itest', 0775, true); + MigratedPoFixture::writePo( + $this->root . '/itest/ilias_de.po', + MigratedPoFixture::catalog('itest', ['greeting' => 'Hallo']) + ); + $directory = $this->directoryWithPattern('itest', 'itest/', 'ilias_%s'); + + $this->achieve($this->directoryManager($directory)); + + $this->assertFileExists($this->artifactPath('de', 'itest')); + $this->assertSame( + ['greeting' => 'Hallo'], + MigratedPoFixture::readMo($this->artifactPath('de', 'itest')) + ); + } + + /** + * An invalid shipped file name pattern is a warning that skips only that module. + */ + public function testAnInvalidShippedFileNamePatternWarnsAndDoesNotAbortTheBuild(): void + { + $invalid = $this->directoryWithPattern('badmod', 'badmod/', 'no-placeholder-at-all'); + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $valid = MigratedPoFixture::directory('itest', 'itest/'); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($this->directoryManager($invalid, $valid), $io); + + $warning = implode("\n", $messages); + $this->assertStringContainsString('WARNING', $warning); + $this->assertStringContainsString('badmod', $warning); + $this->assertFileExists($this->artifactPath('de', 'itest')); + } + + public function testIsNotableAndHasNoPreconditions(): void + { + $objective = new ShippedLanguageFilesCompiledObjective( + $this->directoryManager(), + $this->root + ); + + $this->assertTrue($objective->isNotable()); + $this->assertSame([], $objective->getPreconditions($this->createStub(Environment::class))); + $this->assertTrue($objective->isApplicable($this->createStub(Environment::class))); + } +} diff --git a/components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php b/components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php index d57ffbf7038e..0a6eb8290a75 100644 --- a/components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php +++ b/components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php @@ -19,19 +19,18 @@ declare(strict_types=1); use ILIAS\Setup; +use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; /** - * Guards the database handling of ilLanguagesInstalledAndUpdatedObjective. - * - * The class used to overwrite $GLOBALS['ilDB'] with the Setup-provided - * database for the duration of the install and restore it afterwards, with a - * "@todo remove this once ilSetupLanguage supports proper DI" attached. That - * is gone: ilSetupLanguage resolves its database lazily, so setDbHandler() - * is authoritative for everything the install path touches. These tests pin - * both halves of that down, because a regression would be silent - the - * global fallback would simply take over again. + * Guards the database handling of ilLanguagesInstalledAndUpdatedObjective: ilSetupLanguage resolves its database + * lazily, so setDbHandler() must stay authoritative; a regression would be silent because the global fallback + * takes over again. */ class ilLanguagesInstalledAndUpdatedObjectiveTest extends TestCase { @@ -40,6 +39,8 @@ class ilLanguagesInstalledAndUpdatedObjectiveTest extends TestCase protected function setUp(): void { + // never the build of the installation, see MigratedPoFixture::resetRuntime() + MigratedPoFixture::resetRuntime(); // these tests replace/remove $GLOBALS['ilDB'], which other tests in random order rely on $this->had_global_db = isset($GLOBALS['ilDB']); $this->previous_global_db = $GLOBALS['ilDB'] ?? null; @@ -110,6 +111,9 @@ public function testAchieveNeedsNoGlobalDatabaseAtAll(): void $objective = new ilLanguagesInstalledAndUpdatedObjective(new ilSetupLanguage('en')); $environment = new Setup\ArrayEnvironment([ Setup\Environment::RESOURCE_DATABASE => $this->createDatabaseMock('INJECTED', $log), + // the real ilSetupLanguage resolves the client data directory of the running installation; its owner check may + // warn (e.g. as root), which is unrelated here + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $this->createStub(Setup\AdminInteraction::class), ]); $objective->achieve($environment); @@ -147,6 +151,7 @@ public function testAchieveReusesTheLanguagesDeterminedInIsApplicable(): void { $setup_language = $this->createMock(ilSetupLanguage::class); $setup_language->expects($this->once())->method('getInstalledLanguages')->willReturn(['de', 'fr']); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); $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]); @@ -166,9 +171,9 @@ public function testAchieveReusesTheLanguagesDeterminedInIsApplicable(): void /** * @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 + private function createSetupLanguageMock(array $installed_language_keys, array &$log): Stub&ilSetupLanguage { - $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language = $this->createStub(ilSetupLanguage::class); $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys); $setup_language->method('getAvailableLanguagesForInstallation')->willReturn([]); $setup_language->method('getLocalLanguages')->willReturn([]); @@ -219,7 +224,7 @@ public function testFreshlyInstalledLanguageIsImmediatelyRefreshedAgainByUpdateL $log = []; $newly_installed = []; - $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language = $this->createStub(ilSetupLanguage::class); $setup_language->method('getInstalledLanguages')->willReturnCallback( static function () use (&$newly_installed): array { return array_merge(['de'], $newly_installed); @@ -247,4 +252,707 @@ static function (string $lang_key) use (&$newly_installed): void { // 'fr' is flushed by InstallLanguage; UpdateLanguage then sees ['de', 'fr'] and flushes both again $this->assertSame(['fr', 'de', 'fr'], $log); } + + /** + * @param list $flushed collects every flushed language key + */ + private function createAchievableSetupLanguage(array $installed, array $invalid, array &$flushed, bool $default_directories = false): Stub&ilSetupLanguage + { + $setup_language = $this->createStub(ilSetupLanguage::class); + $setup_language->method('getInstalledLanguages')->willReturn($installed); + $setup_language->method('getAvailableLanguagesForInstallation')->willReturn([]); + $setup_language->method('getLocalLanguages')->willReturn([]); + $setup_language->method('getInvalidLocalLanguageFiles')->willReturn([]); + $setup_language->method('checkLanguageForInstallation')->willReturnCallback( + static fn(string $lang_key): bool => !in_array($lang_key, $invalid, true) + ); + $setup_language->method('usesDefaultLanguageFileDirectories')->willReturn($default_directories); + $setup_language->method('flushLanguageForInstallation')->willReturnCallback( + static function (string $lang_key) use (&$flushed): void { + $flushed[] = $lang_key; + } + ); + + return $setup_language; + } + + private function databaseStub(): ilDBInterface + { + return $this->createStub(ilDBInterface::class); + } + + /** + * An invalid language file only skips that language (reported); valid ones are still refreshed. + */ + public function testAchieveSkipsInvalidLanguagesAndInformsTheAdministrator(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de', 'xx', 'fr'], ['xx'], $flushed); + $messages = []; + $io = $this->createStub(Setup\AdminInteraction::class); + $io->method('inform')->willReturnCallback(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $io, + ])); + + $this->assertSame(['de', 'fr'], $flushed, 'valid languages are refreshed, the invalid one is untouched'); + $this->assertCount(1, $messages); + $this->assertStringContainsString('xx', $messages[0]); + $this->assertStringNotContainsString('de', str_replace('Skipped languages', '', $messages[0])); + } + + public function testAchieveReportsSkippedLanguagesViaErrorLogWithoutAdminInteraction(): void + { + $this->expectErrorLog(); + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de', 'xx'], ['xx'], $flushed); + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + ])); + + $this->assertSame(['de'], $flushed); + $this->assertStringContainsString('xx', (string) file_get_contents((string) ini_get('error_log'))); + } + + public function testAchieveWithOnlyInvalidLanguagesDoesNotThrowAndWritesNothing(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['xx'], ['xx'], $flushed); + $io = $this->createMock(Setup\AdminInteraction::class); + $io->expects($this->once())->method('inform'); + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $io, + ])); + + $this->assertSame([], $flushed); + } + + public function testAchieveDoesNotInformWhenEveryLanguageIsValid(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $io = $this->createMock(Setup\AdminInteraction::class); + $io->expects($this->never())->method('inform'); + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $io, + ])); + + $this->assertSame(['de'], $flushed); + } + + /** + * @param list $messages collects every message passed to AdminInteraction::inform() + */ + private function informCollectingEnvironment(array &$messages): Setup\Environment + { + $io = $this->createStub(Setup\AdminInteraction::class); + $io->method('inform')->willReturnCallback(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + return new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $io, + ]); + } + + /** + * Without a resolvable client data directory no PO/MO overlay can be written: worth a warning instead of silently + * serving the modules from the database, but only if such a module exists. + */ + public function testWarnsAboutAMissingClientDataDirectoryWhenMigratedModulesExist(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('getClientDataDir')->willReturn(null); + $setup_language->method('hasMigratedModules')->willReturn(true); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + $this->assertCount(1, $messages); + $this->assertStringStartsWith('WARNING: The client data directory is not known', $messages[0]); + } + + /** + * Without any migrated module a missing client data directory is no warning. + */ + public function testNoWarningAboutAMissingClientDataDirectoryWithoutAnyMigratedModule(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('getClientDataDir')->willReturn(null); + $setup_language->method('hasMigratedModules')->willReturn(false); + $io = $this->createMock(Setup\AdminInteraction::class); + $io->expects($this->never())->method('inform'); + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $io, + ])); + } + + /** + * With every requested language invalid, $language_keys is empty before informAboutMissingClientDataDirectory() + * is reached: no warning, whatever hasMigratedModules() says. + */ + public function testNoWarningAboutAMissingClientDataDirectoryWithNoValidLanguageKeys(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['xx'], ['xx'], $flushed); + $setup_language->method('getClientDataDir')->willReturn(null); + $setup_language->method('hasMigratedModules')->willReturn(true); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + // the "invalid language" notice is expected, the missing-client-data-dir warning is not + $this->assertCount(1, $messages); + $this->assertStringStartsWith('Skipped languages', $messages[0]); + } + + /** + * Checked before anything is written: unwritable overlay directories of the languages about to be installed or + * updated are reported by name; the update still runs. + */ + public function testAchieveWarnsAboutUnwritableOverlayDirectoriesBeforeWritingAndStillUpdates(): void + { + $flushed = []; + $events = []; + $setup_language = $this->createAchievableSetupLanguage(['de', 'fr'], [], $flushed); + $setup_language->method('findUnwritableOverlayDirectories')->willReturnCallback( + static function (array $lang_keys) use (&$events, &$flushed): array { + $events[] = ['check', $lang_keys, count($flushed)]; + return ['/data/client/lang/components/pilot/lang', '/data/client/lang/components/other/lang']; + } + ); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + $this->assertSame([['check', ['de', 'fr'], 0]], $events, 'checked once, for all languages, before any write'); + $this->assertSame(['de', 'fr'], $flushed, 'the update is not aborted'); + $this->assertCount(1, $messages); + $this->assertStringStartsWith('WARNING:', $messages[0]); + $this->assertStringContainsString('/data/client/lang/components/pilot/lang, /data/client/lang/components/other/lang', $messages[0]); + $this->assertStringContainsString('web server user', $messages[0]); + } + + /** + * Invalid languages are skipped before the check. + */ + public function testTheWritabilityCheckOnlyCoversTheLanguagesThatAreActuallyWritten(): void + { + $flushed = []; + $checked = []; + $setup_language = $this->createAchievableSetupLanguage(['de', 'xx'], ['xx'], $flushed); + $setup_language->method('findUnwritableOverlayDirectories')->willReturnCallback( + static function (array $lang_keys) use (&$checked): array { + $checked[] = $lang_keys; + return []; + } + ); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + $this->assertSame([['de']], $checked); + } + + /** + * Overlays that could not be written although the check passed (race, single unwritable file) are reported from + * the Activity results, every language once, from InstallLanguage and UpdateLanguage together. + */ + public function testAchieveInformsAboutTheLanguagesWhoseOverlayCouldNotBeWritten(): void + { + $install_language = $this->createMock(\ILIAS\Language\Activities\InstallLanguage::class); + $install_language->expects($this->once())->method('perform')->willReturn([ + 'overlay_write_failed_language_keys' => ['fr'], + ]); + $update_language = $this->createMock(\ILIAS\Language\Activities\UpdateLanguage::class); + $update_language->expects($this->once())->method('perform')->willReturn([ + 'overlay_write_failed_language_keys' => ['de', 'fr'], + ]); + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de', 'fr'], [], $flushed); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language, $install_language, $update_language)) + ->achieve($this->informCollectingEnvironment($messages)); + + $this->assertCount(1, $messages); + $this->assertStringStartsWith('WARNING:', $messages[0]); + $this->assertStringContainsString('the languages fr, de ', $messages[0]); + } + + /** + * Customizing entries left out for disallowed markup (LanguageInstallationManager::getSkippedInvalidMarkupEntries()) + * are reported as a WARNING per language; the install/update is not aborted. + */ + public function testAchieveWarnsAboutSkippedInvalidMarkupEntriesPerLanguage(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de', 'fr'], [], $flushed); + $setup_language->method('getSkippedInvalidMarkupEntries')->willReturn([ + 'de' => ['common#:#greeting' => ['tag '], + ]); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + $warnings = array_values(array_filter( + $messages, + static fn(string $m): bool => str_contains($m, 'Local changes') && str_contains($m, 'contain HTML') + )); + $this->assertCount(1, $warnings); + $this->assertStringContainsString('common#:#greeting', $warnings[0]); + $this->assertStringContainsString('de', $warnings[0]); + } + + /** + * A legacy local change that is still allowed is not reported. + */ + public function testAchieveDoesNotWarnAboutAnAllowedPreExistingLocalChange(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('getSkippedInvalidMarkupEntries')->willReturn([]); + $setup_language->method('getClientDataDir')->willReturn(null); + $setup_language->method('getLocalChanges')->willReturn(['common' => ['greeting' => 'Hallo, geändert']]); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + $this->assertSame( + [], + array_values(array_filter($messages, static fn(string $m): bool => str_contains($m, 'Local changes'))) + ); + } + + /** + * No skipped entries, no such warning. + */ + public function testAchieveDoesNotWarnAboutInvalidMarkupWithoutAnySkippedEntry(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('getSkippedInvalidMarkupEntries')->willReturn([]); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + $this->assertSame( + [], + array_values(array_filter($messages, static fn(string $m): bool => str_contains($m, 'markup that is not allowed'))) + ); + } + + /** + * installLanguages() returns the languages with an unwritten overlay; a missing result key counts as none, + * duplicates are removed. + */ + public function testInstallLanguagesReturnsTheDeduplicatedLanguagesWithAnUnwrittenOverlay(): void + { + $install_language = $this->createStub(\ILIAS\Language\Activities\InstallLanguage::class); + $install_language->method('perform')->willReturn(['overlay_write_failed_language_keys' => ['fr', 'de']]); + $update_language = $this->createStub(\ILIAS\Language\Activities\UpdateLanguage::class); + $update_language->method('perform')->willReturn(['overlay_write_failed_language_keys' => ['de', 'it']]); + $flushed = []; + $objective = new ilLanguagesInstalledAndUpdatedObjective( + $this->createAchievableSetupLanguage([], [], $flushed), + $install_language, + $update_language + ); + + $this->assertSame( + ['fr', 'de', 'it'], + (new ReflectionMethod($objective, 'installLanguages'))->invoke($objective, ['de', 'fr', 'it']) + ); + + $install_without_field = $this->createStub(\ILIAS\Language\Activities\InstallLanguage::class); + $install_without_field->method('perform')->willReturn([]); + $update_without_field = $this->createStub(\ILIAS\Language\Activities\UpdateLanguage::class); + $update_without_field->method('perform')->willReturn([]); + $objective = new ilLanguagesInstalledAndUpdatedObjective( + $this->createAchievableSetupLanguage([], [], $flushed), + $install_without_field, + $update_without_field + ); + $this->assertSame([], (new ReflectionMethod($objective, 'installLanguages'))->invoke($objective, ['de'])); + } + + /** + * With only the default directories no UpdateLanguage runs, so only InstallLanguage's result contributes. + */ + public function testInstallLanguagesWithDefaultDirectoriesOnlyReportsTheInstallResult(): void + { + $install_language = $this->createStub(\ILIAS\Language\Activities\InstallLanguage::class); + $install_language->method('perform')->willReturn(['overlay_write_failed_language_keys' => ['fr']]); + $update_language = $this->createMock(\ILIAS\Language\Activities\UpdateLanguage::class); + $update_language->expects($this->never())->method('perform'); + $flushed = []; + $objective = new ilLanguagesInstalledAndUpdatedObjective( + $this->createAchievableSetupLanguage([], [], $flushed, true), + $install_language, + $update_language + ); + + $this->assertSame(['fr'], (new ReflectionMethod($objective, 'installLanguages'))->invoke($objective, ['fr'])); + } + + /** + * Writable is not enough: a Setup run as another user than the client data directory's owner is warned about. + * As root the directory is handed to a non-root user, otherwise "/" (root-owned) serves as another user's directory, + * which fires the root-specific branch ("$owner === 0", "belongs to root") instead; the assertions therefore + * branch on the observed owner. + */ + public function testAchieveWarnsWhenSetupDoesNotRunAsTheOwnerOfTheClientDataDirectory(): void + { + if (!function_exists('posix_geteuid')) { + $this->markTestSkipped('The owner check needs the POSIX extension.'); + } + $datadir = sys_get_temp_dir() . '/ilias_obj_owner_' . bin2hex(random_bytes(4)); + mkdir($datadir); + try { + $foreign_dir = $this->directoryOfAnotherUser($datadir); + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('getClientDataDir')->willReturn($foreign_dir); + // the owner-mismatch warning is only given when there is a migrated module's overlay to protect + $setup_language->method('hasMigratedModules')->willReturn(true); + $foreign_owner = fileowner($foreign_dir); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + } finally { + MigratedPoFixture::removeDirectory($datadir); + } + + $this->assertNotSame(posix_geteuid(), $foreign_owner); + $this->assertCount(1, $messages); + $this->assertStringStartsWith('WARNING: Setup runs as user id ' . posix_geteuid() . ',', $messages[0]); + if ($foreign_owner === 0) { + $this->assertStringContainsString('belongs to root', $messages[0]); + } else { + $this->assertStringContainsString($foreign_dir . ' belongs to user id ' . $foreign_owner . '.', $messages[0]); + } + $this->assertSame(['de'], $flushed); + } + + private function directoryOfAnotherUser(string $own_directory): string + { + if (posix_geteuid() === 0) { + if (!@chown($own_directory, 65534)) { + $this->markTestSkipped('Cannot hand a directory to another user.'); + } + clearstatcache(); + return $own_directory; + } + if (fileowner('/') === posix_geteuid()) { + $this->markTestSkipped('No directory of another user available.'); + } + + return '/'; + } + + public function testAchieveDoesNotWarnWhenSetupRunsAsTheOwnerOfTheClientDataDirectory(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('A Setup run as root is always warned about (running Setup as root is not supported).'); + } + $datadir = sys_get_temp_dir() . '/ilias_obj_owner_' . bin2hex(random_bytes(4)); + mkdir($datadir); + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('getClientDataDir')->willReturn($datadir); + $io = $this->createMock(Setup\AdminInteraction::class); + $io->expects($this->never())->method('inform'); + + try { + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $io, + ])); + } finally { + MigratedPoFixture::removeDirectory($datadir); + } + } + + /** + * The owner warning comes in addition to the unwritable-directories warning: that one concerns this run, the + * owner mismatch every later write by the web server. + */ + public function testTheOwnerWarningIsGivenInAdditionToTheUnwritableDirectoriesWarning(): void + { + if (!function_exists('posix_geteuid')) { + $this->markTestSkipped('The owner check needs the POSIX extension.'); + } + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('findUnwritableOverlayDirectories')->willReturn(['/x/lang/components/pilot/lang']); + $setup_language->method('getClientDataDir')->willReturn('/'); + // the owner-mismatch warning is only given when there is a migrated module's overlay to protect + $setup_language->method('hasMigratedModules')->willReturn(true); + $messages = []; + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve($this->informCollectingEnvironment($messages)); + + $this->assertCount(2, $messages); + $this->assertStringContainsString('/x/lang/components/pilot/lang', $messages[0]); + $this->assertStringStartsWith('WARNING: Setup runs as user id ' . posix_geteuid() . ',', $messages[1]); + $this->assertStringContainsString('chown -R ', $messages[1]); + } + + /** + * The owner-mismatch warning only protects a migrated module's overlay: without a migrated module it is not given. + */ + public function testNoOwnerWarningWithoutAnyMigratedModule(): void + { + if (!function_exists('posix_geteuid')) { + $this->markTestSkipped('The owner check needs the POSIX extension.'); + } + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + $setup_language->method('getClientDataDir')->willReturn('/'); + $setup_language->method('hasMigratedModules')->willReturn(false); + $io = $this->createMock(Setup\AdminInteraction::class); + $io->expects($this->never())->method('inform'); + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment([ + Setup\Environment::RESOURCE_DATABASE => $this->databaseStub(), + Setup\Environment::RESOURCE_ADMIN_INTERACTION => $io, + ])); + } + + /** + * An instance with only the default directories (plugin Setup Objectives) must never refresh an installed + * language: that would drop every component-contributed module from lng_data. + */ + public function testAnInstanceWithDefaultDirectoriesInstallsMissingLanguagesButNeverRefreshes(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed, true); + + $this->invokeInstallLanguages(new ilLanguagesInstalledAndUpdatedObjective($setup_language), ['de', 'fr']); + + $this->assertSame(['fr'], $flushed); + } + + public function testInstallLanguagesWithoutKeysDoesNothing(): void + { + $flushed = []; + $setup_language = $this->createAchievableSetupLanguage(['de'], [], $flushed); + + $this->invokeInstallLanguages(new ilLanguagesInstalledAndUpdatedObjective($setup_language), []); + + $this->assertSame([], $flushed); + } + + private function iniStub(string $datadir): ilIniFile + { + $ini = $this->createStub(ilIniFile::class); + $ini->method('readVariable')->willReturnCallback( + static fn(string $group, string $name): string => $group === 'clients' && $name === 'datadir' ? $datadir : '' + ); + return $ini; + } + + /** + * @param array $resources + * @return list every value setClientDataDir() was called with + */ + private function achieveCapturingClientDataDir(array $resources): array + { + $flushed = []; + $calls = []; + $setup_language = $this->createAchievableSetupLanguage([], [], $flushed); + $setup_language->method('setClientDataDir')->willReturnCallback( + static function (?string $dir) use (&$calls): void { + $calls[] = $dir; + } + ); + + (new ilLanguagesInstalledAndUpdatedObjective($setup_language))->achieve(new Setup\ArrayEnvironment( + [Setup\Environment::RESOURCE_DATABASE => $this->databaseStub()] + $resources + )); + + return $calls; + } + + public function testAchieveTakesTheClientDataDirFromTheSetupEnvironment(): void + { + $datadir = sys_get_temp_dir() . '/ilias_obj_cdd_' . bin2hex(random_bytes(4)); + mkdir($datadir . '/myclient', 0775, true); + + try { + $calls = $this->achieveCapturingClientDataDir([ + Setup\Environment::RESOURCE_ILIAS_INI => $this->iniStub($datadir . '/'), + Setup\Environment::RESOURCE_CLIENT_ID => 'myclient', + ]); + } finally { + MigratedPoFixture::removeDirectory($datadir); + } + + $this->assertSame([$datadir . '/myclient'], $calls); + } + + /** + * @param array $resources + */ + #[DataProvider('incompleteClientEnvironments')] + public function testAchieveKeepsTheDefaultResolutionWithoutAUsableClientDataDir(\Closure $resources): void + { + $datadir = sys_get_temp_dir() . '/ilias_obj_cdd_' . bin2hex(random_bytes(4)); + mkdir($datadir . '/myclient', 0775, true); + + try { + $calls = $this->achieveCapturingClientDataDir($resources($this->iniStub($datadir))); + } finally { + MigratedPoFixture::removeDirectory($datadir); + } + + $this->assertSame([], $calls, 'setClientDataDir() must not be called (not even with null)'); + } + + public static function incompleteClientEnvironments(): array + { + return [ + 'no ini, no client id' => [static fn(ilIniFile $ini): array => []], + 'no client id' => [static fn(ilIniFile $ini): array => [Setup\Environment::RESOURCE_ILIAS_INI => $ini]], + 'no ini' => [static fn(ilIniFile $ini): array => [Setup\Environment::RESOURCE_CLIENT_ID => 'myclient']], + 'client directory does not exist' => [static fn(ilIniFile $ini): array => [ + Setup\Environment::RESOURCE_ILIAS_INI => $ini, + Setup\Environment::RESOURCE_CLIENT_ID => 'other', + ]], + 'unsafe client id' => [static fn(ilIniFile $ini): array => [ + Setup\Environment::RESOURCE_ILIAS_INI => $ini, + Setup\Environment::RESOURCE_CLIENT_ID => '../myclient', + ]], + ]; + } + + private static function directory(string $prefix, string $path, bool $local = false): LanguageFileDirectory + { + return MigratedPoFixture::directory($prefix, $path, $local); + } + + private static function manager(LanguageFileDirectory ...$directories): LanguageFileDirectoryManager + { + return new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + ...$directories + ); + } + + private function hashFor(?LanguageFileDirectoryManager $manager): string + { + $setup_language = new ilSetupLanguage('en', $manager); + return (new ilLanguagesInstalledAndUpdatedObjective( + $setup_language, + $this->createStub(\ILIAS\Language\Activities\InstallLanguage::class), + $this->createStub(\ILIAS\Language\Activities\UpdateLanguage::class) + ))->getHash(); + } + + public function testTheHashIsStableForTheSameDirectoryConfiguration(): void + { + $build = static fn(): LanguageFileDirectoryManager => new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + self::directory('tos', 'components/ILIAS/TermsOfService/lang/') + ); + + $this->assertSame($this->hashFor($build()), $this->hashFor($build())); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $this->hashFor($build())); + } + + /** + * The default-directories instance never refreshes, so it must not collide with one built with an equal explicit + * configuration. + */ + public function testTheDefaultDirectoriesHashDiffersFromAnExplicitlyEqualConfiguration(): void + { + $this->assertNotSame( + $this->hashFor(null), + $this->hashFor(new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory())) + ); + } + + /** + * Each part of a directory's configuration (class, prefix, path, suffix) and a contributed directory must change + * the hash, otherwise the Setup keeps only one of two differently configured objectives. + */ + #[DataProvider('differingConfigurations')] + public function testTheHashDiffersForDifferentDirectoryConfigurations(\Closure $a, \Closure $b): void + { + $this->assertNotSame($this->hashFor($a()), $this->hashFor($b())); + } + + public static function differingConfigurations(): array + { + return [ + 'default vs. with contributed directory' => [ + static fn() => null, + static fn() => self::manager(self::directory('tos', 'components/ILIAS/TermsOfService/lang/')), + ], + 'prefix differs' => [ + static fn() => self::manager(self::directory('tos', 'components/x/lang/')), + static fn() => self::manager(self::directory('file', 'components/x/lang/')), + ], + 'path differs' => [ + static fn() => self::manager(self::directory('tos', 'components/x/lang/')), + static fn() => self::manager(self::directory('tos', 'components/y/lang/')), + ], + 'suffix differs' => [ + static fn() => self::manager(self::directory('tos', 'components/x/lang/')), + static fn() => self::manager(self::directory('tos', 'components/x/lang/', true)), + ], + 'only the class differs' => [ + static fn() => self::manager(new \ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory(new \ILIAS\Language(), 'tos')), + static function (): LanguageFileDirectoryManager { + $component = new \ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory(new \ILIAS\Language(), 'tos'); + return self::manager(self::directory('tos', $component->getPath())); + }, + ], + ]; + } } diff --git a/components/ILIAS/Language/tests/ShippedChangesSinceBackupTest.php b/components/ILIAS/Language/tests/ShippedChangesSinceBackupTest.php new file mode 100644 index 000000000000..8d427491819a --- /dev/null +++ b/components/ILIAS/Language/tests/ShippedChangesSinceBackupTest.php @@ -0,0 +1,159 @@ + 'http://localhost', 'ILIAS_VERSION' => 'test'] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } + } + $this->temp_directory = sys_get_temp_dir() . '/ilias_shipped_changes_' . bin2hex(random_bytes(4)); + mkdir($this->temp_directory . '/lang', 0775, true); + mkdir($this->temp_directory . '/backup', 0775, true); + $this->shipped_relative_path = 'components/ILIAS/Language/tests/tmp-shipped-changes-' . bin2hex(random_bytes(4)) . '/'; + + $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $language->lang_path = $this->temp_directory . '/lang'; + $this->setGlobalVariable('lng', $language); + + $this->manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('mtest', $this->shipped_relative_path) + ); + $this->setGlobalVariable(LanguageFileDirectoryManager::class, $this->manager); + } + + protected function tearDown(): void + { + MigratedPoFixture::removeShippedDirectory($this->shipped_relative_path); + MigratedPoFixture::removeDirectory($this->temp_directory); + + parent::tearDown(); + } + + /** @param list $lines module#:#identifier#:#value */ + private function writeLang(string $file, array $lines): void + { + file_put_contents( + $file, + "HEADER\n\n" . implode("\n", $lines) . "\n" + ); + } + + /** @param array $entries identifier => value */ + private function shipPo(array $entries): void + { + MigratedPoFixture::writeShippedPo($this->shipped_relative_path, 'mtest', 'de', MigratedPoFixture::catalog('mtest', $entries)); + } + + /** @param array $entries identifier => value */ + private function backUpPo(array $entries): void + { + MigratedPoFixture::writePo($this->temp_directory . '/backup/mtest_de.po', MigratedPoFixture::catalog('mtest', $entries)); + } + + private function languageObject(): ilObjLanguageExt + { + $object = (new ReflectionClass(ilObjLanguageExt::class))->newInstanceWithoutConstructor(); + $object->key = 'de'; + $object->separator = self::SEPARATOR; + (new ReflectionProperty(ilObjLanguage::class, 'language_file_directory_manager'))->setValue($object, $this->manager); + + return $object; + } + + private function formerFile(): ilLanguageFile + { + $former = new ilLanguageFile($this->temp_directory . '/former.lang', 'de', 'global'); + $this->assertTrue($former->read()); + + return $former; + } + + public function testChangesOfAnOrdinaryModuleAndOfAMigratedModuleAreBothReportedUnderModuleSeparatorIdentifier(): void + { + $this->writeLang($this->temp_directory . '/former.lang', [ + 'plainmod#:#changed#:#Old', + 'plainmod#:#same#:#Same', + 'mtest#:#greeting#:#Lang line before', + ]); + $this->writeLang($this->temp_directory . '/lang/ilias_de.lang', [ + 'plainmod#:#changed#:#New', + 'plainmod#:#same#:#Same', + 'plainmod#:#added#:#Added', + // the .lang line of a PO module is not its source: it must not count although it differs from the backup + 'mtest#:#greeting#:#Lang line after', + ]); + $this->backUpPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss']); + $this->shipPo(['greeting' => 'Hallo (neu)', 'farewell' => 'Tschüss']); + + $changes = $this->languageObject()->getShippedChangesSinceBackup($this->formerFile(), $this->temp_directory . '/backup'); + + ksort($changes); + $this->assertSame( + [ + 'mtest#:#greeting' => 'Hallo (neu)', + 'plainmod#:#added' => 'Added', + 'plainmod#:#changed' => 'New', + ], + $changes + ); + } + + public function testAMigratedModuleWithoutBackupContributesNothingAndItsLangLinesStayExcluded(): void + { + $this->writeLang($this->temp_directory . '/former.lang', ['plainmod#:#a#:#1']); + $this->writeLang($this->temp_directory . '/lang/ilias_de.lang', [ + 'plainmod#:#a#:#2', + 'mtest#:#greeting#:#Lang line only', + ]); + $this->shipPo(['greeting' => 'Hallo']); + + $changes = $this->languageObject()->getShippedChangesSinceBackup($this->formerFile(), $this->temp_directory . '/backup'); + + $this->assertSame(['plainmod#:#a' => '2'], $changes); + } +} diff --git a/components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php b/components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php new file mode 100644 index 000000000000..d9309b65891f --- /dev/null +++ b/components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php @@ -0,0 +1,601 @@ + */ + private array $value_rows = []; + /** @var list */ + private array $remark_rows = []; + /** @var array>> rows fetchAssoc() hands out per statement */ + private array $statement_rows = []; + /** @var list every manipulate() SQL string, in call order */ + private array $manipulate_calls = []; + + protected function setUp(): void + { + parent::setUp(); + + foreach ([ + 'ILIAS_ABSOLUTE_PATH' => realpath(__DIR__ . '/../../../../'), + 'ILIAS_HTTP_PATH' => 'http://localhost', + 'ILIAS_VERSION' => 'test', + ] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } + } + // registerDirectoryManager() contributes a migrated module in every test, so a foreign CLIENT_DATA_DIR must skip the whole class + $this->created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + (new ReflectionClass(ilCachedLanguage::class))->getProperty('instances')->setValue(null, []); + MigratedPoFixture::resetRuntime(); + + $this->fixture_directory = __DIR__ . '/tmp-shipped-values-fixtures-' . bin2hex(random_bytes(4)); + $shipped = MigratedPoFixture::catalog('itest', [ + 'greeting' => 'Aus der PO', + 'po_only' => 'Nur in der PO', + 'farewell' => 'Tschüss', + ]); + $shipped->find('itest', 'greeting')->addExtractedComment('Begrüßung auf der Startseite'); + $shipped->find('itest', 'greeting')->addExtractedComment('kurz halten'); + MigratedPoFixture::writePo($this->shippedPo(), $shipped); + $this->registerDirectoryManager(); + + $this->setGlobalVariable('lng', $this->createStub(ilLanguage::class)); + $this->setGlobalVariable('ilDB', $this->database()); + $this->setGlobalVariable('ilUser', $this->createStub(ilObjUser::class)); + $this->setGlobalVariable('ilErr', $this->createStub(ilErrorHandling::class)); + $component_factory = $this->createStub(ilComponentFactory::class); + $component_factory->method('getActivePluginsInSlot')->willReturn(new ArrayIterator([])); + $this->setGlobalVariable('component.factory', $component_factory); + + $this->global_file = $this->fixture_directory . '/ilias_' . self::LANG . '.lang'; + $this->writeGlobalFile([ + 'common#:#yes#:#Ja###Zustimmung', + 'itest#:#greeting#:#Veraltet in lang/###alter lang-Kommentar', + 'itest#:#stale#:#Nur noch in lang/###Kommentar einer entfernten Variable', + 'itest#:#farewell#:#Tschüss', + 'common#:#no#:#Nein', + ]); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeDirectory($this->fixture_directory); + } + // never remove a foreign CLIENT_DATA_DIR + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects')->setValue(null, []); + + parent::tearDown(); + } + + private function shippedPo(): string + { + return $this->fixture_directory . '/itest_' . self::LANG . '.po'; + } + + /** + * Local changes of the migrated module are read from the overlay, so they are seeded there. + * + * @param array $entries + */ + private function seedOverlay(array $entries): void + { + MigratedPoFixture::writePair( + rtrim(CLIENT_DATA_DIR, '/') . '/lang/itest/' . self::LANG . '/itest_' . self::LANG, + MigratedPoFixture::catalog('itest', $entries) + ); + } + + /** + * Makes CLIENT_WEB_DIR name one of $clients client directories (each with a client.ini.php) below + * the fixture directory - the constant can be defined once per process (every test has its own). + */ + private function defineClientDirectories(int $clients): void + { + $web = $this->fixture_directory . '/web'; + for ($i = 1; $i <= $clients; $i++) { + mkdir($web . '/client_' . $i, 0775, true); + file_put_contents($web . '/client_' . $i . '/client.ini.php', ''); + } + define('CLIENT_WEB_DIR', $web . '/client_1'); + } + + private function registerDirectoryManager(): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('itest', 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/') + ) + ); + } + + /** + * Writes lang/ilias_zz.lang and makes it the (freshly read) global language file. + * + * @param list $lines raw lines after the header + */ + private function writeGlobalFile(array $lines): void + { + if (!is_dir(dirname($this->global_file))) { + mkdir(dirname($this->global_file), 0775, true); + } + file_put_contents( + $this->global_file, + "\n/**\n* @module language file\n*/\n\n" . implode("\n", $lines) + ); + $global = new ilLanguageFile($this->global_file, self::LANG, 'global'); + $this->assertTrue($global->read(), $global->getErrorMessage()); + (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects')->setValue(null, [self::LANG => $global]); + } + + private function database(): ilDBInterface + { + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value): string => "'" . (string) $value . "'"); + $db->method('in')->willReturn('1 = 1'); + $db->method('query')->willReturnCallback(function (string $sql): ilDBStatement { + $value_rows = str_starts_with($sql, /** @lang text */ 'SELECT module, identifier, value FROM lng_data') ? $this->value_rows : []; + $statement = $this->createStub(ilDBStatement::class); + $statement->method('fetchRow')->willReturnCallback(static function () use (&$value_rows): ?array { + return array_shift($value_rows); + }); + $this->statement_rows[spl_object_id($statement)] = str_starts_with($sql, /** @lang text */ 'SELECT module, identifier, remarks') + ? $this->remark_rows + : []; + return $statement; + }); + $db->method('fetchAssoc')->willReturnCallback(function (ilDBStatement $statement): ?array { + return array_shift($this->statement_rows[spl_object_id($statement)]); + }); + $db->method('manipulate')->willReturnCallback(function (string $sql): int { + $this->manipulate_calls[] = $sql; + return 0; + }); + + return $db; + } + + private function languageObject(): ilObjLanguageExt + { + $object = (new ReflectionClass(ilObjLanguageExt::class))->newInstanceWithoutConstructor(); + $object->key = self::LANG; + $object->separator = '#:#'; + // passed unconditionally to ShippedPoMerger::merge() as backup directory, never written to here (no overlay for "itest") + $object->cust_lang_path = $this->fixture_directory; + + return $object; + } + + private function expectAWarningAboutTheUnreadablePo(): void + { + $logger = $this->createMock(ilLogger::class); + $logger->expects($this->atLeastOnce())->method('warning')->with($this->stringContains('"itest"')); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + } + + /** + * @param array $values + * @return array + */ + private static function sorted(array $values): array + { + ksort($values); + return $values; + } + + public function testShippedValuesTakeAMigratedModuleFromItsShippedPoInsteadOfItsLangLines(): void + { + $object = $this->languageObject(); + + $this->assertSame( + [ + 'common#:#no' => 'Nein', + 'common#:#yes' => 'Ja', + 'itest#:#farewell' => 'Tschüss', + 'itest#:#greeting' => 'Aus der PO', + 'itest#:#po_only' => 'Nur in der PO', + ], + self::sorted($object->getShippedValues()) + ); + $this->assertSame(['itest'], $object->getModulesMaintainedInPoFiles()); + $this->assertSame([], $object->getUnreadableShippedPoModules()); + } + + /** A PO module contributes the extracted comments of its shipped .po, never its .lang comments. */ + public function testShippedCommentsTakeAMigratedModuleFromTheExtractedCommentsOfItsShippedPo(): void + { + $this->assertSame( + [ + 'common#:#yes' => 'Zustimmung', + 'itest#:#greeting' => 'Begrüßung auf der Startseite kurz halten', + ], + self::sorted($this->languageObject()->getShippedComments()) + ); + } + + /** getShippedComments() never carries the "not translated yet" marker; only getShippedCommentsForDisplay() does, display-only. */ + public function testShippedCommentsNeverIncludeTheFuzzyMarker(): void + { + $shipped = MigratedPoFixture::readPo($this->shippedPo()); + $shipped->find('itest', 'farewell')?->addFlag('fuzzy'); + MigratedPoFixture::writePo($this->shippedPo(), $shipped); + + $this->assertArrayNotHasKey('itest#:#farewell', $this->languageObject()->getShippedComments()); + } + + /** getShippedCommentsForDisplay() appends the marker for a "fuzzy" entry, to its own comment or alone. */ + public function testShippedCommentsForDisplayAppendsTheFuzzyMarker(): void + { + $shipped = MigratedPoFixture::readPo($this->shippedPo()); + $shipped->find('itest', 'greeting')?->addFlag('fuzzy'); + $shipped->find('itest', 'farewell')?->addFlag('fuzzy'); + MigratedPoFixture::writePo($this->shippedPo(), $shipped); + + $lng = $this->createStub(ilLanguage::class); + $lng->method('txt')->willReturnArgument(0); + $this->setGlobalVariable('lng', $lng); + + $comments = $this->languageObject()->getShippedCommentsForDisplay(); + + $this->assertSame('Begrüßung auf der Startseite kurz halten - lng_not_translated', $comments['itest#:#greeting'] ?? null); + $this->assertSame('lng_not_translated', $comments['itest#:#farewell'] ?? null); + $this->assertArrayNotHasKey('itest#:#po_only', $comments, 'not fuzzy - no marker'); + } + + /** An unreadable shipped .po keeps the module's .lang lines as approximation, logged and reported as GUI warning. */ + public function testAnUnreadableShippedPoFallsBackToTheLangLinesAndIsReported(): void + { + file_put_contents($this->shippedPo(), "msgid \"kaputt\n"); + $this->expectAWarningAboutTheUnreadablePo(); + $object = $this->languageObject(); + + $this->assertSame( + [ + 'common#:#no' => 'Nein', + 'common#:#yes' => 'Ja', + 'itest#:#farewell' => 'Tschüss', + 'itest#:#greeting' => 'Veraltet in lang/', + 'itest#:#stale' => 'Nur noch in lang/', + ], + self::sorted($object->getShippedValues()) + ); + $this->assertSame('alter lang-Kommentar', $object->getShippedComments()['itest#:#greeting']); + $this->assertSame(['itest'], $object->getUnreadableShippedPoModules()); + $this->assertSame(['itest'], $object->getModulesMaintainedInPoFiles()); + } + + public function testWithoutDirectoryManagerTheShippedValuesAreThePlainGlobalLanguageFile(): void + { + unset($GLOBALS['DIC'][LanguageFileDirectoryManager::class]); + $object = $this->languageObject(); + + $this->assertSame(ilLanguageFile::_getGlobalLanguageFile(self::LANG)->getAllValues(), $object->getShippedValues()); + $this->assertSame(ilLanguageFile::_getGlobalLanguageFile(self::LANG)->getAllComments(), $object->getShippedComments()); + $this->assertSame([], $object->getModulesMaintainedInPoFiles()); + } + + /** "Added" = not shipped: a stale .lang line of a migrated module counts as added, a value the .po ships does not. */ + public function testAddedValuesAreComparedAgainstTheShippedPoOfAMigratedModule(): void + { + $this->value_rows = [ + ['module' => 'common', 'identifier' => 'custom', 'value' => 'Eigene'], + ['module' => 'common', 'identifier' => 'yes', 'value' => 'Ja'], + ['module' => 'itest', 'identifier' => 'greeting', 'value' => 'Aus der PO'], + ['module' => 'itest', 'identifier' => 'po_only', 'value' => 'Nur in der PO'], + ['module' => 'itest', 'identifier' => 'stale', 'value' => 'Nur noch in lang/'], + ]; + + $this->assertSame( + ['common#:#custom' => 'Eigene', 'itest#:#stale' => 'Nur noch in lang/'], + $this->languageObject()->getAddedValues() + ); + } + + public function testCommentedValuesAreThoseWithAShippedCommentOfTheShippedPoForAMigratedModule(): void + { + $this->value_rows = [ + ['module' => 'common', 'identifier' => 'no', 'value' => 'Nein'], + ['module' => 'common', 'identifier' => 'yes', 'value' => 'Ja'], + ['module' => 'itest', 'identifier' => 'farewell', 'value' => 'Tschüss'], + ['module' => 'itest', 'identifier' => 'greeting', 'value' => 'Servus'], + ['module' => 'itest', 'identifier' => 'stale', 'value' => 'Nur noch in lang/'], + ]; + $this->seedOverlay(['greeting' => 'Servus']); + + $this->assertSame( + ['common#:#yes' => 'Ja', 'itest#:#greeting' => 'Servus'], + $this->languageObject()->getCommentedValues() + ); + } + + public function testMergedValuesAreTheShippedValuesOverriddenByTheLocalOnes(): void + { + $this->value_rows = [ + ['module' => 'common', 'identifier' => 'custom', 'value' => 'Eigene'], + ['module' => 'itest', 'identifier' => 'greeting', 'value' => 'Servus'], + ]; + $this->seedOverlay(['greeting' => 'Servus']); + + $this->assertSame( + [ + 'common#:#custom' => 'Eigene', + 'common#:#no' => 'Nein', + 'common#:#yes' => 'Ja', + 'itest#:#farewell' => 'Tschüss', + 'itest#:#greeting' => 'Servus', + 'itest#:#po_only' => 'Nur in der PO', + ], + self::sorted($this->languageObject()->getMergedValues()) + ); + } + + public function testMergedRemarksAreTheShippedCommentsOverriddenByTheLocalRemarks(): void + { + $this->remark_rows = [ + ['module' => 'common', 'identifier' => 'yes', 'remarks' => 'lokale Bemerkung'], + ['module' => 'itest', 'identifier' => 'farewell', 'remarks' => null], + ]; + + $this->assertSame( + [ + 'common#:#yes' => 'lokale Bemerkung', + 'itest#:#farewell' => null, + 'itest#:#greeting' => 'Begrüßung auf der Startseite kurz halten', + ], + self::sorted($this->languageObject()->getMergedRemarks()) + ); + } + + public function testTheEffectiveRemarkIsTheShippedNoteWithoutALocalOneAndTheLocalOneOtherwise(): void + { + $this->remark_rows = [['module' => 'itest', 'identifier' => 'farewell', 'remarks' => 'lokal']]; + + $this->assertSame( + ['itest#:#farewell' => 'lokal', 'itest#:#greeting' => 'Begrüßung auf der Startseite kurz halten'], + self::sorted($this->languageObject()->getEffectiveRemarks()) + ); + + $this->remark_rows = [['module' => 'itest', 'identifier' => 'greeting', 'remarks' => 'lokal']]; + + $this->assertSame('lokal', $this->languageObject()->getEffectiveRemarks()['itest#:#greeting']); + } + + public function testAnUnreadableShippedPoHasNoNoteForTheEffectiveRemarks(): void + { + file_put_contents($this->shippedPo(), "msgid \"kaputt\n"); + $this->expectAWarningAboutTheUnreadablePo(); + + $this->assertSame([], $this->languageObject()->getEffectiveRemarks()); + } + + public function testALocalRemarkOfAPluralMessageSuppressesTheNotesOfItsForms(): void + { + $catalog = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry('itest', 'item'); + $entry->setPlural('items', ['Eintrag', 'Einträge']); + $entry->addExtractedComment('Notiz zur Nachricht'); + $catalog->add($entry); + MigratedPoFixture::writePo($this->shippedPo(), $catalog); + + $notes = $this->languageObject()->getEffectiveRemarks(); + $this->assertSame('Notiz zur Nachricht', $notes['itest#:#item [0]'] ?? null); + $this->assertSame('Notiz zur Nachricht', $notes['itest#:#item [1]'] ?? null); + + $this->remark_rows = [['module' => 'itest', 'identifier' => 'item', 'remarks' => 'lokal']]; + $effective = $this->languageObject()->getEffectiveRemarks(); + $this->assertSame('lokal', $effective['itest#:#item'] ?? null); + $this->assertArrayNotHasKey('itest#:#item [0]', $effective); + $this->assertArrayNotHasKey('itest#:#item [1]', $effective); + } + + /** @return list the entry lines of the written global language file */ + private function entryLinesOfTheGlobalFile(): array + { + $content = (string) file_get_contents($this->global_file); + $start = strpos($content, ''); + $this->assertNotFalse($start); + + return explode("\n", ltrim(substr($content, $start + strlen('')), "\n")); + } + + /** A PO module is skipped entirely: its lines stay byte for byte, nothing is written or added; other modules are merged as before. */ + public function testMergeSkipsModulesMaintainedInPoFilesAndKeepsTheirLinesByteForByte(): void + { + $this->writeGlobalFile([ + 'common#:#yes#:#Ja###Zustimmung', + "itest#:#greeting#:#Veraltet in lang/ \t", + ' itest#:#stale#:#Alt###Kommentar ', + 'common#:#no#:#Nein ', + 'itest#:#farewell#:#Tschüss', + ]); + $this->value_rows = [ + ['module' => 'common', 'identifier' => 'new', 'value' => 'Neu'], + ['module' => 'common', 'identifier' => 'yes', 'value' => 'Jawohl'], + ['module' => 'itest', 'identifier' => 'farewell', 'value' => 'Servus'], + ['module' => 'itest', 'identifier' => 'greeting', 'value' => 'Lokal geändert'], + ['module' => 'itest', 'identifier' => 'local_only', 'value' => 'Nur lokal'], + ]; + $this->remark_rows = [ + ['module' => 'common', 'identifier' => 'yes', 'remarks' => 'lokale Bemerkung'], + ['module' => 'itest', 'identifier' => 'farewell', 'remarks' => 'lokale Bemerkung einer PO-Variable'], + ]; + // a "lang" component logger is always requested once the directory manager is registered + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + + $result = $this->languageObject()->mergeLocalChangesIntoGlobalLanguageFile(); + + // "itest" has no overlay (its lng_data rows are only the dual-write fallback): nothing to merge, so not reported as skipped + $this->assertSame( + ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => [], 'not_single_client' => []], + $result + ); + $this->assertSame( + [ + 'common#:#yes#:#Jawohl###lokale Bemerkung', + "itest#:#greeting#:#Veraltet in lang/ \t", + ' itest#:#stale#:#Alt###Kommentar ', + 'common#:#no#:#Nein', + 'itest#:#farewell#:#Tschüss', + 'common#:#new#:#Neu', + ], + $this->entryLinesOfTheGlobalFile() + ); + } + + public function testMergeWithoutModulesMaintainedInPoFilesMergesEverything(): void + { + unset($GLOBALS['DIC'][LanguageFileDirectoryManager::class]); + $this->value_rows = [ + ['module' => 'itest', 'identifier' => 'greeting', 'value' => 'Lokal geändert'], + ]; + + $this->assertSame( + ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => [], 'not_single_client' => []], + $this->languageObject()->mergeLocalChangesIntoGlobalLanguageFile() + ); + $this->assertSame( + [ + 'common#:#yes#:#Ja###Zustimmung', + 'itest#:#greeting#:#Lokal geändert###alter lang-Kommentar', + 'itest#:#stale#:#Nur noch in lang/###Kommentar einer entfernten Variable', + 'itest#:#farewell#:#Tschüss', + 'common#:#no#:#Nein', + ], + $this->entryLinesOfTheGlobalFile() + ); + } + + /** After a successful merge (mergeIntoDatabase()) the lng_data row of every taken-over value and remark loses local_change/remarks. */ + public function testMergeUpdatesLngDataOfTheTakenOverValueAndRemarkToNull(): void + { + $overlay = MigratedPoFixture::catalog('itest', ['greeting' => 'Lokal geändert']); + LocalChangeComments::setRemark($overlay->find('itest', 'greeting'), 'lokale Bemerkung'); + MigratedPoFixture::writePair( + rtrim(CLIENT_DATA_DIR, '/') . '/lang/itest/' . self::LANG . '/itest_' . self::LANG, + $overlay + ); + $logger = $this->createStub(ilLogger::class); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + + $this->defineClientDirectories(1); + $object = $this->languageObject(); + $object->cust_lang_path = $this->fixture_directory; + $result = $object->mergeLocalChangesIntoGlobalLanguageFile(); + + $this->assertNotSame([], $result['written'], 'the shipped .po was actually written'); + $this->assertSame( + [ + "UPDATE lng_data SET local_change = NULL WHERE lang_key = 'zz' AND module = 'itest' AND 1 = 1", + "UPDATE lng_data SET remarks = NULL WHERE lang_key = 'zz' AND module = 'itest' AND 1 = 1", + ], + $this->manipulate_calls + ); + } + + /** + * With more than one client the shared shipped .po stays as it is (their lng_data would no longer + * match it); the global language file is written, and the modules with local changes are reported. + */ + public function testMergeInAMultiClientInstallationLeavesTheSharedShippedPoAloneAndReportsTheModules(): void + { + $this->defineClientDirectories(2); + $this->seedOverlay(['greeting' => 'Lokal geändert']); + $po_before = (string) file_get_contents($this->shippedPo()); + $this->value_rows = [['module' => 'common', 'identifier' => 'yes', 'value' => 'Jawohl']]; + $this->setGlobalVariable('ilLoggerFactory', $this->createStub(ilLoggerFactory::class)); + + $result = $this->languageObject()->mergeLocalChangesIntoGlobalLanguageFile(); + + $this->assertSame( + ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => [], 'not_single_client' => ['itest']], + $result + ); + $this->assertSame($po_before, file_get_contents($this->shippedPo())); + $this->assertSame([], $this->manipulate_calls, 'lng_data of the PO module is not touched'); + $this->assertContains('common#:#yes#:#Jawohl###Zustimmung', $this->entryLinesOfTheGlobalFile()); + } + + public function testMergeInAMultiClientInstallationReportsNoModuleWithoutLocalChanges(): void + { + $this->defineClientDirectories(2); + $this->setGlobalVariable('ilLoggerFactory', $this->createStub(ilLoggerFactory::class)); + + $result = $this->languageObject()->mergeLocalChangesIntoGlobalLanguageFile(); + + $this->assertSame([], $result['not_single_client']); + } + + public function testASingleClientInstallationHasExactlyOneClientDirectoryWithAClientIni(): void + { + $this->assertFalse(defined('CLIENT_WEB_DIR'), 'precondition'); + $this->assertFalse(ilObjLanguageExt::isSingleClientInstallation(), 'client directory unknown'); + + $web = $this->fixture_directory . '/web'; + mkdir($web . '/client_a', 0775, true); + define('CLIENT_WEB_DIR', $web . '/client_a'); + $this->assertFalse(ilObjLanguageExt::isSingleClientInstallation(), 'no client.ini.php at all'); + + file_put_contents($web . '/client_a/client.ini.php', ''); + $this->assertTrue(ilObjLanguageExt::isSingleClientInstallation()); + + mkdir($web . '/client_b'); + file_put_contents($web . '/client_b/client.ini.php', ''); + $this->assertFalse(ilObjLanguageExt::isSingleClientInstallation(), 'a second client'); + } +} diff --git a/components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php b/components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php new file mode 100644 index 000000000000..1b601907b7d3 --- /dev/null +++ b/components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php @@ -0,0 +1,409 @@ +name(), [ + 'testTearDownNeverDeletesAClientDataDirThisClassDidNotCreateItself', + 'testRefusesToWriteIntoAClientDataDirOutsideSysTempDir', + ], true)) { + return; + } + $this->guardClientDataDirIsTestOwned(); + if (!defined('CLIENT_DATA_DIR')) { + define('CLIENT_DATA_DIR', sys_get_temp_dir() . '/ilias_sync_delete_import_test_' . bin2hex(random_bytes(4))); + $this->created_client_data_dir_root = true; + } + } + + /** Writing fixture files into a foreign CLIENT_DATA_DIR (e.g. the real /var/iliasdata) must be refused, not only its deletion. */ + private function guardClientDataDirIsTestOwned(): void + { + if (defined('CLIENT_DATA_DIR') && !str_starts_with(CLIENT_DATA_DIR, sys_get_temp_dir() . '/')) { + $this->markTestSkipped( + 'CLIENT_DATA_DIR ("' . CLIENT_DATA_DIR . '") is not a test-owned temp directory - ' + . 'refusing to write fixture files there.' + ); + } + } + + protected function tearDown(): void + { + if (isset($this->fixture_directory) && is_dir($this->fixture_directory)) { + array_map('unlink', glob($this->fixture_directory . '/*') ?: []); + rmdir($this->fixture_directory); + } + + // delete only a CLIENT_DATA_DIR this test created: it once wiped the real /var/iliasdata in a full-suite run + foreach ($this->overlayFixtureDirectories() as $overlay_fixture_dir) { + if (is_dir($overlay_fixture_dir)) { + $this->removeDirectoryRecursively($overlay_fixture_dir); + } + } + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + $this->removeDirectoryRecursively(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** + * The overlay directories this test's fixtures wrote to; empty if none were seeded. + * + * @return list + */ + private function overlayFixtureDirectories(): array + { + if (!isset($this->fixture_directory) || !defined('CLIENT_DATA_DIR')) { + return []; + } + + return array_map( + static fn(string $module): string => rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module, + array_values($this->seeded_modules) + ); + } + + /** + * The modules seedFixtureModule() seeded; their overlays (`lang//`) are this test's own. + * + * @var array + */ + private array $seeded_modules = []; + + private function removeDirectoryRecursively(string $dir): void + { + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path)) { + $this->removeDirectoryRecursively($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * Seeds only the shipped `.po` of a throwaway module (no shipped `.mo` exists). + * + * @param array $entries identifier => value + */ + private function seedFixtureModule(string $module, string $lang_key, array $entries): LanguageFileDirectory + { + $this->seeded_modules[$module] = $module; + $this->fixture_directory ??= __DIR__ . '/tmp-clearmigrated-fixtures-' . bin2hex(random_bytes(4)); + if (!is_dir($this->fixture_directory)) { + mkdir($this->fixture_directory, 0775, true); + } + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + foreach ($entries as $identifier => $value) { + $translations->add(MigratedPoFixture::entry($module, $identifier, $value)); + } + + $base_path = $this->fixture_directory . '/' . $module . '_' . $lang_key; + MigratedPoFixture::writePo($base_path . '.po', $translations); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + + return new class ($module, $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + } + + /** Bootstraps the overlay `.po`+`.mo` from the shipped `.po`, simulating an installed language (sync() would create a missing one too). */ + private function bootstrapOverlayFromShipped(string $module, string $lang_key): void + { + $overlay_dir = $this->overlayDirectory($module, $lang_key); + if (!is_dir($overlay_dir)) { + mkdir($overlay_dir, 0775, true); + } + copy( + $this->fixture_directory . '/' . $module . '_' . $lang_key . '.po', + $overlay_dir . $module . '_' . $lang_key . '.po' + ); + MigratedPoFixture::writeOverlayRevision($overlay_dir . $module . '_' . $lang_key, MigratedPoFixture::readPo($this->fixture_directory . '/' . $module . '_' . $lang_key . '.po')); + } + + private function overlayDirectory(string $module = 'dtest', string $lang_key = 'de'): string + { + return rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/'; + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed) + ); + } + + private function loadFixturePo(string $module, string $lang_key): \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog + { + return MigratedPoFixture::readPo($this->fixture_directory . '/' . $module . '_' . $lang_key . '.po'); + } + + private function loadOverlayPo(string $module, string $lang_key): \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog + { + return MigratedPoFixture::readPo($this->overlayDirectory($module, $lang_key) . $module . '_' . $lang_key . '.po'); + } + + private function overlayMoExists(string $module, string $lang_key): bool + { + return is_file(MigratedPoFixture::overlayMo($this->overlayDirectory($module, $lang_key) . $module . '_' . $lang_key)); + } + + /** + * @param array $to_save module.separator.topic => value, the shape importLanguageFile() passes to _saveValues() + */ + private function invoke(array $modules_before_delete, array $to_save): void + { + $instance = (new ReflectionClass(ilObjLanguageExt::class))->newInstanceWithoutConstructor(); + $instance->key = 'de'; + $instance->separator = '#:#'; + + $method = new ReflectionMethod(ilObjLanguageExt::class, 'syncMigratedFilesAfterDeleteModeImport'); + $method->invoke($instance, $modules_before_delete, $to_save); + } + + /** A module that existed before the wipe but is absent from $to_save is synced with an empty map; the shipped `.po` stays untouched. */ + public function testSyncsAModuleMissingFromTheImportWithAnEmptyMap(): void + { + $directory = $this->seedFixtureModule('dtest', 'de', ['greeting' => 'Hallo']); + $this->bootstrapOverlayFromShipped('dtest', 'de'); + $this->registerDirectoryManager($directory); + + $this->invoke(['dtest'], []); + + $this->assertFileDoesNotExist($this->overlayDirectory() . 'dtest_de.po'); + $this->assertNotNull( + $this->loadFixturePo('dtest', 'de')->find('dtest', 'greeting'), + 'the shipped .po must never be touched by this sync path' + ); + } + + /** A module both existing before and present in $to_save is synced with exactly its subset, into the overlay. */ + public function testSyncsAModulePresentInTheImportWithItsToSaveSubset(): void + { + $directory = $this->seedFixtureModule('dtest', 'de', ['greeting' => 'Hallo']); + $this->bootstrapOverlayFromShipped('dtest', 'de'); + $this->registerDirectoryManager($directory); + + $this->invoke(['dtest'], ['dtest#:#greeting' => 'Hallo, neu']); + + $translation = $this->loadOverlayPo('dtest', 'de')->find(null, 'greeting'); + $this->assertNotNull($translation); + $this->assertSame('Hallo, neu', $translation->getTranslation()); + + $shipped_translation = $this->loadFixturePo('dtest', 'de')->find('dtest', 'greeting'); + $this->assertNotNull($shipped_translation); + $this->assertSame('Hallo', $shipped_translation->getTranslation(), 'the shipped .po must never be touched'); + } + + public function testDoesNotThrowForANonMigratedModuleMissingFromTheImport(): void + { + $this->registerDirectoryManager(); + + $this->invoke(['plain_module'], []); + + $this->addToAssertionCount(1); + } + + public function testIsANoOpWhenNoDirectoryManagerIsRegisteredAtAll(): void + { + $this->invoke(['dtest'], []); + + $this->addToAssertionCount(1); + } + + /** A key without separator must not be attributed to a module: pins explode() yielding exactly two parts. */ + public function testAToSaveKeyWithoutASeparatorDoesNotAccidentallyAttachToAnyModule(): void + { + $directory = $this->seedFixtureModule('dtest', 'de', ['greeting' => 'Hallo']); + $this->bootstrapOverlayFromShipped('dtest', 'de'); + $this->registerDirectoryManager($directory); + + // "dtest" is only reached via $modules_before_delete, so it is synced with an empty map + $this->invoke(['dtest'], ['not_a_valid_key' => 'value']); + + $this->assertFileDoesNotExist($this->overlayDirectory() . 'dtest_de.po'); + } + + /** Of several modules existing before only one is in $to_save: the missing one is emptied, the present one updated. */ + public function testSyncsEachOfSeveralModulesWithItsOwnCorrectEntries(): void + { + $missing = $this->seedFixtureModule('dtest', 'de', ['greeting' => 'Hallo']); + $this->bootstrapOverlayFromShipped('dtest', 'de'); + $kept = $this->seedFixtureModule('ktest', 'de', ['greeting' => 'Hallo']); + $this->bootstrapOverlayFromShipped('ktest', 'de'); + $this->registerDirectoryManager($missing, $kept); + + $this->invoke(['dtest', 'ktest'], ['ktest#:#greeting' => 'Hallo, neu']); + + $this->assertFileDoesNotExist($this->overlayDirectory() . 'dtest_de.po'); + $updated = $this->loadOverlayPo('ktest', 'de')->find(null, 'greeting'); + $this->assertNotNull($updated); + $this->assertSame('Hallo, neu', $updated->getTranslation()); + } + + /** An import into a language without overlay creates it, seeded with the shipped originals. */ + public function testCreatesAMissingOverlayWithTheShippedOriginal(): void + { + $directory = $this->seedFixtureModule('dtest', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + + $this->invoke(['dtest'], ['dtest#:#greeting' => 'Hallo, neu']); + + $this->assertTrue($this->overlayMoExists('dtest', 'de')); + $greeting = $this->loadOverlayPo('dtest', 'de')->find(null, 'greeting'); + $this->assertSame('Hallo, neu', $greeting->getTranslation()); + $this->assertSame('Hallo', \ILIAS\Language\ComponentTranslation\LocalChangeComments::getOriginal($greeting)); + } + + /** + * tearDown() once deleted the whole CLIENT_DATA_DIR if it was is_dir(), wiping the real /var/iliasdata in a full-suite + * run: a foreign, pre-existing definition must stay untouched. Own process so no other test defines it first; + * setUp() skips auto-defining it for this test by name. + */ + #[RunInSeparateProcess] + public function testTearDownNeverDeletesAClientDataDirThisClassDidNotCreateItself(): void + { + $this->assertFalse(defined('CLIENT_DATA_DIR')); + + $foreign_dir = sys_get_temp_dir() . '/ilias_foreign_client_data_dir_' . bin2hex(random_bytes(4)); + mkdir($foreign_dir, 0775, true); + file_put_contents($foreign_dir . '/marker.txt', 'do-not-delete'); + // a foreign CLIENT_DATA_DIR this class did not create + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + $directory = $this->seedFixtureModule('dforeign', 'de', ['greeting' => 'Hallo']); + $this->bootstrapOverlayFromShipped('dforeign', 'de'); + $this->registerDirectoryManager($directory); + $this->assertTrue($this->overlayMoExists('dforeign', 'de')); + + // tearDown() runs again after the test; harmless, nothing is left to clean up + $this->tearDown(); + + $this->assertFileExists( + $foreign_dir . '/marker.txt', + 'tearDown() deleted (part of) a CLIENT_DATA_DIR this test class did not create itself.' + ); + $this->assertFalse($this->overlayMoExists('dforeign', 'de')); + } finally { + // runs even if an assertion failed, so $foreign_dir is never left behind + if (is_file($foreign_dir . '/marker.txt')) { + unlink($foreign_dir . '/marker.txt'); + } + if (is_dir($foreign_dir)) { + $this->removeDirectoryRecursively($foreign_dir); + } + } + } + + /** A CLIENT_DATA_DIR outside sys_get_temp_dir() must not be written into: guardClientDataDirIsTestOwned() skips. */ + #[RunInSeparateProcess] + public function testRefusesToWriteIntoAClientDataDirOutsideSysTempDir(): void + { + $foreign_dir = __DIR__ . '/tmp-not-a-temp-dir-' . bin2hex(random_bytes(4)); + $this->assertFalse(str_starts_with($foreign_dir, sys_get_temp_dir() . '/')); + mkdir($foreign_dir, 0775, true); + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + try { + $this->guardClientDataDirIsTestOwned(); + $this->fail('guardClientDataDirIsTestOwned() must refuse a CLIENT_DATA_DIR outside sys_get_temp_dir().'); + } catch (\PHPUnit\Framework\SkippedWithMessageException $e) { + $this->assertStringContainsString($foreign_dir, $e->getMessage()); + } + + $this->assertSame([], array_diff(scandir($foreign_dir) ?: [], ['.', '..'])); + } finally { + if (is_dir($foreign_dir)) { + $this->removeDirectoryRecursively($foreign_dir); + } + } + } +} diff --git a/components/ILIAS/Language/tests/ToJSMapTest.php b/components/ILIAS/Language/tests/ToJSMapTest.php new file mode 100644 index 000000000000..ce97f05e3acb --- /dev/null +++ b/components/ILIAS/Language/tests/ToJSMapTest.php @@ -0,0 +1,82 @@ +` block via json_encode(): without JSON_HEX_TAG a value with "" + * would end the element early, and a "'" in the key could break out of the JS string literal. + */ +class ToJSMapTest extends ilLanguageBaseTestCase +{ + /** + * @param array $map + * @return list + */ + private function callToJSMap(array $map): array + { + $captured = []; + $tpl = $this->createStub(ilGlobalTemplateInterface::class); + $tpl->method('addOnloadCode')->willReturnCallback(function (string $code) use (&$captured): void { + $captured[] = $code; + }); + $this->setGlobalVariable('tpl', $tpl); + + $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $language->toJSMap($map, $tpl); + + return $captured; + } + + public function testAValueEndingTheScriptElementIsNeutralisedByHexEncoding(): void + { + $code = $this->callToJSMap(['greeting' => 'Hallo']); + + $this->assertCount(1, $code); + $this->assertStringNotContainsString('', $code[0]); + $this->assertStringContainsString('\u003Cscript\u003E', $code[0]); + } + + public function testTheGeneratedCodeNeverClosesTheSurroundingScriptElement(): void + { + $code = $this->callToJSMap(['greeting' => 'Hallo']); + $html = '

content

'; + + $document = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR, 'UTF-8'); + + $this->assertSame(1, $document->getElementsByTagName('script')->length); + $this->assertNotNull($document->getElementById('after')); + } + + public function testASingleQuoteInTheKeyCannotBreakOutOfTheJsStringLiteral(): void + { + $code = $this->callToJSMap(["it's_a_key" => 'Wert']); + + $this->assertCount(1, $code); + $this->assertStringNotContainsString("'a_key", $code[0]); + $this->assertStringContainsString(json_encode("it's_a_key", JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), $code[0]); + } + + public function testAnEmptyValueIsSkippedEntirely(): void + { + $code = $this->callToJSMap(['empty' => '', 'filled' => 'x']); + + $this->assertCount(1, $code); + $this->assertStringContainsString('filled', $code[0]); + } +} diff --git a/components/ILIAS/Language/tests/UninstallRemovesMigratedMoFilesTest.php b/components/ILIAS/Language/tests/UninstallRemovesMigratedMoFilesTest.php new file mode 100644 index 000000000000..3ce6adf9d0c9 --- /dev/null +++ b/components/ILIAS/Language/tests/UninstallRemovesMigratedMoFilesTest.php @@ -0,0 +1,502 @@ +removeDirectoryRecursively($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + protected function setUp(): void + { + parent::setUp(); + + if (!defined('ILIAS_ABSOLUTE_PATH')) { + define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../')); + } + + MigratedPoFixture::resetRuntime(); + } + + protected function tearDown(): void + { + if (isset($this->fixture_directory) && is_dir($this->fixture_directory)) { + $this->removeDirectoryRecursively($this->fixture_directory); + } + + // delete only a CLIENT_DATA_DIR this test created: it once wiped the real /var/iliasdata in a full-suite run + $overlay_fixture_dir = $this->overlayFixtureDirectory(); + if ($overlay_fixture_dir !== null && is_dir($overlay_fixture_dir)) { + $this->removeDirectoryRecursively($overlay_fixture_dir); + } + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + $this->removeDirectoryRecursively(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** The one subdirectory under CLIENT_DATA_DIR this test's fixture wrote to (see seedOverlay()); null if none. */ + private function overlayFixtureDirectory(): ?string + { + if (!isset($this->fixture_directory) || !defined('CLIENT_DATA_DIR')) { + return null; + } + + return rtrim(CLIENT_DATA_DIR, '/') . '/lang'; + } + + /** + * Deliberately not called from setUp(): the "CLIENT_DATA_DIR cannot be resolved" test must observe it undefined. + * An already defined CLIENT_DATA_DIR is reused: tearDown() removes only this class' own subdirectory. + */ + private function ensureClientDataDirDefined(): void + { + // seeding an overlay would write fixture files into a foreign CLIENT_DATA_DIR (e.g. the real /var/iliasdata): refuse + if (defined('CLIENT_DATA_DIR') && !str_starts_with(CLIENT_DATA_DIR, sys_get_temp_dir() . '/')) { + $this->markTestSkipped( + 'CLIENT_DATA_DIR ("' . CLIENT_DATA_DIR . '") is not a test-owned temp directory - ' + . 'refusing to write fixture files there.' + ); + } + if (!defined('CLIENT_DATA_DIR')) { + define('CLIENT_DATA_DIR', sys_get_temp_dir() . '/ilias_uninstall_mo_test_' . bin2hex(random_bytes(4))); + $this->created_client_data_dir_root = true; + } + } + + /** + * Seeds only the shipped `.po` of a throwaway module (no shipped `.mo` exists). + * + * @param array $entries identifier => value + */ + private function seedShippedModule(string $module, string $lang_key, array $entries): LanguageFileDirectory + { + $this->fixture_directory ??= __DIR__ . '/tmp-uninstall-fixtures-' . bin2hex(random_bytes(4)); + if (!is_dir($this->fixture_directory)) { + mkdir($this->fixture_directory, 0775, true); + } + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + foreach ($entries as $identifier => $value) { + $translations->add(MigratedPoFixture::entry($module, $identifier, $value)); + } + + $base_path = $this->fixture_directory . '/' . $module . '_' . $lang_key; + MigratedPoFixture::writePo($base_path . '.po', $translations); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + + return new class ($module, $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + } + + /** + * Seeds the overlay `.po`+`.mo` pair under CLIENT_DATA_DIR, mirroring the shipped relative path, simulating an installed + * language. Requires seedShippedModule() first. + * + * @param array $entries identifier => value + */ + private function seedOverlay(string $module, string $lang_key, array $entries): void + { + $this->ensureClientDataDirDefined(); + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + foreach ($entries as $identifier => $value) { + $translations->add(MigratedPoFixture::entry($module, $identifier, $value)); + } + + $overlay_dir = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/'; + if (!is_dir($overlay_dir)) { + mkdir($overlay_dir, 0775, true); + } + + $base_path = $overlay_dir . $module . '_' . $lang_key; + MigratedPoFixture::writePo($base_path . '.po', $translations); + MigratedPoFixture::writeOverlayRevision($base_path, $translations); + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed) + ); + } + + private function shippedPath(string $module, string $lang_key, string $extension): string + { + return $this->fixture_directory . '/' . $module . '_' . $lang_key . '.' . $extension; + } + + private function overlayPath(string $module, string $lang_key, string $extension): string + { + $base = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/' . $module . '_' . $lang_key; + + return match ($extension) { + 'mo' => MigratedPoFixture::overlayMo($base), + 'lock' => MigratedPoFixture::overlayLock($base), + default => $base . '.' . $extension, + }; + } + + private function loadShippedPo(string $module, string $lang_key): \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog + { + return MigratedPoFixture::readPo($this->shippedPath($module, $lang_key, 'po')); + } + + private function callRemoveMigratedMoFiles(string $lang_key): void + { + (new ReflectionClass(ilObjLanguage::class)) + ->getMethod('removeMigratedMoFiles') + ->invoke(null, $lang_key); + } + + /** + * What the runtime serves for $module/$lang_key, see MigratedPoFixture::servedTexts(). + * + * @return array|null + */ + private function callLoadFromMigratedLanguageFile(string $module, string $lang_key, bool $build = true): ?array + { + return MigratedPoFixture::servedTexts($module, $lang_key, $build); + } + + public function testRemovesBothOverlayFilesButLeavesTheShippedPoUntouched(): void + { + $directory = $this->seedShippedModule('utest', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('utest', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + $po_before = file_get_contents($this->shippedPath('utest', 'de', 'po')); + + $this->callRemoveMigratedMoFiles('de'); + + $this->assertFileDoesNotExist($this->overlayPath('utest', 'de', 'mo')); + $this->assertFileDoesNotExist($this->overlayPath('utest', 'de', 'po')); + $this->assertFileExists($this->shippedPath('utest', 'de', 'po')); + $this->assertSame($po_before, file_get_contents($this->shippedPath('utest', 'de', 'po'))); + } + + public function testRemovesEachContributedModuleIndependently(): void + { + $first = $this->seedShippedModule('uone', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('uone', 'de', ['greeting' => 'Hallo']); + $second = $this->seedShippedModule('utwo', 'de', ['greeting' => 'Servus']); + $this->seedOverlay('utwo', 'de', ['greeting' => 'Servus']); + $this->registerDirectoryManager($first, $second); + + $this->callRemoveMigratedMoFiles('de'); + + $this->assertFileDoesNotExist($this->overlayPath('uone', 'de', 'mo')); + $this->assertFileDoesNotExist($this->overlayPath('uone', 'de', 'po')); + $this->assertFileDoesNotExist($this->overlayPath('utwo', 'de', 'mo')); + $this->assertFileDoesNotExist($this->overlayPath('utwo', 'de', 'po')); + $this->assertFileExists($this->shippedPath('uone', 'de', 'po')); + $this->assertFileExists($this->shippedPath('utwo', 'de', 'po')); + } + + public function testOnlyRemovesTheOverlayForTheRequestedLanguageNotOtherLanguages(): void + { + $directory = $this->seedShippedModule('utest', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('utest', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('utest', 'fr', ['greeting' => 'Bonjour']); + $this->registerDirectoryManager($directory); + + $this->callRemoveMigratedMoFiles('de'); + + $this->assertFileDoesNotExist($this->overlayPath('utest', 'de', 'mo')); + $this->assertFileExists($this->overlayPath('utest', 'fr', 'mo')); + $this->assertFileExists($this->overlayPath('utest', 'fr', 'po')); + } + + public function testIsANoOpWhenNoOverlayExistsForThatLanguage(): void + { + $directory = $this->seedShippedModule('utest', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + + $this->callRemoveMigratedMoFiles('de'); + + $this->addToAssertionCount(1); + $this->assertFileExists($this->shippedPath('utest', 'de', 'po')); + } + + public function testIsANoOpForALanguageThatHasNoOverlayAtAll(): void + { + $directory = $this->seedShippedModule('utest', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('utest', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + + $this->callRemoveMigratedMoFiles('fr'); + + $this->assertFileExists($this->overlayPath('utest', 'de', 'mo')); + $this->assertFileExists($this->overlayPath('utest', 'de', 'po')); + } + + public function testReturnsImmediatelyWithoutErrorWhenNoDirectoryManagerIsRegisteredAtAll(): void + { + $this->callRemoveMigratedMoFiles('de'); + + $this->addToAssertionCount(1); + } + + /** Without a resolvable CLIENT_DATA_DIR removeMigratedMoFiles() must no-op, never touch the shipped `.po`; own process, since another test defines it. */ + #[RunInSeparateProcess] + public function testIsANoOpWhenClientDataDirIsNotResolvable(): void + { + $this->assertFalse(defined('CLIENT_DATA_DIR')); + + $directory = $this->seedShippedModule('utest', 'de', ['greeting' => 'Hallo']); + $shipped_po_before = file_get_contents($this->shippedPath('utest', 'de', 'po')); + $this->registerDirectoryManager($directory); + + $this->callRemoveMigratedMoFiles('de'); + + $this->assertSame($shipped_po_before, file_get_contents($this->shippedPath('utest', 'de', 'po'))); + } + + public function testInvalidatesIlLanguagesCacheSoTheRemovalIsVisibleImmediately(): void + { + $directory = $this->seedShippedModule('utest', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('utest', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + // "de" is installed (the migrated state is only served for installed languages) + (new ReflectionProperty(ilLanguage::class, 'installed_languages'))->setValue(null, ['de']); + + $before = $this->callLoadFromMigratedLanguageFile('utest', 'de'); + $this->assertSame(['greeting' => 'Hallo'], $before); + + $this->callRemoveMigratedMoFiles('de'); + + // a stale hit from before the removal must not leak through + $this->assertNull($this->callLoadFromMigratedLanguageFile('utest', 'de', false)); + } + + /** + * A failure removing one module's overlay is logged via $DIC->logger()->forComponent('lang')->warning() and swallowed, + * and does not abort other modules. The logger is stubbed via the 'ilLoggerFactory' service; the failing module's overlay + * directory is made unwritable (unlink() needs directory permission) and the PHP warning silenced. + */ + public function testLogsAndSwallowsARemovalFailureForOneModuleWithoutAbortingOthers(): void + { + if (posix_getuid() === 0) { + $this->markTestSkipped('Cannot force an unremovable file while running as root; skipping.'); + } + + $this->ensureClientDataDirDefined(); + $this->fixture_directory ??= __DIR__ . '/tmp-uninstall-fixtures-' . bin2hex(random_bytes(4)); + if (!is_dir($this->fixture_directory)) { + mkdir($this->fixture_directory, 0775, true); + } + + // the failing module has its own sub-path, so only its overlay directory is locked + $shipped_readonly_dir = $this->fixture_directory . '/readonly'; + mkdir($shipped_readonly_dir, 0775, true); + $shipped_translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + $shipped_translations->add(MigratedPoFixture::entry('uone', 'greeting', 'Hallo')); + MigratedPoFixture::writePo($shipped_readonly_dir . '/uone_de.po', $shipped_translations); + + $relative_readonly_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/readonly/'; + $overlay_readonly_dir = rtrim(CLIENT_DATA_DIR, '/') . '/lang/uone/de/'; + mkdir($overlay_readonly_dir, 0775, true); + MigratedPoFixture::writePair($overlay_readonly_dir . 'uone_de', $shipped_translations); + + $failing = new class ('uone', $relative_readonly_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + $working = $this->seedShippedModule('utwo', 'de', ['greeting' => 'Servus']); + $this->seedOverlay('utwo', 'de', ['greeting' => 'Servus']); + $this->registerDirectoryManager($failing, $working); + + chmod($overlay_readonly_dir, 0555); + + // two warnings: the lock file cannot be created, then the removal itself fails + $warnings = []; + $logger = $this->createStub(ilLogger::class); + $logger->method('warning')->willReturnCallback(function (string $message) use (&$warnings): void { + $warnings[] = $message; + }); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $this->callRemoveMigratedMoFiles('de'); + } finally { + restore_error_handler(); + chmod($overlay_readonly_dir, 0775); + } + + $this->assertCount(2, $warnings); + $this->assertStringContainsString('uone/de/lock', $warnings[0]); + $this->assertStringContainsString('uone', $warnings[1]); + + $this->assertFileExists(MigratedPoFixture::overlayMo($overlay_readonly_dir . 'uone_de')); + $this->assertFileExists($overlay_readonly_dir . 'uone_de.po'); + + $this->assertFileDoesNotExist($this->overlayPath('utwo', 'de', 'mo')); + $this->assertFileDoesNotExist($this->overlayPath('utwo', 'de', 'po')); + } + + /** + * tearDown() once deleted the whole CLIENT_DATA_DIR if it was merely defined(), wiping the real /var/iliasdata in a + * full-suite run: a foreign, pre-existing definition must stay untouched. Own process so no other test defines it first. + */ + #[RunInSeparateProcess] + public function testTearDownNeverDeletesAClientDataDirThisClassDidNotCreateItself(): void + { + $foreign_dir = sys_get_temp_dir() . '/ilias_foreign_client_data_dir_' . bin2hex(random_bytes(4)); + mkdir($foreign_dir, 0775, true); + file_put_contents($foreign_dir . '/marker.txt', 'do-not-delete'); + // a foreign CLIENT_DATA_DIR this class did not create + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + // the fixture is still written into the foreign directory; only deleting CLIENT_DATA_DIR itself must never happen + $directory = $this->seedShippedModule('uforeign', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('uforeign', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + $this->assertFileExists($this->overlayPath('uforeign', 'de', 'po')); + + // tearDown() runs again after the test; harmless, nothing is left to clean up + $this->tearDown(); + + $this->assertFileExists( + $foreign_dir . '/marker.txt', + 'tearDown() deleted (part of) a CLIENT_DATA_DIR this test class did not create itself.' + ); + $this->assertFileDoesNotExist($this->overlayPath('uforeign', 'de', 'po')); + } finally { + // runs even if an assertion failed, so $foreign_dir is never left behind + if (is_file($foreign_dir . '/marker.txt')) { + unlink($foreign_dir . '/marker.txt'); + } + if (is_dir($foreign_dir)) { + $this->removeDirectoryRecursively($foreign_dir); + } + } + } + + /** A CLIENT_DATA_DIR outside sys_get_temp_dir() must not be written into: ensureClientDataDirDefined() skips. Own process. */ + #[RunInSeparateProcess] + public function testRefusesToWriteIntoAClientDataDirOutsideSysTempDir(): void + { + $foreign_dir = __DIR__ . '/tmp-not-a-temp-dir-' . bin2hex(random_bytes(4)); + $this->assertFalse(str_starts_with($foreign_dir, sys_get_temp_dir() . '/')); + mkdir($foreign_dir, 0775, true); + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + try { + $directory = $this->seedShippedModule('uoutside', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('uoutside', 'de', ['greeting' => 'Hallo']); + $this->fail('seedOverlay() must refuse to write into a CLIENT_DATA_DIR outside sys_get_temp_dir().'); + } catch (SkippedWithMessageException $e) { + $this->assertStringContainsString($foreign_dir, $e->getMessage()); + } + + $this->assertSame([], array_diff(scandir($foreign_dir) ?: [], ['.', '..'])); + } finally { + if (is_dir($foreign_dir)) { + $this->removeDirectoryRecursively($foreign_dir); + } + } + } +} diff --git a/components/ILIAS/Language/tests/UninstallRemovesPluginMigratedMoFilesTest.php b/components/ILIAS/Language/tests/UninstallRemovesPluginMigratedMoFilesTest.php new file mode 100644 index 000000000000..94837b2f3aa6 --- /dev/null +++ b/components/ILIAS/Language/tests/UninstallRemovesPluginMigratedMoFilesTest.php @@ -0,0 +1,500 @@ +fixture_directory) && is_dir($this->fixture_directory)) { + $this->removeDirectoryRecursively($this->fixture_directory); + } + if (isset($this->plugin_root_directory) && is_dir($this->plugin_root_directory)) { + $this->removeDirectoryRecursively($this->plugin_root_directory); + } + + // delete only a CLIENT_DATA_DIR this test created: it once wiped the real /var/iliasdata in a full-suite run + $overlay_fixture_dir = $this->overlayFixtureDirectory(); + if ($overlay_fixture_dir !== null && is_dir($overlay_fixture_dir)) { + $this->removeDirectoryRecursively($overlay_fixture_dir); + } + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + $this->removeDirectoryRecursively(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** The one subdirectory under CLIENT_DATA_DIR this test's fixture wrote to (see seedOverlay()); null if none. */ + private function overlayFixtureDirectory(): ?string + { + if (!isset($this->fixture_directory) || !defined('CLIENT_DATA_DIR')) { + return null; + } + + return rtrim(CLIENT_DATA_DIR, '/') . '/lang'; + } + + private function removeDirectoryRecursively(string $dir): void + { + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path)) { + $this->removeDirectoryRecursively($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * Deliberately not called from setUp(): the "CLIENT_DATA_DIR cannot be resolved" test must observe it undefined. + * An already defined CLIENT_DATA_DIR is reused: tearDown() removes only this class' own subdirectory. + */ + private function ensureClientDataDirDefined(): void + { + // seeding an overlay would write fixture files into a foreign CLIENT_DATA_DIR (e.g. the real /var/iliasdata): refuse + if (defined('CLIENT_DATA_DIR') && !str_starts_with(CLIENT_DATA_DIR, sys_get_temp_dir() . '/')) { + $this->markTestSkipped( + 'CLIENT_DATA_DIR ("' . CLIENT_DATA_DIR . '") is not a test-owned temp directory - ' + . 'refusing to write fixture files there.' + ); + } + if (!defined('CLIENT_DATA_DIR')) { + define('CLIENT_DATA_DIR', sys_get_temp_dir() . '/ilias_plugin_uninstall_mo_test_' . bin2hex(random_bytes(4))); + $this->created_client_data_dir_root = true; + } + } + + /** Seeds only the shipped `.po` of a throwaway module/language (no shipped `.mo` exists). */ + private function seedShippedModule(string $module, string $lang_key, array $entries): LanguageFileDirectory + { + $this->fixture_directory ??= __DIR__ . '/tmp-plugin-uninstall-fixtures-' . bin2hex(random_bytes(4)); + if (!is_dir($this->fixture_directory)) { + mkdir($this->fixture_directory, 0775, true); + } + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + foreach ($entries as $identifier => $value) { + $translations->add(MigratedPoFixture::entry($module, $identifier, $value)); + } + + $base_path = $this->fixture_directory . '/' . $module . '_' . $lang_key; + MigratedPoFixture::writePo($base_path . '.po', $translations); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + + return new class ($module, $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + } + + /** Seeds the overlay `.po`+`.mo` pair under CLIENT_DATA_DIR, mirroring the shipped relative path. Requires seedShippedModule() first. */ + private function seedOverlay(string $module, string $lang_key, array $entries): void + { + $this->ensureClientDataDirDefined(); + + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + foreach ($entries as $identifier => $value) { + $translations->add(MigratedPoFixture::entry($module, $identifier, $value)); + } + + $overlay_dir = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/'; + if (!is_dir($overlay_dir)) { + mkdir($overlay_dir, 0775, true); + } + + $base_path = $overlay_dir . $module . '_' . $lang_key; + MigratedPoFixture::writePo($base_path . '.po', $translations); + MigratedPoFixture::writeOverlayRevision($base_path, $translations); + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed) + ); + } + + private function shippedPath(string $module, string $lang_key, string $extension): string + { + return $this->fixture_directory . '/' . $module . '_' . $lang_key . '.' . $extension; + } + + private function overlayPath(string $module, string $lang_key, string $extension): string + { + $base = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/' . $lang_key . '/' . $module . '_' . $lang_key; + + return match ($extension) { + 'mo' => MigratedPoFixture::overlayMo($base), + 'lock' => MigratedPoFixture::overlayLock($base), + default => $base . '.' . $extension, + }; + } + + /** + * getAvailableLangFiles() scans "/lang" for "ilias_.lang"; only the file names matter. + * + * @param list $lang_keys + */ + private function createPluginInfo(string $module_prefix, array $lang_keys): ilPluginInfo + { + $this->plugin_root_directory = __DIR__ . '/tmp-plugin-root-' . bin2hex(random_bytes(4)); + mkdir($this->plugin_root_directory . '/lang', 0775, true); + foreach ($lang_keys as $lang_key) { + touch($this->plugin_root_directory . '/lang/ilias_' . $lang_key . '.lang'); + } + + $component = $this->createStub(ilComponentInfo::class); + $component->method('getId')->willReturn('comp'); + + $slot = $this->createStub(ilPluginSlotInfo::class); + $slot->method('getId')->willReturn('slot'); + + $plugin_info = $this->createStub(ilPluginInfo::class); + $plugin_info->method('getComponent')->willReturn($component); + $plugin_info->method('getPluginSlot')->willReturn($slot); + $plugin_info->method('getId')->willReturn($module_prefix); + $plugin_info->method('getPath')->willReturn($this->plugin_root_directory); + + return $plugin_info; + } + + private function createDatabaseStub(): ilDBInterface + { + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $v): string => "'" . (string) $v . "'"); + + return $db; + } + + public function testUninstallRemovesBothOverlayFilesForEveryLanguageThePluginShipsButLeavesTheShippedPoUntouched(): void + { + $module = 'comp_slot_ptest'; + $directory = $this->seedShippedModule($module, 'de', ['greeting' => 'Hallo']); + $this->seedOverlay($module, 'de', ['greeting' => 'Hallo']); + $this->seedShippedModule($module, 'fr', ['greeting' => 'Bonjour']); + $this->seedOverlay($module, 'fr', ['greeting' => 'Bonjour']); + $this->registerDirectoryManager($directory); + $this->setGlobalVariable('ilDB', $this->createDatabaseStub()); + + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest', ['de', 'fr'])); + $plugin_language->uninstall(); + + $this->assertFileDoesNotExist($this->overlayPath($module, 'de', 'mo')); + $this->assertFileDoesNotExist($this->overlayPath($module, 'de', 'po')); + $this->assertFileDoesNotExist($this->overlayPath($module, 'fr', 'mo')); + $this->assertFileDoesNotExist($this->overlayPath($module, 'fr', 'po')); + $this->assertFileExists($this->shippedPath($module, 'de', 'po')); + $this->assertFileExists($this->shippedPath($module, 'fr', 'po')); + } + + public function testUninstallDoesNotTouchAnotherModulesOverlay(): void + { + $module = 'comp_slot_ptest'; + $other_module = 'comp_slot_other'; + $directory = $this->seedShippedModule($module, 'de', ['greeting' => 'Hallo']); + $this->seedOverlay($module, 'de', ['greeting' => 'Hallo']); + $other_directory = $this->seedShippedModule($other_module, 'de', ['greeting' => 'Hallo']); + $this->seedOverlay($other_module, 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory, $other_directory); + $this->setGlobalVariable('ilDB', $this->createDatabaseStub()); + + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest', ['de'])); + $plugin_language->uninstall(); + + $this->assertFileDoesNotExist($this->overlayPath($module, 'de', 'mo')); + $this->assertFileExists($this->overlayPath($other_module, 'de', 'mo')); + $this->assertFileExists($this->overlayPath($other_module, 'de', 'po')); + } + + public function testIsANoOpWhenNoDirectoryManagerIsRegisteredAtAll(): void + { + $this->setGlobalVariable('ilDB', $this->createDatabaseStub()); + + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest', ['de'])); + $plugin_language->uninstall(); + + $this->addToAssertionCount(1); + } + + public function testIsANoOpWhenTheModuleHasNoOverlayForALanguageItShips(): void + { + $module = 'comp_slot_ptest'; + $directory = $this->seedShippedModule($module, 'de', ['greeting' => 'Hallo']); + $this->seedOverlay($module, 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + $this->setGlobalVariable('ilDB', $this->createDatabaseStub()); + + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest', ['de', 'es'])); + $plugin_language->uninstall(); + + $this->assertFileDoesNotExist($this->overlayPath($module, 'de', 'mo')); + } + + /** A prefix that cannot be a module name (plugin id with a hyphen) is never looked up: a warning is logged and a validly prefixed module's overlay survives. */ + public function testUninstallLogsAndSkipsWhenThePrefixIsNotAValidModuleName(): void + { + $other_module = 'comp_slot_other'; + $other_directory = $this->seedShippedModule($other_module, 'de', ['greeting' => 'Hallo']); + $this->seedOverlay($other_module, 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($other_directory); + $this->setGlobalVariable('ilDB', $this->createDatabaseStub()); + $logger = $this->createMock(ilLogger::class); + $logger->expects($this->once())->method('warning')->with($this->stringContains('comp_slot_p-test')); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($logger); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + + $plugin_language = new ilPluginLanguage($this->createPluginInfo('p-test', ['de'])); + $plugin_language->uninstall(); + + $this->assertFileExists($this->overlayPath($other_module, 'de', 'mo')); + $this->assertFileExists($this->overlayPath($other_module, 'de', 'po')); + } + + /** Without a resolvable CLIENT_DATA_DIR the overlay cleanup must no-op, never touch the shipped `.po`; own process, since another test defines it. */ + #[RunInSeparateProcess] + public function testIsANoOpWhenClientDataDirIsNotResolvable(): void + { + $this->assertFalse(defined('CLIENT_DATA_DIR')); + + $module = 'comp_slot_ptest'; + $directory = $this->seedShippedModule($module, 'de', ['greeting' => 'Hallo']); + $shipped_po_before = file_get_contents($this->shippedPath($module, 'de', 'po')); + $this->registerDirectoryManager($directory); + $this->setGlobalVariable('ilDB', $this->createDatabaseStub()); + + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest', ['de'])); + $plugin_language->uninstall(); + + $this->assertSame($shipped_po_before, file_get_contents($this->shippedPath($module, 'de', 'po'))); + } + + /** A failure removing one language's overlay is logged via $DIC->logger()->forComponent('lang')->warning() and swallowed; the other languages are still processed. */ + public function testLogsAndSwallowsARemovalFailureForOneLanguageWithoutAbortingOthers(): void + { + if (posix_getuid() === 0) { + $this->markTestSkipped('Cannot force an unremovable file while running as root; skipping.'); + } + + // both languages share one directory, so both fail: the loop continuing is proven by both being attempted and logged + $module = 'comp_slot_ptest'; + $this->ensureClientDataDirDefined(); + $this->fixture_directory ??= __DIR__ . '/tmp-plugin-uninstall-fixtures-' . bin2hex(random_bytes(4)); + $shipped_readonly_dir = $this->fixture_directory . '/readonly'; + mkdir($shipped_readonly_dir, 0775, true); + + $relative_readonly_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/readonly/'; + $overlay_readonly_dir = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module; + + foreach (['de' => 'Hallo', 'fr' => 'Bonjour'] as $lang_key => $value) { + $translations = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog(); + $translations->add(MigratedPoFixture::entry($module, 'greeting', $value)); + MigratedPoFixture::writePo("$shipped_readonly_dir/{$module}_{$lang_key}.po", $translations); + MigratedPoFixture::writePair("$overlay_readonly_dir/$lang_key/{$module}_{$lang_key}", $translations); + } + + $directory = new class ($module, $relative_readonly_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + $this->registerDirectoryManager($directory); + $this->setGlobalVariable('ilDB', $this->createDatabaseStub()); + + chmod("$overlay_readonly_dir/de", 0555); + chmod("$overlay_readonly_dir/fr", 0555); + + $warnings = []; + $logger = $this->createStub(ilLogger::class); + $logger->method('warning')->willReturnCallback(function (string $message) use (&$warnings): void { + $warnings[] = $message; + }); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturnMap([ + ['lang', $logger], + ]); + $this->setGlobalVariable('ilLoggerFactory', $logger_factory); + + set_error_handler(static fn(): bool => true, E_WARNING); + try { + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest', ['de', 'fr'])); + $plugin_language->uninstall(); + } finally { + restore_error_handler(); + chmod("$overlay_readonly_dir/de", 0775); + chmod("$overlay_readonly_dir/fr", 0775); + } + + // each failed language is logged individually, preceded by the warning that its lock file could not be created + $lock_warnings = array_values(array_filter($warnings, static fn(string $w): bool => str_contains($w, 'lock file'))); + $warnings = array_values(array_filter($warnings, static fn(string $w): bool => !str_contains($w, 'lock file'))); + $this->assertCount(2, $lock_warnings); + $this->assertCount(2, $warnings); + $this->assertStringContainsString('de', $warnings[0] ?? ''); + $this->assertStringContainsString('fr', $warnings[1] ?? ''); + $this->assertFileExists(MigratedPoFixture::overlayMo("$overlay_readonly_dir/de/{$module}_de")); + $this->assertFileExists(MigratedPoFixture::overlayMo("$overlay_readonly_dir/fr/{$module}_fr")); + } + + /** + * tearDown() once deleted the whole CLIENT_DATA_DIR if it was merely defined(), wiping the real /var/iliasdata in a + * full-suite run: a foreign, pre-existing definition must stay untouched. Own process so no other test defines it first. + */ + #[RunInSeparateProcess] + public function testTearDownNeverDeletesAClientDataDirThisClassDidNotCreateItself(): void + { + $foreign_dir = sys_get_temp_dir() . '/ilias_foreign_client_data_dir_' . bin2hex(random_bytes(4)); + mkdir($foreign_dir, 0775, true); + file_put_contents($foreign_dir . '/marker.txt', 'do-not-delete'); + // a foreign CLIENT_DATA_DIR this class did not create + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + $directory = $this->seedShippedModule('uforeign', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('uforeign', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($directory); + $this->assertFileExists($this->overlayPath('uforeign', 'de', 'po')); + + // tearDown() runs again after the test; harmless, nothing is left to clean up + $this->tearDown(); + + $this->assertFileExists( + $foreign_dir . '/marker.txt', + 'tearDown() deleted (part of) a CLIENT_DATA_DIR this test class did not create itself.' + ); + $this->assertFileDoesNotExist($this->overlayPath('uforeign', 'de', 'po')); + } finally { + // runs even if an assertion failed, so $foreign_dir is never left behind + if (is_file($foreign_dir . '/marker.txt')) { + unlink($foreign_dir . '/marker.txt'); + } + if (is_dir($foreign_dir)) { + $this->removeDirectoryRecursively($foreign_dir); + } + } + } + + /** A CLIENT_DATA_DIR outside sys_get_temp_dir() must not be written into: ensureClientDataDirDefined() skips. */ + #[RunInSeparateProcess] + public function testRefusesToWriteIntoAClientDataDirOutsideSysTempDir(): void + { + $foreign_dir = __DIR__ . '/tmp-not-a-temp-dir-' . bin2hex(random_bytes(4)); + $this->assertFalse(str_starts_with($foreign_dir, sys_get_temp_dir() . '/')); + mkdir($foreign_dir, 0775, true); + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + try { + $directory = $this->seedShippedModule('uoutside', 'de', ['greeting' => 'Hallo']); + $this->seedOverlay('uoutside', 'de', ['greeting' => 'Hallo']); + $this->fail('seedOverlay() must refuse to write into a CLIENT_DATA_DIR outside sys_get_temp_dir().'); + } catch (SkippedWithMessageException $e) { + $this->assertStringContainsString($foreign_dir, $e->getMessage()); + } + + $this->assertSame([], array_diff(scandir($foreign_dir) ?: [], ['.', '..'])); + } finally { + if (is_dir($foreign_dir)) { + $this->removeDirectoryRecursively($foreign_dir); + } + } + } +} diff --git a/components/ILIAS/Language/tests/WithPluralFormKeysTest.php b/components/ILIAS/Language/tests/WithPluralFormKeysTest.php new file mode 100644 index 000000000000..41377d5dc108 --- /dev/null +++ b/components/ILIAS/Language/tests/WithPluralFormKeysTest.php @@ -0,0 +1,130 @@ + $entries + * @param array{values: array, modules: list} $shipped_migrated + * @return array + */ + private function call(array $entries, array $shipped_migrated): array + { + $method = new ReflectionMethod(ilObjLanguageExt::class, 'withPluralFormKeys'); + + return $method->invoke(null, $entries, $shipped_migrated, self::SEPARATOR); + } + + private const SHIPPED_PLURAL = [ + 'values' => ['ptest#:#item [0]' => 'Eintrag', 'ptest#:#item [1]' => 'Einträge'], + 'modules' => ['ptest'], + ]; + + public function testMapsThePlainIdentifierOfAPluralMessageToItsDefaultFormKey(): void + { + $result = $this->call(['ptest#:#item' => 'X'], self::SHIPPED_PLURAL); + + $this->assertSame(['ptest#:#item [1]' => 'X'], $result); + } + + /** A key of a non-migrated module is never touched, even if it looks like a plural identifier of another module. */ + public function testLeavesAKeyOfANonMigratedModuleUnchanged(): void + { + $entries = ['other#:#item' => 'X']; + + $this->assertSame($entries, $this->call($entries, self::SHIPPED_PLURAL)); + } + + /** A singular key configured alongside a plural message is an ordinary identifier: defaultFormKeyOf() returns null for it. */ + public function testLeavesASingularKeyUnchanged(): void + { + $shipped = self::SHIPPED_PLURAL; + $shipped['values']['ptest#:#item_singular'] = 'Ein Eintrag'; + $entries = ['ptest#:#item_singular' => 'X']; + + $this->assertSame($entries, $this->call($entries, $shipped)); + } + + public function testLeavesAnExplicitFormKeyUnchanged(): void + { + $entries = ['ptest#:#item [0]' => 'X', 'ptest#:#item [1]' => 'Y']; + + $this->assertSame($entries, $this->call($entries, self::SHIPPED_PLURAL)); + } + + public function testLeavesAnOrdinaryIdentifierOfAMigratedModuleUnchanged(): void + { + $entries = ['ptest#:#greeting' => 'Hallo']; + + $this->assertSame($entries, $this->call($entries, self::SHIPPED_PLURAL)); + } + + /** Without any migrated module the map is returned untouched (documented fast path). */ + public function testReturnsEntriesUnchangedWhenNoModuleIsMigratedAtAll(): void + { + $entries = ['ptest#:#item' => 'X', 'malformed-key-without-separator' => 'Y']; + + $this->assertSame($entries, $this->call($entries, ['values' => [], 'modules' => []])); + } + + public function testLeavesAMalformedKeyWithoutASeparatorUnchanged(): void + { + $entries = ['not_a_module_topic_pair' => 'X']; + + $this->assertSame($entries, $this->call($entries, self::SHIPPED_PLURAL)); + } + + /** Order matters: a later entry mapping to the same resolved key overwrites an earlier one, like a duplicate array key. */ + public function testALaterEntryMappingToTheSameFormKeyOverwritesAnEarlierOne(): void + { + $result = $this->call( + ['ptest#:#item [1]' => 'Von Formzeile', 'ptest#:#item' => 'Von Plain-Key'], + self::SHIPPED_PLURAL + ); + + $this->assertSame(['ptest#:#item [1]' => 'Von Plain-Key'], $result); + } + + public function testAnEarlierPlainKeyIsOverwrittenByALaterExplicitFormKey(): void + { + $result = $this->call( + ['ptest#:#item' => 'Von Plain-Key', 'ptest#:#item [1]' => 'Von Formzeile'], + self::SHIPPED_PLURAL + ); + + $this->assertSame(['ptest#:#item [1]' => 'Von Formzeile'], $result); + } + + /** Values of any type round-trip unchanged: the remarks map may carry booleans/null. */ + public function testPreservesNonStringValueTypes(): void + { + $result = $this->call(['ptest#:#item' => null], self::SHIPPED_PLURAL); + + $this->assertNull($result['ptest#:#item [1]']); + } +} diff --git a/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php b/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php index 816cf5bea6f0..bd24f0f574a5 100644 --- a/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php +++ b/components/ILIAS/Language/tests/ilLanguageBaseTestCase.php @@ -31,6 +31,8 @@ abstract class ilLanguageBaseTestCase extends TestCase { protected function setUp(): void { + // never the build of the installation, see MigratedPoFixture::resetRuntime() + MigratedPoFixture::resetRuntime(); $GLOBALS['DIC'] = new Container(); parent::setUp(); diff --git a/components/ILIAS/Language/tests/ilLanguageFileTest.php b/components/ILIAS/Language/tests/ilLanguageFileTest.php new file mode 100644 index 000000000000..02dfb3d1f8e5 --- /dev/null +++ b/components/ILIAS/Language/tests/ilLanguageFileTest.php @@ -0,0 +1,136 @@ +"; + + private string $file; + + protected function setUp(): void + { + parent::setUp(); + + foreach (['ILIAS_HTTP_PATH' => 'http://localhost', 'ILIAS_VERSION' => 'test'] as $name => $value) { + if (!defined($name)) { + define($name, $value); + } + } + $this->setGlobalVariable('lng', $this->createStub(ilLanguage::class)); + $this->setGlobalVariable('ilUser', $this->createStub(ilObjUser::class)); + $this->file = sys_get_temp_dir() . '/ilias_lang_file_test_' . bin2hex(random_bytes(4)) . '.lang'; + } + + protected function tearDown(): void + { + if (is_file($this->file)) { + unlink($this->file); + } + + parent::tearDown(); + } + + /** @param list $lines */ + private function read(array $lines): ilLanguageFile + { + file_put_contents($this->file, self::HEADER . "\n" . implode("\n", $lines) . "\n"); + $language_file = new ilLanguageFile($this->file, 'zz'); + $this->assertTrue($language_file->read(), $language_file->getErrorMessage()); + + return $language_file; + } + + /** @return list */ + private static function builtLines(ilLanguageFile $language_file): array + { + return explode("\n", substr($language_file->build(self::HEADER), strlen(self::HEADER) + 1)); + } + + public function testWithoutKeptLinesEveryLineIsNormalisedAsBefore(): void + { + $language_file = $this->read(["a#:#x#:#Wert ", "\tb#:#y#:#Zwei###Kommentar "]); + + $this->assertSame(['a#:#x#:#Wert', 'b#:#y#:#Zwei###Kommentar'], self::builtLines($language_file)); + } + + public function testAKeptLineIsWrittenExactlyAsReadIncludingSurroundingWhitespace(): void + { + $language_file = $this->read(["a#:#x#:#Wert \t", " b#:#y#:#Zwei###Kommentar ", 'c#:#z#:#Drei ']); + + $language_file->keepOriginalLines(['a#:#x', 'b#:#y']); + + $this->assertSame( + ["a#:#x#:#Wert \t", " b#:#y#:#Zwei###Kommentar ", 'c#:#z#:#Drei'], + self::builtLines($language_file) + ); + } + + /** Values and comments set after read() do not change a kept line, also one without surrounding whitespace. */ + public function testAKeptLineIgnoresValuesAndCommentsSetAfterReading(): void + { + $language_file = $this->read(['a#:#x#:#Wert###Kommentar', 'b#:#y#:#Zwei', 'c#:#z#:#Drei']); + $language_file->keepOriginalLines(['a#:#x', 'b#:#y']); + + $language_file->setAllValues(['a#:#x' => 'Neu', 'b#:#y' => 'Neu', 'c#:#z' => 'Neu']); + $language_file->setAllComments(['b#:#y' => 'neuer Kommentar', 'c#:#z' => 'neuer Kommentar']); + + $this->assertSame( + ['a#:#x#:#Wert###Kommentar', 'b#:#y#:#Zwei', 'c#:#z#:#Neu###neuer Kommentar'], + self::builtLines($language_file) + ); + } + + public function testUnknownKeysAreIgnoredAndTheValuesSetDetermineOrderAndPresence(): void + { + $language_file = $this->read(['a#:#x#:#Eins ', 'b#:#y#:#Zwei ', 'c#:#z#:#Drei ']); + $language_file->keepOriginalLines(['a#:#x', 'c#:#z', 'd#:#new']); + + $language_file->setAllValues(['c#:#z' => 'Neu', 'd#:#new' => 'Neu', 'b#:#y' => 'Neu']); + + $this->assertSame(['c#:#z#:#Drei ', 'd#:#new#:#Neu', 'b#:#y#:#Neu'], self::builtLines($language_file)); + } + + public function testReadingAgainForgetsTheKeptLines(): void + { + $language_file = $this->read(['a#:#x#:#Eins ']); + $language_file->keepOriginalLines(['a#:#x']); + + file_put_contents($this->file, self::HEADER . "\na#:#x#:#Zwei \n"); + $this->assertTrue($language_file->read()); + + $this->assertSame(['a#:#x#:#Zwei'], self::builtLines($language_file)); + } + + /** write() keeps the line as read too; with Windows line endings the line break is not part of it. */ + public function testWriteEmitsTheKeptLineAsReadWithoutItsLineBreak(): void + { + file_put_contents($this->file, self::HEADER . "\r\na#:#x#:#Eins \r\nb#:#y#:#Zwei \r\n"); + $language_file = new ilLanguageFile($this->file, 'zz'); + $this->assertTrue($language_file->read()); + $language_file->keepOriginalLines(['a#:#x']); + + $language_file->write(self::HEADER); + + $this->assertSame(self::HEADER . "\na#:#x#:#Eins \nb#:#y#:#Zwei", file_get_contents($this->file)); + } +} diff --git a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php index 46e2055f28c5..7d72c3c30eff 100755 --- a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php +++ b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php @@ -22,7 +22,7 @@ use ILIAS\Data\Factory as DataFactory; use ILIAS\Language\Activities\InstallLanguage; use ILIAS\Language\Activities\UpdateLanguage; -use ILIAS\Setup\Objective\NullObjective; +use ILIAS\Language\Setup\ShippedLanguageFilesCompiledObjective; class ilLanguageSetupAgentTest extends ilLanguageBaseTestCase { @@ -33,8 +33,8 @@ class ilLanguageSetupAgentTest extends ilLanguageBaseTestCase protected function setUp(): void { - $refinery = new Refinery($this->createMock(DataFactory::class), $this->createMock(\ilLanguage::class)); - $setup_language = $this->createMock(ilSetupLanguage::class); + $refinery = new Refinery($this->createStub(DataFactory::class), $this->createStub(\ilLanguage::class)); + $setup_language = $this->createStub(ilSetupLanguage::class); $install_language = new InstallLanguage( $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), $setup_language, @@ -93,6 +93,6 @@ public function testGetBuildArtifactObjective(): void { $result = $this->obj->getBuildObjective(); - $this->assertInstanceOf(NullObjective::class, $result); + $this->assertInstanceOf(ShippedLanguageFilesCompiledObjective::class, $result); } } diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php new file mode 100644 index 000000000000..1d13279c1b89 --- /dev/null +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php @@ -0,0 +1,301 @@ +maintenance_directory = sys_get_temp_dir() . '/ilias_lang_maintain_' . bin2hex(random_bytes(4)); + mkdir($this->maintenance_directory); + file_put_contents($this->maintenance_directory . '/ilias_zz.lang', "header\n\ncommon#:#yes#:#Ja"); + file_put_contents($this->maintenance_directory . '/ilias_zz.lang.local', "header\n\ncommon#:#yes#:#Jawohl"); + + $object = $this->createMock(ilObjLanguageExt::class); + $object->key = 'zz'; + $object->method('getLangPath')->willReturn($this->maintenance_directory); + $object->method('getCustLangPath')->willReturn($this->maintenance_directory); + + return $object; + } + + /** @param list $messages type and message of every setOnScreenMessage() call */ + private function runMaintenance( + string $action, + ilObjLanguageExt $object, + array &$messages, + ?RemoveLocalLanguageChanges $remove_local_language_changes = null, + bool $langmode = true + ): void { + $tpl = $this->createStub(ilGlobalTemplateInterface::class); + $tpl->method('setOnScreenMessage')->willReturnCallback( + static function (string $type, string $message) use (&$messages): void { + $messages[] = [$type, $message]; + } + ); + $ctrl = $this->createMock(ilCtrl::class); + $ctrl->expects($this->once())->method('redirect')->with($this->isInstanceOf(ilObjLanguageExtGUI::class), 'maintain'); + + $gui = $this->newGuiWithoutConstructor(ilObjLanguageExtGUI::class); + $this->inject($gui, 'http', $this->mockHttpWithParsedBody(['maintain' => $action])); + $this->inject($gui, 'tpl', $tpl); + $this->inject($gui, 'ctrl', $ctrl); + $this->inject($gui, 'lng', $this->createLanguageMockWithFormatStrings()); + $this->inject($gui, 'object', $object); + // "merge" escapes module names with the real Refinery classes, see importedMessageProvider() + $this->inject($gui, 'refinery', new \ILIAS\Refinery\Factory( + $this->createStub(\ILIAS\Data\Factory::class), + $this->createStub(\ILIAS\Language\Language::class) + )); + $this->inject($gui, 'langmode', $langmode); + // only "clear" reaches clearLocalChanges(), which needs these + $this->inject($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + $this->inject($gui, 'user', $this->createStub(ilObjUser::class)); + $this->inject($gui, 'activity_error_logger', $this->activityErrorLoggerForGui()); + $this->inject( + $gui, + 'remove_local_language_changes', + $remove_local_language_changes ?? $this->createStub(RemoveLocalLanguageChanges::class) + ); + + $session_before = $_SESSION ?? null; + try { + $gui->maintainExecuteObject(); + } finally { + if ($session_before === null) { + unset($_SESSION); + } else { + $_SESSION = $session_before; + } + MigratedPoFixture::removeDirectory((string) $this->maintenance_directory); + } + } + + private function stubRemoveLocalLanguageChanges(ResultOk|ResultError $result): RemoveLocalLanguageChanges&Stub + { + $activity = $this->createStub(RemoveLocalLanguageChanges::class); + $activity->method('maybePerformAs')->willReturn($result); + + return $activity; + } + + public function testClearWithAnUnreadableShippedPoShowsAFailureAndKeepsTheLocalFlag(): void + { + $object = $this->maintenanceLanguageObject(); + $object->method('getUnreadableShippedPoModules')->willReturn(['pilot', 'tos']); + $object->expects($this->never())->method('setLocal'); + $object->expects($this->never())->method('importLanguageFile'); + $messages = []; + + $this->runMaintenance('clear', $object, $messages, $this->stubRemoveLocalLanguageChanges(new ResultOk([ + 'removed_local_changes_language_keys' => [], + 'invalid_language_file_keys' => ['zz'], + 'not_installed_language_keys' => [], + 'overlay_write_failed_language_keys' => [], + ]))); + + $this->assertSame([['failure', 'lng_error_clear_shipped_po_unreadable: pilot, tos']], $messages); + } + + public function testClearWithAnUnwrittenOverlayShowsTheSuccessTextAsFailureNamingTheModules(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->never())->method('setLocal'); + $object->expects($this->never())->method('importLanguageFile'); + $messages = []; + + $this->runMaintenance('clear', $object, $messages, $this->stubRemoveLocalLanguageChanges(new ResultOk([ + 'removed_local_changes_language_keys' => ['zz'], + 'invalid_language_file_keys' => [], + 'not_installed_language_keys' => [], + 'overlay_write_failed_language_keys' => ['zz'], + ]))); + + $this->assertSame( + [['failure', 'language_cleared_local
lng_po_overlay_not_written_languages: meta_l_zz']], + $messages + ); + } + + public function testClearWithEveryOverlayWrittenShowsSuccess(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->never())->method('setLocal'); + $object->expects($this->never())->method('importLanguageFile'); + $messages = []; + + $this->runMaintenance('clear', $object, $messages, $this->stubRemoveLocalLanguageChanges(new ResultOk([ + 'removed_local_changes_language_keys' => ['zz'], + 'invalid_language_file_keys' => [], + 'not_installed_language_keys' => [], + 'overlay_write_failed_language_keys' => [], + ]))); + + $this->assertSame([['success', 'language_cleared_local']], $messages); + } + + public function testClearWithAnActivityErrorShowsItsRenderedMessage(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->never())->method('setLocal'); + $messages = []; + + $this->runMaintenance( + 'clear', + $object, + $messages, + $this->stubRemoveLocalLanguageChanges(new ResultError(new \DomainException('internal wording', 403))) + ); + + $this->assertSame([['failure', 'language_error_clear_local
msg_no_perm_write']], $messages); + } + + /** "load" never refreshes the shipped baseline and reports an unwritten overlay like "clear". */ + public function testLoadWithAnUnwrittenOverlayShowsAFailureNamingTheModules(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->once())->method('importLanguageFile') + ->with($this->maintenance_directory . '/ilias_zz.lang.local', 'replace') + ->willReturn(['pilot', 'tos']); + $object->expects($this->once())->method('setLocal')->with(true); + $messages = []; + + $this->runMaintenance('load', $object, $messages); + + $this->assertSame([['failure', 'language_loaded_local
lng_po_overlay_not_written: pilot, tos']], $messages); + } + + /** "merge" names the skipped PO-file modules in an additional failure message (an error, not an intended skip). */ + public function testMergeReportsTheSkippedModulesMaintainedInPoFiles(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->once())->method('mergeLocalChangesIntoGlobalLanguageFile')->willReturn([ + 'written' => [], + 'skipped' => ['pilot', 'tos'], + 'invalid_markup' => [], + 'not_merged' => [], + 'unwritten_database' => [], + ]); + $messages = []; + + $this->runMaintenance('merge', $object, $messages); + + $this->assertSame( + [['success', 'language_merged_global'], ['failure', 'lng_merge_po_not_written: pilot, tos']], + $messages + ); + } + + public function testMergeWithoutModulesMaintainedInPoFilesShowsOnlyTheSuccess(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->once())->method('mergeLocalChangesIntoGlobalLanguageFile')->willReturn([ + 'written' => [], + 'skipped' => [], + 'invalid_markup' => [], + 'not_merged' => [], + 'unwritten_database' => [], + 'not_single_client' => [], + ]); + $messages = []; + + $this->runMaintenance('merge', $object, $messages); + + $this->assertSame([['success', 'language_merged_global']], $messages); + } + + public function testMergeNamesTheModulesLeftOutBecauseTheInstallationHasSeveralClients(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->once())->method('mergeLocalChangesIntoGlobalLanguageFile')->willReturn([ + 'written' => [], + 'skipped' => [], + 'invalid_markup' => [], + 'not_merged' => [], + 'unwritten_database' => [], + 'not_single_client' => ['pilot'], + ]); + $messages = []; + + $this->runMaintenance('merge', $object, $messages); + + $this->assertSame( + [['success', 'language_merged_global'], ['failure', 'lng_merge_po_not_single_client: pilot']], + $messages + ); + } + + /** A failed database write (unwritten_database) is reported like any partial failure; the written files stay. */ + public function testMergeReportsModulesWhoseDatabaseUpdateFailed(): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->once())->method('mergeLocalChangesIntoGlobalLanguageFile')->willReturn([ + 'written' => ['some/shipped.po'], + 'skipped' => [], + 'invalid_markup' => [], + 'not_merged' => [], + 'unwritten_overlay' => [], + 'unwritten_database' => ['pilot'], + ]); + $messages = []; + + $this->runMaintenance('merge', $object, $messages); + + $this->assertSame( + [['success', "language_merged_global
lng_merged_po_modules: some/shipped.po"], ['failure', 'lng_merge_database_not_updated: pilot']], + $messages + ); + } + + /** "merge" (like "delete_added"/"remove_local_file") needs LANGMODE: without it the request is refused before the language object is touched. */ + #[DataProvider('langmodeOnlyMaintenanceActions')] + public function testMaintenanceActionsRequiringLangmodeAreRefusedWithoutIt(string $action): void + { + $object = $this->maintenanceLanguageObject(); + $object->expects($this->never())->method('mergeLocalChangesIntoGlobalLanguageFile'); + $messages = []; + + $this->runMaintenance($action, $object, $messages, langmode: false); + + $this->assertSame([['failure', 'permission_denied']], $messages); + } + + /** @return array */ + public static function langmodeOnlyMaintenanceActions(): array + { + return [ + 'merge' => ['merge'], + 'delete_added' => ['delete_added'], + 'remove_local_file' => ['remove_local_file'], + ]; + } +} diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveNewEntryTest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveNewEntryTest.php new file mode 100644 index 000000000000..d3f0478443ff --- /dev/null +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveNewEntryTest.php @@ -0,0 +1,282 @@ +getMockBuilder(ilObjLanguageExtGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(['initAddNewEntryForm']) + ->getMock(); + + $gui->method('initAddNewEntryForm')->willReturn($form); + + $this->inject($gui, 'ctrl', $ctrl); + $this->inject($gui, 'user', $user); + $this->inject($gui, 'tpl', $tpl); + $this->inject($gui, 'lng', $lng); + $this->inject($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + $this->inject($gui, 'add_language_entry', $add_language_entry); + $this->inject( + $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; + } + + #[AllowMockObjectsWithoutExpectations] + public function testSaveNewEntryObjectBuildsModuleIdentifierAndTranslationsFromFormAcrossAllInstalledLanguages(): void + { + $user = $this->createStub(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->createStub(ilCtrl::class), + $user, + $this->createStub(ilGlobalTemplateInterface::class), + $form, + $this->createLanguageMockReturningTopicAsIs(['de', 'en']) + ); + + $gui->saveNewEntryObject(); + } + + #[AllowMockObjectsWithoutExpectations] + public function testSaveNewEntryObjectShowsSuccessMessageAndRedirectsToViewOnSuccess(): void + { + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $add_language_entry = $this->createStub(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 (activityErrorMessage()) and redirects to "view". */ + #[AllowMockObjectsWithoutExpectations] + public function testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToViewOnThrowableError(): void + { + $this->stubLoggerLangError('boom'); + + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $add_language_entry = $this->createStub(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 fall through to the success path and trip the once() expectations + $gui->saveNewEntryObject(); + } + + /** An input error (4xx) must not redirect: the form is re-shown with the entered values. */ + #[AllowMockObjectsWithoutExpectations] + public function testSaveNewEntryObjectDoesNotRedirectAndReshowsTheFormWithPostedValuesOnAnInputError(): void + { + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $add_language_entry = $this->createStub(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.' + ); + + $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() (the real re-render path) needs global $DIC["tpl"] and the form HTML: stubbed + $form->method('getHTML')->willReturn(''); + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + $GLOBALS['DIC']['tpl'] = $this->createStub(ilGlobalTemplateInterface::class); + + $gui = $this->createGuiForSaveNewEntry( + $add_language_entry, + $ctrl, + $user, + $tpl, + $form, + $this->createLanguageMockReturningTopicAsIs(['de', 'en']) + ); + // addNewEntryObject() also checks the "eid" query parameter + $query_wrapper = $this->createStub(\ILIAS\HTTP\Wrapper\ArrayBasedRequestWrapper::class); + $query_wrapper->method('has')->willReturn(false); + $wrapper_factory = $this->createStub(\ILIAS\HTTP\Wrapper\WrapperFactory::class); + $wrapper_factory->method('query')->willReturn($query_wrapper); + $http = $this->createStub(\ILIAS\HTTP\GlobalHttpState::class); + $http->method('wrapper')->willReturn($wrapper_factory); + $this->inject($gui, 'http', $http); + + $gui->saveNewEntryObject(); + } + + /** If the PO/MO overlay of a PO module cannot be written for some languages, a failure naming them replaces "settings_saved". */ + #[AllowMockObjectsWithoutExpectations] + public function testSaveNewEntryObjectShowsAFailureInsteadOfSuccessWhenAnOverlayWasNotWritten(): void + { + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + $add_language_entry = $this->createStub(AddLanguageEntry::class); + $add_language_entry->method('maybePerformAs')->willReturn(new ResultOk([ + 'added_language_keys' => ['de', 'en'], + 'overlay_write_failed_language_keys' => ['de', 'en'], + ])); + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with( + 'failure', + 'lng_po_overlay_not_written_languages: de, en', + 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->createLanguageMockWithFormatStrings(['de', 'en']) + ); + + $gui->saveNewEntryObject(); + } + + #[AllowMockObjectsWithoutExpectations] + public function testSaveNewEntryObjectShowsSuccessWhenTheOverlayListIsEmpty(): void + { + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + $add_language_entry = $this->createStub(AddLanguageEntry::class); + $add_language_entry->method('maybePerformAs')->willReturn(new ResultOk([ + 'added_language_keys' => ['de'], + 'overlay_write_failed_language_keys' => [], + ])); + $tpl = $this->createMock(ilGlobalTemplateInterface::class); + $tpl->expects($this->once())->method('setOnScreenMessage')->with('success', 'settings_saved', true); + + $gui = $this->createGuiForSaveNewEntry( + $add_language_entry, + $this->createStub(ilCtrl::class), + $user, + $tpl, + $this->mockCheckedFormWithInputs(['mod' => 'common', 'id' => 'sometopic']), + $this->createLanguageMockWithFormatStrings(['de']) + ); + + $gui->saveNewEntryObject(); + } +} diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveObjectTest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveObjectTest.php new file mode 100644 index 000000000000..d7d1404e0706 --- /dev/null +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveObjectTest.php @@ -0,0 +1,217 @@ +|null */ + private ?array $global_language_files_before = null; + + /** ilLanguageFile and ilCachedLanguage keep static caches: start empty, restore afterwards. */ + #[Before] + protected function isolateLanguageCaches(): void + { + $files = new ReflectionProperty(ilLanguageFile::class, 'global_file_objects'); + $this->global_language_files_before = $files->isInitialized() ? $files->getValue() : null; + $files->setValue(null, []); + (new ReflectionProperty(ilCachedLanguage::class, 'instances'))->setValue(null, []); + } + + #[After] + protected function restoreLanguageCaches(): void + { + $files = new ReflectionProperty(ilLanguageFile::class, 'global_file_objects'); + $files->setValue(null, $this->global_language_files_before ?? []); + (new ReflectionProperty(ilCachedLanguage::class, 'instances'))->setValue(null, []); + } + + /** + * Partial mock with viewObject() stubbed so its call arguments can be captured. + * + * @param array> $parsed_body + * @return ilObjLanguageExtGUI&MockObject + */ + private function createGuiForSaveObjectWithParsedBody(array $parsed_body): ilObjLanguageExtGUI + { + /** @var ilObjLanguageExtGUI&MockObject $gui */ + $gui = $this->getMockBuilder(ilObjLanguageExtGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(['viewObject']) + ->getMock(); + + $this->inject($gui, 'http', $this->mockHttpWithParsedBody($parsed_body)); + $this->inject($gui, 'lng', $this->createLanguageMockReturningTopicAsIs()); + $this->inject($gui, 'object', $this->createFakeLanguageObject('de')); + $this->inject($gui, 'refinery', new \ILIAS\Refinery\Factory( + $this->createStub(\ILIAS\Data\Factory::class), + $this->createStub(\ILIAS\Language\Language::class) + )); + + return $gui; + } + + /** Seeds the static cache of ilLanguageFile::_getGlobalLanguageFile() with an empty fake: findInvalidMarkupOfChangedValues() reads the shipped value from it. */ + private function seedEmptyGlobalLanguageFile(string $lang_key): void + { + $fake_global_file = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor(); + $fake_global_file->setAllValues([]); + $fake_global_file->setAllComments([]); + $cache = new ReflectionProperty(ilLanguageFile::class, 'global_file_objects'); + $cache->setValue(null, ($cache->isInitialized() ? $cache->getValue() : []) + [$lang_key => $fake_global_file]); + } + + /** Answers every SELECT with nothing and fails on any insert()/manipulate(): a rejected save writes nothing. */ + private function databaseThatMustNeverBeWrittenTo(): ilDBInterface + { + $empty_statement = $this->createStub(ilDBStatement::class); + $empty_statement->method('fetchRow')->willReturn(null); + $empty_statement->method('fetchAssoc')->willReturn(null); + + $db = $this->createMock(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value, string $type): string => "'" . (string) $value . "'"); + $db->method('query')->willReturn($empty_statement); + $db->method('fetchAssoc')->willReturn(null); + $db->expects($this->never())->method('insert'); + $db->expects($this->never())->method('manipulate'); + + return $db; + } + + public function testSaveObjectRejectsEverythingWhenAnyValueHasDisallowedMarkupAndNamesEveryAffectedKeyEscaped(): void + { + $this->seedEmptyGlobalLanguageFile('de'); + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + $GLOBALS['DIC']['ilDB'] = fn() => $this->databaseThatMustNeverBeWrittenTo(); + $GLOBALS['DIC']['lng'] = fn() => $this->createStub(ilLanguage::class); + $logger_factory = $this->createStub(ilLoggerFactory::class); + $logger_factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); + $GLOBALS['DIC']['ilLoggerFactory'] = static fn() => $logger_factory; + + $gui = $this->createGuiForSaveObjectWithParsedBody([ + 'common#:#yes' => '', + 'common#:#yes#:#comment' => '', + // the KEY itself contains HTML-significant characters (a form field name) + 'other#:#x' => '', + 'other#:#x#:#comment' => '', + ]); + $this->inject($gui, 'object', $this->createFakeLanguageObject('de')); + $gui->expects($this->once())->method('viewObject')->with( + 0, + [], + $this->callback(static function (array $messages): bool { + $message = $messages[0] ?? ''; + return str_contains($message, 'common#:#yes') + && str_contains($message, '<b>') + && !str_contains($message, '#:#x') + && !str_contains($message, 'alert(1)') + && !str_contains($message, 'alert(2)') + && str_contains($message, 'common#:#yes: <script>'); + }), + $this->callback(static fn(array $values): bool => ($values['common#:#yes'] ?? null) === ''), + $this->identicalTo(['common#:#yes' => '', 'other#:#x' => '']) + ); + + $gui->saveObject(); + } + + public function testSaveObjectTransformsLineBreaksAndDoubleLessThanBeforeCheckingThenSaves(): void + { + if (!defined('ILIAS_ABSOLUTE_PATH')) { + define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../')); + } + $this->seedEmptyGlobalLanguageFile('zz_saveobject_test'); + + $empty_row_statement = $this->createStub(ilDBStatement::class); + $empty_row_statement->method('fetchRow')->willReturn(null); + $query_f_statement = $this->createStub(ilDBStatement::class); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value, string $type): string => "'" . (string) $value . "'"); + $db->method('query')->willReturn($empty_row_statement); + $db->method('queryF')->willReturn($query_f_statement); + $db->method('fetchAssoc')->willReturnCallback( + static function (ilDBStatement $statement) use ($query_f_statement) { + return $statement === $query_f_statement ? ['lang_array' => serialize(['x' => 'irrelevant'])] : null; + } + ); + $db->method('manipulate')->willReturnCallback(function (string $sql): int { + if (str_starts_with($sql, /** @lang text */ 'INSERT INTO lng_data')) { + $this->assertStringContainsString("'x
'", $sql, 'the value must reach lng_data already transformed'); + } + return 0; + }); + $captured_lang_array = null; + $db->method('insert')->willReturnCallback( + function (string $table, array $values) use (&$captured_lang_array): int { + if ($table === 'lng_modules') { + $captured_lang_array = unserialize($values['lang_array'][1] ?? '', ['allowed_classes' => false]); + } + return 1; + } + ); + + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + $GLOBALS['DIC']['ilDB'] = static fn() => $db; + $GLOBALS['DIC']['lng'] = fn() => $this->createStub(ilLanguage::class); + $GLOBALS['DIC']['ilErr'] = static fn() => null; + + $lng = $GLOBALS['DIC']->language(); + $gui = $this->createGuiForSaveObjectWithParsedBody([ + 'testmodule' . $lng->separator . 'greeting' => "x\r\n", + 'testmodule' . $lng->separator . 'greeting' . $lng->separator . 'comment' => '', + ]); + $this->inject($gui, 'object', $this->createFakeLanguageObject('zz_saveobject_test')); + $gui->expects($this->once())->method('viewObject')->with(1, []); + + $gui->saveObject(); + + $this->assertSame(['greeting' => 'x
'], $captured_lang_array); + } + + public function testSaveObjectIgnoresAValueAndACommentSentAsArray(): void + { + $this->seedEmptyGlobalLanguageFile('de'); + $GLOBALS['DIC'] = new \ILIAS\DI\Container(); + $GLOBALS['DIC']['ilDB'] = fn() => $this->databaseThatMustNeverBeWrittenTo(); + $GLOBALS['DIC']['lng'] = fn() => $this->createStub(ilLanguage::class); + $gui = $this->createGuiForSaveObjectWithParsedBody([ + 'common#:#yes' => ['x'], + 'common#:#yes#:#comment' => ['y'], + ]); + $gui->expects($this->once())->method('viewObject')->with(1, []); + $warnings = []; + set_error_handler(static function (int $number, string $message) use (&$warnings): bool { + $warnings[] = $message; + return true; + }); + try { + $gui->saveObject(); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings); + } +} diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveSettingsTest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveSettingsTest.php new file mode 100644 index 000000000000..3b01dc06257c --- /dev/null +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUISaveSettingsTest.php @@ -0,0 +1,191 @@ +getMockBuilder(ilObjLanguageExtGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(['initNewSettingsForm']) + ->getMock(); + + $form = $this->createStub(ilPropertyFormGUI::class); + $form->method('getHTML')->willReturn(''); + $gui->method('initNewSettingsForm')->willReturn($form); + + $this->inject($gui, 'http', $http); + $this->inject($gui, 'user', $user); + $this->inject($gui, 'tpl', $tpl); + $this->inject($gui, 'lng', $lng ?? $this->createLanguageMockReturningTopicAsIs()); + $this->inject($gui, 'object', $this->createFakeLanguageObject('de')); + $this->inject($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); + $this->inject( + $gui, + 'set_language_translation_enabled', + $set_language_translation_enabled + ); + $this->inject( + $gui, + 'activity_error_logger', + $this->activityErrorLoggerForGui() + ); + + return $gui; + } + + /** A non-empty "translation" POST value means the checkbox was submitted; the parameters come from $this->object->key. */ + #[AllowMockObjectsWithoutExpectations] + public function testSaveSettingsObjectBuildsEnabledTrueFromANonEmptyTranslationPostValue(): void + { + $user = $this->createStub(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->createStub(ilGlobalTemplateInterface::class) + ); + + $gui->saveSettingsObject(); + } + + /** An unchecked checkbox is not submitted at all: a missing "translation" key builds enabled=false. */ + #[AllowMockObjectsWithoutExpectations] + public function testSaveSettingsObjectBuildsEnabledFalseWhenTranslationPostKeyIsMissing(): void + { + $user = $this->createStub(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->createStub(ilGlobalTemplateInterface::class) + ); + + $gui->saveSettingsObject(); + } + + #[AllowMockObjectsWithoutExpectations] + public function testSaveSettingsObjectShowsSuccessMessageWhenResultReportsChangedTrue(): void + { + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createStub(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(); + } + + /** Shown only if the value actually changed (see SetLanguageTranslationEnabled::getOutputDescription()). */ + #[AllowMockObjectsWithoutExpectations] + public function testSaveSettingsObjectShowsNoMessageWhenResultReportsChangedFalse(): void + { + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createStub(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(); + } + + /** Unlike the folder GUI's write commands saveSettingsObject() never redirects; a \Throwable still goes through activityErrorMessage(). */ + #[AllowMockObjectsWithoutExpectations] + public function testSaveSettingsObjectShowsGenericFailureMessageAndNeverRedirectsOnThrowableError(): void + { + $this->stubLoggerLangError('boom'); + + $user = $this->createStub(ilObjUser::class); + $user->method('getId')->willReturn(6); + + $set_language_translation_enabled = $this->createStub(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 is injected: a redirect would fatally error instead of passing silently + $gui->saveSettingsObject(); + } +} diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php index 5c8357943ba0..85230b2c5d6d 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php @@ -18,464 +18,125 @@ declare(strict_types=1); -use ILIAS\Data\Result\Error as ResultError; -use ILIAS\Data\Result\Ok as ResultOk; -use ILIAS\Language\Activities\AddLanguageEntry; -use ILIAS\Language\Activities\SetLanguageTranslationEnabled; -use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; -/** - * Built like ilObjLanguageFolderGUITest (newInstanceWithoutConstructor plus reflection-injected collaborators). - * The ilPropertyFormGUI builders are stubbed via a partial mock: the form constructor needs `global $DIC`. - */ class ilObjLanguageExtGUITest extends TestCase { - /** $GLOBALS['DIC'] is global state shared by all tests of the process. */ - protected function tearDown(): void - { - unset($GLOBALS['DIC']); - $this->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) - ); + use ilObjLanguageExtGUITestFixture; - 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 + /** A data-changing command missing here would be triggerable via GET without the CSRF token. */ + public function testGetUnsafeGetCommandsListsEveryDataChangingCommand(): void { - $lng = $this->createMock(ilLanguage::class); - $lng->method('txt')->willReturnArgument(0); - $lng->method('getInstalledLanguages')->willReturn($installed_languages); - - return $lng; - } + $gui = $this->newGuiWithoutConstructor(ilObjLanguageExtGUI::class); - /** - * 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->assertSame( + ['save', 'upload', 'maintainExecute', 'saveSettings', 'saveNewEntry'], + $gui->getUnsafeGetCommands() ); - - $this->stubbed_activity_error_logger = $logger; } - private function mockHttpWithParsedBody(array $parsed_body): \ILIAS\HTTP\GlobalHttpState + /** No POST command is exempt from the CSRF token. */ + public function testGetSafePostCommandsIsEmpty(): void { - $request = $this->createMock(ServerRequestInterface::class); - $request->method('getParsedBody')->willReturn($parsed_body); - - $http = $this->createMock(\ILIAS\HTTP\GlobalHttpState::class); - $http->method('request')->willReturn($request); + $gui = $this->newGuiWithoutConstructor(ilObjLanguageExtGUI::class); - return $http; + $this->assertSame([], $gui->getSafePostCommands()); } - private function createFakeLanguageObject(string $key): ilObjLanguageExt&MockObject + /** @return array */ + public static function importedMessageProvider(): array { - /** @var ilObjLanguageExt&MockObject $object */ - $object = $this->createMock(ilObjLanguageExt::class); - $object->key = $key; - - return $object; + return [ + 'HTML-significant characters are escaped' => [ + '.lang', + static function (self $test, string $message): void { + $test->assertStringNotContainsString('Hallo']); + $captured = []; + $this->stubDatabase($captured); + $warnings = []; + $this->stubLoggerCapturingWarnings($warnings); + + $plugin_language->updateLanguages(['de']); + + $value = $captured['comp_slot_ptest#:#comp_slot_ptest_greeting']; + $this->assertStringNotContainsString('assertStringContainsString('Hallo', $value); + $this->assertNotEmpty(array_filter($warnings, static fn(string $w): bool => str_contains($w, 'Markup not allowed'))); + } + + /** Unlike a plugin `.po`, a `.lang` value is taken over unchanged: disallowed markup is only logged. */ + public function testDisallowedMarkupInALangFileIsTakenOverUnchangedAndWarnedAbout(): void + { + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest')); + file_put_contents( + $this->plugin_root_directory . '/lang/ilias_de.lang', + "greeting#:#Hallo\n" + ); + $captured = []; + $this->stubDatabase($captured); + $warnings = []; + $this->stubLoggerCapturingWarnings($warnings); + + $plugin_language->updateLanguages(['de']); + + $value = $captured['comp_slot_ptest#:#comp_slot_ptest_greeting']; + $this->assertSame('Hallo', $value); + $this->assertNotEmpty(array_filter($warnings, static fn(string $w): bool => str_contains($w, 'Markup not allowed'))); + } + + public function testEmptyTranslationsAreSkipped(): void + { + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest')); + $this->writeShippedPluginPo('de', ['greeting' => 'Hallo', 'empty_one' => '']); + $captured = []; + $this->stubDatabase($captured); + + $plugin_language->updateLanguages(['de']); + + $this->assertArrayHasKey('comp_slot_ptest#:#comp_slot_ptest_greeting', $captured); + $this->assertArrayNotHasKey('comp_slot_ptest#:#comp_slot_ptest_empty_one', $captured); + } + + /** A broken `.po` skips only that language. */ + public function testABrokenPoSkipsOnlyThatLanguageAndOthersAreStillUpdated(): void + { + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest')); + file_put_contents($this->plugin_root_directory . '/lang/ilias_de.po', "msgid \"kaputt\n"); + $this->writeShippedPluginPo('en', ['greeting' => 'Hello']); + $captured = []; + $this->stubDatabase($captured); + $warnings = []; + $this->stubLoggerCapturingWarnings($warnings); + + $plugin_language->updateLanguages(['de', 'en']); + + $this->assertSame('Hello', $captured['comp_slot_ptest#:#comp_slot_ptest_greeting'], 'the valid "en" .po is still applied'); + $this->assertNotEmpty(array_filter($warnings, static fn(string $w): bool => str_contains($w, 'cannot be read'))); + } +} diff --git a/components/ILIAS/Language/tests/ilSetupLanguageTest.php b/components/ILIAS/Language/tests/ilSetupLanguageTest.php index 5cebd8b20476..2b3b79008947 100755 --- a/components/ILIAS/Language/tests/ilSetupLanguageTest.php +++ b/components/ILIAS/Language/tests/ilSetupLanguageTest.php @@ -23,6 +23,7 @@ use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +use PHPUnit\Framework\MockObject\MockObject; /** * Class ilSetupLanguageTest @@ -37,6 +38,8 @@ class ilSetupLanguageTest extends ilLanguageBaseTestCase protected function setUp(): void { unset($GLOBALS['ilDB']); + // per process: two PHPUnit runs at once must not share (and delete) the same directory + $this->tempComponentDir .= '_' . getmypid(); $this->newLangSetupDe = new ilSetupLanguage('de'); $this->newLangSetupEs = new ilSetupLanguage('es'); @@ -382,7 +385,7 @@ public function testGetInvalidLocalLanguageFilesReturnsMalformedFileName(): void ); } - private function createDatabaseMock(): ilDBInterface + private function createDatabaseMock(): ilDBInterface&MockObject { $db = $this->createMock(ilDBInterface::class); $db->method('nextId')->willReturn(42); @@ -401,7 +404,7 @@ public function testRegisterInstalledLanguageInsertsUnknownLanguage(): void $db->expects($this->once()) ->method('manipulate') ->with($this->logicalAnd( - $this->stringContains('INSERT INTO object_data'), + $this->stringContains(/** @lang text */ 'INSERT INTO object_data'), $this->stringContains("'de'"), $this->stringContains("'installed'") )) @@ -441,4 +444,109 @@ public function testRegisterInstalledLanguageUpdatesKnownLanguage(): void [] ); } + + public function testWithoutInjectedDirectoriesOnlyTheDefaultDirectoriesAreUsed(): void + { + $setup_language = new ilSetupLanguage('de'); + + $this->assertTrue($setup_language->usesDefaultLanguageFileDirectories()); + $this->assertSame( + [MainLanguageFileDirectory::class, CustomizingLanguageFileDirectory::class], + array_map( + static fn(LanguageFileDirectory $d): string => $d::class, + iterator_to_array($setup_language->getLanguageFileDirectoryManager()->getAllDirectories(), false) + ) + ); + } + + /** The flag says who built the instance (component graph vs. plugin Setup objective), so even a manager with only the default directories counts as injected. */ + public function testAnInjectedDirectoryManagerIsUsedAndReported(): void + { + $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()); + $setup_language = new ilSetupLanguage('de', $manager); + + $this->assertFalse($setup_language->usesDefaultLanguageFileDirectories()); + $this->assertSame($manager, $setup_language->getLanguageFileDirectoryManager()); + } + + private function managerOf(ilSetupLanguage $setup_language): \ILIAS\Language\Setup\LanguageInstallationManager + { + $setup_language->setDbHandler($this->createStub(ilDBInterface::class)); + + return (new ReflectionMethod(ilSetupLanguage::class, 'manager'))->invoke($setup_language); + } + + private function resolvedClientDataDir(ilSetupLanguage $setup_language): ?string + { + $manager = $this->managerOf($setup_language); + $resolver = (new ReflectionProperty(\ILIAS\Language\Setup\LanguageInstallationManager::class, 'client_data_dir_resolver')) + ->getValue($manager); + + return $resolver(); + } + + /** setClientDataDir() overrides the resolution lazily, also after construction; null restores resolveClientDataDir(). */ + public function testSetClientDataDirOverridesTheResolutionUntilResetWithNull(): void + { + $setup_language = new ilSetupLanguage('de'); + $default = \ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths::resolveClientDataDir( + (string) realpath(__DIR__ . '/../../../../') + ); + + $setup_language->setClientDataDir('/from/the/setup/environment'); + $this->assertSame('/from/the/setup/environment', $this->resolvedClientDataDir($setup_language)); + + $setup_language->setClientDataDir(null); + $this->assertSame($default, $this->resolvedClientDataDir($setup_language)); + } + + /** CLI Setup has no global cache: the translation cache must be a silent no-op. */ + public function testTheTranslationCacheIsANoOpWithoutGlobalCache(): void + { + $setup_language = new ilSetupLanguage('de'); + $manager = $this->managerOf($setup_language); + $translation_cache = (new ReflectionProperty(\ILIAS\Language\Setup\LanguageInstallationManager::class, 'translation_cache')) + ->getValue($manager); + + $this->assertInstanceOf(\ILIAS\Language\Setup\NullTranslationCache::class, $translation_cache); + $translation_cache->invalidate('de'); + } + + public function testGetClientDataDirReturnsTheSetDirectoryOrTheDefaultResolution(): void + { + $setup_language = new ilSetupLanguage('de'); + $default = \ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths::resolveClientDataDir( + (string) realpath(__DIR__ . '/../../../../') + ); + + $this->assertSame($default, $setup_language->getClientDataDir()); + $setup_language->setClientDataDir('/from/the/setup/environment'); + $this->assertSame('/from/the/setup/environment', $setup_language->getClientDataDir()); + } + + public function testFindUnwritableOverlayDirectoriesChecksTheSetClientDataDir(): void + { + $fixture = 'tmp-setup-unwritable-' . bin2hex(random_bytes(4)); + $client_data_dir = sys_get_temp_dir() . '/ilias_setup_unwritable_' . bin2hex(random_bytes(4)); + MigratedPoFixture::writePo(__DIR__ . '/' . $fixture . '/stest_de.po', MigratedPoFixture::catalog('stest', ['a' => 'b'])); + mkdir($client_data_dir); + file_put_contents($client_data_dir . '/lang', 'not a directory'); + + try { + $setup_language = new ilSetupLanguage('de', new \ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager( + new \ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('stest', 'components/ILIAS/Language/tests/' . $fixture . '/') + )); + $setup_language->setClientDataDir($client_data_dir); + + $this->assertSame( + [$client_data_dir . '/lang/stest/de'], + $setup_language->findUnwritableOverlayDirectories(['de']) + ); + $this->assertSame([], $setup_language->findUnwritableOverlayDirectories(['en'])); + } finally { + MigratedPoFixture::removeDirectory(__DIR__ . '/' . $fixture); + MigratedPoFixture::removeDirectory($client_data_dir); + } + } } diff --git a/composer.json b/composer.json index 5197e1b5ec6f..ca96e445b81b 100755 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "ezyang/htmlpurifier": "^4.19", "filp/whoops": "^2.18", "firebase/php-jwt": "^7.0.5", + "gettext/gettext": "^5.7.3", "guzzlehttp/psr7": "^2.7.0", "ifsnop/mysqldump-php": "^2.11", "james-heinrich/getid3": "^1.9.24", diff --git a/composer.lock b/composer.lock index 0e3d6f57af31..9f49c6e352c6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2b858b71ec8bdfbcbf1a4de417423914", + "content-hash": "bc0b445c0c4c376ec3963b6b54978c83", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index b7f180498693..744effc29c02 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -11148,6 +11148,17 @@ lng#:#language_detection#:#Automatische Spracherkennung lng#:#lng_disable_language_detection#:#Automatische Spracherkennung deaktivieren lng#:#lng_download_deprecated#:#Veraltete Einträge herunterladen lng#:#lng_enable_language_detection#:#Automatische Spracherkennung aktivieren +lng#:#lng_error_clear_shipped_po_unreadable#:#Die lokalen Änderungen wurden nicht gelöscht: Die ausgelieferten PO-Dateien folgender Module können nicht gelesen werden: %s. Details stehen im Log. +lng#:#lng_markup_not_allowed#:#Nicht erlaubtes Markup in folgenden Einträgen: %s +lng#:#lng_merge_database_not_updated#:#Die ausgelieferten PO-Dateien folgender Module wurden geschrieben, ihre Einträge in der Datenbank konnten aber nicht aktualisiert werden (siehe Log): %s +lng#:#lng_merge_po_not_single_client#:#Diese Installation hat mehr als einen Mandanten: Die lokalen Änderungen folgender über PO-Dateien gepflegter Module wurden nicht in ihre ausgelieferten PO-Dateien übernommen, die alle Mandanten gemeinsam nutzen: %s +lng#:#lng_merge_po_not_written#:#Die lokalen Änderungen folgender Module konnten nicht in ihre ausgelieferten PO-Dateien übernommen werden (siehe Log): %s +lng#:#lng_merge_po_single_client_info#:#Module, die über PO-Dateien gepflegt werden, werden in ihre ausgelieferten PO-Dateien im Code-Verzeichnis übernommen, die alle Mandanten dieser Installation gemeinsam nutzen. Das geschieht nur, wenn die Installation genau einen Mandanten hat. +lng#:#lng_merged_po_modules#:#Die lokalen Änderungen folgender Module wurden in ihre ausgelieferten PO-Dateien übernommen: %s +lng#:#lng_not_translated#:#Nicht übersetzt +lng#:#lng_po_overlay_not_written#:#Die Änderungen wurden in der Datenbank gespeichert, aber die PO/MO-Dateien folgender über PO-Dateien gepflegter Module konnten nicht geschrieben werden: %s. Für diese Module werden weiterhin die bisherigen Texte angezeigt. Bitte prüfen Sie, ob der Webserver-Nutzer Schreibrechte auf das Verzeichnis "lang" im Client-Datenverzeichnis hat. Details stehen im Log. +lng#:#lng_po_overlay_not_written_languages#:#Die Sprachdaten wurden in der Datenbank gespeichert, aber für folgende Sprachen konnten die PO/MO-Dateien der über PO-Dateien gepflegten Module nicht geschrieben werden: %s. Für diese Module werden weiterhin die bisherigen Texte angezeigt. Bitte prüfen Sie, ob der Webserver-Nutzer Schreibrechte auf das Verzeichnis "lang" im Client-Datenverzeichnis hat. Details stehen im Log. +lng#:#lng_shipped_po_unreadable_fallback#:#Die ausgelieferten PO-Dateien folgender Module können nicht gelesen werden: %s. Als Standardwerte werden für diese Module ersatzweise die möglicherweise veralteten Einträge der Sprachdatei (.lang) angezeigt. Details stehen im Log. lng#:#lng_switch_language_detection#:#Automatische Spracherkennung umschalten log#:#log_component_btn_reset#:#Einstellungen zurücksetzen log#:#log_component_col_component#:#Komponente diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 556f3c605185..987da9e66d8c 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -11121,6 +11121,17 @@ lng#:#language_detection#:#Language Detection lng#:#lng_disable_language_detection#:#Disable Language Detection lng#:#lng_download_deprecated#:#Download Deprecated List lng#:#lng_enable_language_detection#:#Enable Language Detection +lng#:#lng_error_clear_shipped_po_unreadable#:#The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details. +lng#:#lng_markup_not_allowed#:#Markup that is not allowed in the following entries: %s +lng#:#lng_merge_database_not_updated#:#The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s +lng#:#lng_merge_po_not_single_client#:#This installation has more than one client: the local changes of the following modules maintained in PO files were not merged into their shipped PO files, which all clients share: %s +lng#:#lng_merge_po_not_written#:#The local changes of the following modules could not be merged into their shipped PO files (see the log): %s +lng#:#lng_merge_po_single_client_info#:#Modules maintained in PO files are merged into their shipped PO files in the code directory, which all clients of this installation share. This is only done if the installation has exactly one client. +lng#:#lng_merged_po_modules#:#The local changes of the following modules were merged into their shipped PO files: %s +lng#:#lng_not_translated#:#Not translated +lng#:#lng_po_overlay_not_written#:#The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory "lang" in the client data directory. See the log for details. +lng#:#lng_po_overlay_not_written_languages#:#The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory "lang" in the client data directory. See the log for details. +lng#:#lng_shipped_po_unreadable_fallback#:#The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details. lng#:#lng_switch_language_detection#:#Switch Language Detection log#:#log_component_btn_reset#:#Reset Settings log#:#log_component_col_component#:#Component From 36f2a70d6afc81d069cc7964791998aff7dfd34e Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:21:19 +0200 Subject: [PATCH 032/103] Language: Add ntxt() for plural forms of modules in PO files ntxt() returns the plural form that the Plural-Forms rule of the language selects for a quantity. For every other module it behaves like txt(). --- .../Language/classes/class.ilLanguage.php | 21 ++ components/ILIAS/Language/tests/NtxtTest.php | 232 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 components/ILIAS/Language/tests/NtxtTest.php diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index ce0f872b3ec7..18274d1b563d 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -255,6 +255,27 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): return $translation; } + /** + * Plural form selected by the "Plural-Forms" rule (native gettext) for a plural message of a migrated + * module, otherwise exactly txt(). The quantity is not inserted; callers do that, e.g. with sprintf(). + */ + public function ntxt(string $a_topic, int $a_n, string $a_default_lang_fallback_mod = ""): string + { + $module = $a_topic === '' ? null : ($this->migrated_key_modules[$a_topic] ?? null); + $value = $module === null + ? null + : MigratedTranslations::pluralText($module, $this->migratedLangKey(), $a_topic, $a_n, self::clientDataDir()); + if ($value !== null && $value !== '') { + self::$used_topics[$a_topic] = $a_topic; + if ($this->usage_log_enabled) { + self::logUsage($module, $a_topic); + } + return $value; + } + + return $this->txt($a_topic, $a_default_lang_fallback_mod); + } + /** * Check if language entry exists */ diff --git a/components/ILIAS/Language/tests/NtxtTest.php b/components/ILIAS/Language/tests/NtxtTest.php new file mode 100644 index 000000000000..7c62e0f28e4a --- /dev/null +++ b/components/ILIAS/Language/tests/NtxtTest.php @@ -0,0 +1,232 @@ +fixture_directory) && is_dir($this->fixture_directory)) { + array_map('unlink', glob($this->fixture_directory . '/*') ?: []); + rmdir($this->fixture_directory); + } + if (isset($this->fixture_directory)) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . basename($this->fixture_directory)); + } + if (defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** Contributes a migrated module whose shipped `.po` (and, if it differs, overlay `.mo`) is $catalog, so plural entries can be built directly. */ + private function contributeModule(string $module, TranslationCatalog $catalog, ?TranslationCatalog $overlay = null): LanguageFileDirectory + { + MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + $this->fixture_directory ??= __DIR__ . '/tmp-ntxt-fixtures-' . bin2hex(random_bytes(4)); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + if ($overlay !== null) { + MigratedPoFixture::writePair(rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/de/' . $module . '_de', $overlay); + } + MigratedPoFixture::writeShippedPo($relative_path, $module, 'de', $catalog); + + return MigratedPoFixture::directory($module, $relative_path); + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), ...$contributed) + ); + } + + private function stubUsageLogDependencies(): void + { + $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + } + + /** Logged problems (e.g. invalid "Plural-Forms") need a logger in $DIC, else they go to error_log() and mark the test risky. */ + private function stubComponentLogger(): void + { + $factory = $this->createStub(ilLoggerFactory::class); + $factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); + $this->setGlobalVariable('ilLoggerFactory', $factory); + } + + private function buildLanguage(string $lang_key = 'de'): ilLanguage + { + global $DIC; + if ($DIC->offsetExists(LanguageFileDirectoryManager::class)) { + MigratedPoFixture::build($DIC[LanguageFileDirectoryManager::class], (string) ILIAS_ABSOLUTE_PATH); + } + $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $reflected = new ReflectionObject($language); + $reflected->getProperty('lang_key')->setValue($language, $lang_key); + $reflected->getProperty('lang_user')->setValue($language, $lang_key); + $reflected->getProperty('lang_default')->setValue($language, $lang_key); + $reflected->getProperty('log')->setValue($language, $this->createStub(ilLogger::class)); + + return $language; + } + + /** @param list $forms */ + private static function pluralCatalog(string $module, string $header, string $id, array $forms): TranslationCatalog + { + $catalog = new TranslationCatalog(); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + if ($header !== '') { + $catalog->setHeader('Plural-Forms', $header); + } + $entry = new TranslationEntry($module, $id); + $entry->setPlural($id . 's', $forms); + $catalog->add($entry); + + return $catalog; + } + + public function testSelectsTheFormThePluralRuleOfTheLanguagePicksForN(): void + { + $catalog = self::pluralCatalog('ntxt', 'nplurals=2; plural=(n != 1);', 'item', ['Eintrag', 'Einträge']); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager($this->contributeModule('ntxt', $catalog)); + + $language = $this->buildLanguage(); + $language->loadLanguageModule('ntxt'); + + $this->assertSame('Eintrag', $language->ntxt('item', 1)); + $this->assertSame('Einträge', $language->ntxt('item', 0)); + $this->assertSame('Einträge', $language->ntxt('item', 5)); + $this->assertSame('Einträge', $language->ntxt('item', -5), 'a negative n counts like its absolute value'); + } + + public function testFallsBackToTxtForAnOrdinarySingularIdentifier(): void + { + $catalog = MigratedPoFixture::catalog('ntxt', ['plain' => 'Einfach']); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager($this->contributeModule('ntxt', $catalog)); + + $language = $this->buildLanguage(); + $language->loadLanguageModule('ntxt'); + + $this->assertSame('Einfach', $language->ntxt('plain', 1)); + $this->assertSame('Einfach', $language->ntxt('plain', 5)); + } + + public function testFallsBackToTxtForATopicThatWasNeverLoadedAtAll(): void + { + $language = $this->buildLanguage(); + + $this->assertSame($language->txt('unknown_topic'), $language->ntxt('unknown_topic', 5)); + } + + /** Without a "Plural-Forms" header the Germanic rule (n==1 -> form 0, else 1) applies instead of failing. */ + public function testUsesTheGermanicRuleWhenThePluralFormsHeaderIsMissing(): void + { + $catalog = self::pluralCatalog('ntxt', '', 'item', ['Eintrag', 'Einträge']); + $this->stubUsageLogDependencies(); + $this->stubComponentLogger(); + $this->registerDirectoryManager($this->contributeModule('ntxt', $catalog)); + + $language = $this->buildLanguage(); + $language->loadLanguageModule('ntxt'); + + $this->assertSame('Eintrag', $language->ntxt('item', 1)); + $this->assertSame('Einträge', $language->ntxt('item', 5)); + } + + /** An empty selected form falls back to txt() rather than returning an empty string. */ + public function testFallsBackToTxtWhenTheSelectedFormIsEmpty(): void + { + $catalog = self::pluralCatalog('ntxt', 'nplurals=2; plural=(n != 1);', 'item', ['Eintrag', '']); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager($this->contributeModule('ntxt', $catalog)); + + $language = $this->buildLanguage(); + $language->loadLanguageModule('ntxt'); + + // an empty default form falls back to form 0, which txt() and the n=5 fallback of ntxt() both serve + $this->assertSame('Eintrag', $language->txt('item')); + $this->assertSame('Eintrag', $language->ntxt('item', 5)); + } + + /** A later module overwriting the identifier with a plain value must win for ntxt() too. */ + public function testALaterModuleOverwritingTheIdentifierWinsOverTheEarlierPluralForms(): void + { + $plural = self::pluralCatalog('ntxt_a', 'nplurals=2; plural=(n != 1);', 'shared', ['Eintrag', 'Einträge']); + $overwritten = MigratedPoFixture::catalog('ntxt_b', ['shared' => 'Überschrieben']); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager( + $this->contributeModule('ntxt_a', $plural), + $this->contributeModule('ntxt_b', $overwritten) + ); + + $language = $this->buildLanguage(); + $language->loadLanguageModule('ntxt_a'); + $language->loadLanguageModule('ntxt_b'); + + $this->assertSame('Überschrieben', $language->ntxt('shared', 5)); + } + + public function testAnOverlayReplacingThePluralFormsIsUsedForFormSelection(): void + { + $shipped = self::pluralCatalog('ntxt', 'nplurals=2; plural=(n != 1);', 'item', ['Eintrag', 'Einträge']); + $overlay = self::pluralCatalog('ntxt', 'nplurals=2; plural=(n != 1);', 'item', ['Datensatz', 'Datensätze']); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager($this->contributeModule('ntxt', $shipped, $overlay)); + + $language = $this->buildLanguage(); + $language->loadLanguageModule('ntxt'); + + $this->assertSame('Datensatz', $language->ntxt('item', 1)); + $this->assertSame('Datensätze', $language->ntxt('item', 5)); + } +} From 0203d7b05b141979e83785875f34684292d2536b Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:22:54 +0200 Subject: [PATCH 033/103] Language: Add a tool that converts a module into PO files convert_module_to_po.php generates the .pot and the .po files of a module from lang/ilias_*.lang, with plural forms from plurals.json (CLDR 48.2). The scan scripts report shipped markup violations and duplicate identifiers. --- .../PluralFormsShippedRulesTest.php | 134 +++ .../tests/ConvertModuleToPoToolTest.php | 933 +++++++++++++++++ .../Language/tools/po-migration/.gitignore | 2 + .../po-migration/convert_module_to_po.php | 946 ++++++++++++++++++ .../Language/tools/po-migration/plurals.json | 44 + .../scan_shipped_case_duplicates.php | 111 ++ .../scan_shipped_cross_module_duplicates.php | 177 ++++ .../po-migration/scan_shipped_markup.php | 73 ++ 8 files changed, 2420 insertions(+) create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/PluralFormsShippedRulesTest.php create mode 100644 components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php create mode 100644 components/ILIAS/Language/tools/po-migration/.gitignore create mode 100644 components/ILIAS/Language/tools/po-migration/convert_module_to_po.php create mode 100644 components/ILIAS/Language/tools/po-migration/plurals.json create mode 100644 components/ILIAS/Language/tools/po-migration/scan_shipped_case_duplicates.php create mode 100644 components/ILIAS/Language/tools/po-migration/scan_shipped_cross_module_duplicates.php create mode 100644 components/ILIAS/Language/tools/po-migration/scan_shipped_markup.php diff --git a/components/ILIAS/Language/tests/ComponentTranslation/PluralFormsShippedRulesTest.php b/components/ILIAS/Language/tests/ComponentTranslation/PluralFormsShippedRulesTest.php new file mode 100644 index 000000000000..adccbffe3290 --- /dev/null +++ b/components/ILIAS/Language/tests/ComponentTranslation/PluralFormsShippedRulesTest.php @@ -0,0 +1,134 @@ + lang key => header + */ + private static function shippedHeaders(): array + { + $data = json_decode((string) file_get_contents(self::PLURALS_JSON), true, 512, JSON_THROW_ON_ERROR); + + return $data['plural_forms']; + } + + /** + * @return array lang key => CLDR category index of n (order of the + * categories as in the header: zero, one, two, few, many, other where present) + */ + private static function oracles(): array + { + $one_other = static fn(int $n): int => $n === 1 ? 0 : 1; + $zero = static fn(int $n): int => 0; + $many_million = static fn(int $n): int => $n !== 0 && $n % 1000000 === 0 ? 1 : 2; + $east_slavic = static fn(int $n): int => $n % 10 === 1 && $n % 100 !== 11 + ? 0 + : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 1 : 2); + $cs_sk = static fn(int $n): int => $n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2); + + return [ + 'ar' => static fn(int $n): int => match (true) { + $n === 0 => 0, + $n === 1 => 1, + $n === 2 => 2, + $n % 100 >= 3 && $n % 100 <= 10 => 3, + $n % 100 >= 11 => 4, + default => 5, + }, + 'bg' => $one_other, 'da' => $one_other, 'de' => $one_other, 'el' => $one_other, + 'en' => $one_other, 'et' => $one_other, 'hu' => $one_other, 'ka' => $one_other, + 'nl' => $one_other, 'sq' => $one_other, 'sv' => $one_other, 'tr' => $one_other, + 'fa' => static fn(int $n): int => $n <= 1 ? 0 : 1, + 'ja' => $zero, 'vi' => $zero, 'zh' => $zero, + 'cs' => $cs_sk, 'sk' => $cs_sk, + 'es' => static fn(int $n): int => $n === 1 ? 0 : $many_million($n), + 'it' => static fn(int $n): int => $n === 1 ? 0 : $many_million($n), + 'fr' => static fn(int $n): int => $n <= 1 ? 0 : $many_million($n), + 'pt' => static fn(int $n): int => $n <= 1 ? 0 : $many_million($n), + 'hr' => $east_slavic, 'ru' => $east_slavic, 'sr' => $east_slavic, 'uk' => $east_slavic, + 'pl' => static fn(int $n): int => $n === 1 + ? 0 + : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 1 : 2), + 'lt' => static fn(int $n): int => $n % 10 === 1 && ($n % 100 < 11 || $n % 100 > 19) + ? 0 + : ($n % 10 >= 2 && ($n % 100 < 11 || $n % 100 > 19) ? 1 : 2), + 'ro' => static fn(int $n): int => $n === 1 + ? 0 + : ($n === 0 || ($n % 100 >= 1 && $n % 100 <= 19) ? 1 : 2), + 'sl' => static fn(int $n): int => match (true) { + $n % 100 === 1 => 0, + $n % 100 === 2 => 1, + $n % 100 === 3, $n % 100 === 4 => 2, + default => 3, + }, + ]; + } + + /** + * @return array + */ + public static function languages(): array + { + $rows = []; + foreach (self::shippedHeaders() as $lang => $header) { + $rows[$lang] = [$lang, $header]; + } + + return $rows; + } + + public function testEveryShippedLanguageHasAnOracleAndViceVersa(): void + { + $this->assertEqualsCanonicalizing( + array_keys(self::shippedHeaders()), + array_keys(self::oracles()), + 'plurals.json and the oracles of this test must list the same languages' + ); + } + + #[DataProvider('languages')] + public function testShippedHeaderSelectsTheCldrCategoryForEveryInteger(string $lang, string $header): void + { + $forms = PluralForms::fromHeader($header); + $oracle = self::oracles()[$lang]; + + $samples = [...range(0, 2000), 10000, 100000, 999999, 1000000, 1000001, 2000000, 2147483647]; + foreach ($samples as $n) { + $this->assertSame($oracle($n), $forms->formIndexFor($n), sprintf('%s, n = %d', $lang, $n)); + } + // header and the largest index used agree, so no form of the .po is never selected + $used = array_unique(array_map($oracle, $samples)); + $this->assertSame(max($used) + 1, $forms->getCount(), $lang); + } +} diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php new file mode 100644 index 000000000000..082440acb129 --- /dev/null +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -0,0 +1,933 @@ +directory = sys_get_temp_dir() . '/ilias_convert_tool_' . bin2hex(random_bytes(4)); + mkdir($this->directory . '/out', 0775, true); + } + + protected function tearDown(): void + { + MigratedPoFixture::removeDirectory($this->directory); + } + + /** @return array{int, string, string} exit code, stdout, stderr */ + private function runTool(string ...$arguments): array + { + return $this->runToolAt(self::TOOL, ...$arguments); + } + + /** + * The tool derives `$repo_root` from its own `__DIR__` (five levels up) with no override, so fixture + * repos need a copy of the tool at the same depth in a throwaway root. + * + * @return array{int, string, string} exit code, stdout, stderr + */ + private function runToolAt(string $tool_path, string ...$arguments): array + { + $result = ChildPhp::run( + ['-d', 'error_reporting=-1', '-d', 'display_errors=stderr', $tool_path, ...$arguments] + ); + $this->assertNotNull($result, 'could not start the child PHP process'); + + return $result; + } + + /** Throwaway root with a copy of the tool at the same relative depth and a symlinked Composer autoloader. */ + private function buildFixtureRepo(): string + { + $root = $this->directory . '/repo'; + $tool_dir = $root . '/components/ILIAS/Language/tools/po-migration'; + mkdir($tool_dir, 0775, true); + mkdir($root . '/lang', 0775, true); + mkdir($root . '/out', 0775, true); + copy(self::TOOL, $tool_dir . '/convert_module_to_po.php'); + + mkdir($root . '/vendor/composer/vendor', 0775, true); + $real_autoload = realpath(__DIR__ . '/../../../../vendor/composer/vendor/autoload.php'); + $this->assertIsString($real_autoload, 'the real Composer autoloader was not found'); + symlink($real_autoload, $root . '/vendor/composer/vendor/autoload.php'); + + return $root; + } + + private function toolPathOf(string $fixture_root): string + { + return $fixture_root . '/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php'; + } + + /** + * Without plurals.json the tool writes no "Plural-Forms" header and no plural message. + * + * @param array $plural_forms lang_key => "Plural-Forms" header + * @param array> $modules + * module => msgid => definition + */ + private function writePluralsConfig(string $fixture_root, array $plural_forms, array $modules): void + { + file_put_contents( + $fixture_root . '/components/ILIAS/Language/tools/po-migration/plurals.json', + (string) json_encode(['plural_forms' => $plural_forms, 'modules' => $modules], JSON_THROW_ON_ERROR) + ); + } + + private function writeLangFile(string $fixture_root, string $lang_key, string $content): void + { + file_put_contents($fixture_root . '/lang/ilias_' . $lang_key . '.lang', $content); + } + + /** @return array file name => content */ + private static function files(string $directory): array + { + $files = []; + foreach (glob($directory . '/*.{po,pot}', GLOB_BRACE) ?: [] as $file) { + $files[basename($file)] = (string) file_get_contents($file); + } + ksort($files); + + return $files; + } + + /** Every language's keys must be a subset of the reference language's, else the run fails before writing. */ + public function testFailsWithExitOneAndWritesNothingWhenALanguageHasAKeyMissingFromTheReferenceLanguage(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n"); + $this->writeLangFile($root, 'fr', "tst#:#greeting#:#Bonjour\ntst#:#extra#:#En trop\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString("reference language 'de' does not have", $stderr); + $this->assertStringContainsString('fr: extra', $stderr); + $this->assertSame([], glob($root . '/out/*') ?: [], 'nothing must be written once an inconsistency was found'); + } + + /** A value with several "#:#" is parsed like LanguageInstallationManager::insertLanguage(): first part only, plus a warning. */ + public function testWarnsOnAnExtraHashColonHashInAValueAndUsesOnlyThePartBeforeItLikeTheInstaller(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo#:#Extra\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString( + 'contains "#:#" in its value - only the part before it is used, like the installer does.', + $stderr + ); + $catalog = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_de.po'); + $entry = $catalog->find(null, 'greeting'); + $this->assertNotNull($entry); + $this->assertSame('Hallo', $entry->getTranslation()); + } + + /** A note merely mentioning "new variable" (no leading date) stays an extracted comment, not the fuzzy flag. */ + public function testANoteThatMerelyMentionsNewVariableStaysANoteAndIsNotMarkedFuzzy(): void + { + $root = $this->buildFixtureRepo(); + $note = 'Bitte pruefen, ob hier evtl. eine neue Variable (englisch: new variable) gemeint war'; + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo###$note\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $catalog = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_de.po'); + $entry = $catalog->find(null, 'greeting'); + $this->assertNotNull($entry); + $this->assertFalse($entry->hasFlag('fuzzy')); + $this->assertSame([$note], $entry->getExtractedComments()); + } + + /** Counterpart of the test above: the dated placeholder format does become the fuzzy flag. */ + public function testTheDatedNewVariablePlaceholderFormatIsMarkedFuzzyNotKeptAsANote(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n"); + $this->writeLangFile($root, 'fr', "tst#:#greeting#:####13 3 2019 new variable\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $catalog = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_fr.po'); + $entry = $catalog->find(null, 'greeting'); + $this->assertNotNull($entry); + $this->assertTrue($entry->hasFlag('fuzzy')); + $this->assertSame([], $entry->getExtractedComments()); + } + + /** + * A reference key a language lacks or has empty gets the reference value, flagged fuzzy; an own note of + * the empty entry stays its extracted comment. + */ + public function testAMissingOrEmptyValueGetsTheReferenceValueFlaggedFuzzy(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'en', "tst#:#greeting#:#Hello\ntst#:#bye#:#Bye\ntst#:#none#:#\n"); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n"); + $this->writeLangFile($root, 'fr', "tst#:#greeting#:####Bitte uebersetzen\ntst#:#bye#:#Salut\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $out = dirname($this->toolPathOf($root)) . '/output/tst'; + $pot = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($out . '/tst.pot'); + $this->assertSame(['greeting', 'bye', 'none'], array_map(static fn($e): string => $e->getId(), $pot->getEntries()), 'msgids from en'); + + $de = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($out . '/tst_de.po'); + $this->assertSame('Hallo', $de->find(null, 'greeting')?->getTranslation()); + $this->assertFalse($de->find(null, 'greeting')->hasFlag('fuzzy')); + $this->assertSame('Bye', $de->find(null, 'bye')?->getTranslation(), 'no line: the en value'); + $this->assertTrue($de->find(null, 'bye')->hasFlag('fuzzy')); + $this->assertSame('', $de->find(null, 'none')?->getTranslation(), 'an empty en value fills nothing'); + $this->assertFalse($de->find(null, 'none')->hasFlag('fuzzy')); + + $fr = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($out . '/tst_fr.po'); + $this->assertSame('Hello', $fr->find(null, 'greeting')?->getTranslation(), 'empty value: the en value'); + $this->assertTrue($fr->find(null, 'greeting')->hasFlag('fuzzy')); + $this->assertSame(['Bitte uebersetzen'], $fr->find(null, 'greeting')->getExtractedComments()); + $this->assertFalse($fr->find(null, 'bye')?->hasFlag('fuzzy')); + + $this->assertMatchesRegularExpression('/^de\s+1\s+0\s+1\s+yes$/m', $stdout); + $this->assertMatchesRegularExpression('/^en\s+3\s+0\s+0\s+yes$/m', $stdout); + $this->assertMatchesRegularExpression('/^fr\s+2\s+0\s+1\s+yes$/m', $stdout); + $this->assertStringContainsString("(2 entries filled from 'en', flagged fuzzy)", $stdout); + } + + /** A missing plural message (and its singular key) gets the reference values, flagged fuzzy. */ + public function testAMissingPluralMessageGetsTheReferenceValuesFlaggedFuzzy(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'en', "tst#:#item#:#Entries\ntst#:#item_singular#:#One entry\n"); + $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\n"); + $this->writeLangFile($root, 'fr', "tst#:#item_singular#:#Une entree\n"); + $this->writePluralsConfig( + $root, + ['en' => 'nplurals=2; plural=(n != 1);', 'de' => 'nplurals=2; plural=(n != 1);', 'fr' => 'nplurals=2; plural=(n > 1);'], + ['tst' => ['item' => ['singular' => 'item_singular']]] + ); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $de_item = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_de.po')->find(null, 'item'); + $this->assertSame(['Eintraege', 'Eintraege'], $de_item?->getPluralTranslations(), 'the singular key filled from the reference language is not taken over'); + $this->assertFalse($de_item->hasFlag('fuzzy'), 'de: a source language, its own value is present'); + $fr_item = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_fr.po')->find(null, 'item'); + $this->assertSame(['Entries', 'Entries'], $fr_item?->getPluralTranslations(), 'the en value in every form'); + $this->assertTrue($fr_item->hasFlag('fuzzy')); + $this->assertStringContainsString('round-trip identically through PO', $stdout); + } + + /** A singular key carrying the dated placeholder is not used: every form is the message's own value. */ + public function testAPluralMessageIsMarkedFuzzyWhenItsSingularKeyIsButOnlyWhereTheSingularValueIsActuallyUsed(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein Eintrag\n"); + $this->writeLangFile( + $root, + 'fr', + "tst#:#item#:#Des entrees\ntst#:#item_singular#:#Une entree###13 3 2019 new variable\n" + ); + $this->writeLangFile( + $root, + 'zh', + "tst#:#item#:#Eintraege ZH\ntst#:#item_singular#:#Ein Eintrag ZH###13 3 2019 new variable\n" + ); + $this->writePluralsConfig( + $root, + [ + 'de' => 'nplurals=2; plural=(n != 1);', + 'fr' => 'nplurals=2; plural=(n != 1);', + 'zh' => 'nplurals=1; plural=0;', + ], + ['tst' => ['item' => ['singular' => 'item_singular']]] + ); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $fr_item = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_fr.po') + ->find(null, 'item'); + $this->assertNotNull($fr_item); + $this->assertSame(['Des entrees', 'Des entrees'], $fr_item->getPluralTranslations(), 'a fuzzy singular key is not taken over'); + $this->assertTrue($fr_item->hasFlag('fuzzy'), 'fr: the own value is copied into both forms'); + + $zh_item = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_zh.po') + ->find(null, 'item'); + $this->assertNotNull($zh_item); + $this->assertSame(['Eintraege ZH'], $zh_item->getPluralTranslations(), 'precondition: single form, own value - not the singular key'); + $this->assertFalse($zh_item->hasFlag('fuzzy'), 'zh: the singular key is never used here, so its fuzziness must not leak in'); + } + + /** Without a singular key the own value is copied into every form: fuzzy only for multi-form non-source languages. */ + public function testAPluralMessageWithoutASingularKeyIsFuzzyOnlyInATranslationWithSeveralForms(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#multi#:#Mehrere Werte\n"); + $this->writeLangFile($root, 'fr', "tst#:#multi#:#Plusieurs valeurs\n"); + $this->writeLangFile($root, 'zh', "tst#:#multi#:#Mehrere Werte ZH\n"); + $this->writePluralsConfig( + $root, + ['de' => 'nplurals=2; plural=(n != 1);', 'fr' => 'nplurals=2; plural=(n > 1);', 'zh' => 'nplurals=1; plural=0;'], + ['tst' => ['multi' => []]] + ); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $de_multi = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_de.po') + ->find(null, 'multi'); + $this->assertNotNull($de_multi); + $this->assertSame(['Mehrere Werte', 'Mehrere Werte'], $de_multi->getPluralTranslations()); + $this->assertFalse($de_multi->hasFlag('fuzzy'), 'de: a source language'); + $fr_multi = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_fr.po') + ->find(null, 'multi'); + $this->assertSame(['Plusieurs valeurs', 'Plusieurs valeurs'], $fr_multi?->getPluralTranslations()); + $this->assertTrue($fr_multi->hasFlag('fuzzy'), 'fr: copied into 2 forms'); + + $zh_multi = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_zh.po') + ->find(null, 'multi'); + $this->assertNotNull($zh_multi); + $this->assertSame(['Mehrere Werte ZH'], $zh_multi->getPluralTranslations()); + $this->assertFalse($zh_multi->hasFlag('fuzzy'), 'zh: a single form is not "copied" anywhere'); + } + + /** A `lang/ilias_*.lang` whose name yields no lowercase language key is skipped with a warning. */ + public function testAFileWithoutALanguageKeyInItsNameIsSkippedWithAWarning(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n"); + file_put_contents($root . '/lang/ilias_ABC.lang', "tst#:#greeting#:#Sollte nicht gelesen werden\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('WARNING: skipping', $stderr); + $this->assertStringContainsString('ilias_ABC.lang', $stderr); + $this->assertStringContainsString('no language key in its name', $stderr); + $this->assertFileDoesNotExist($root . '/out/tst_ABC.po'); + } + + /** "Language-Team" is the English name from `meta_l_` of ilias_en.lang; without it the header is left out. */ + public function testLanguageTeamHeaderNamesTheLanguageInEnglishFromTheMetaModule(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n"); + $this->writeLangFile($root, 'en', "tst#:#greeting#:#Hello\nmeta#:#meta_l_de#:#German\nmeta#:#meta_l_en#:#English\n"); + $this->writeLangFile($root, 'fr', "tst#:#greeting#:#Bonjour\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $de = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_de.po'); + $this->assertSame('de', $de->getHeader('Language')); + $this->assertSame('German', $de->getHeader('Language-Team')); + $en = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_en.po'); + $this->assertSame('English', $en->getHeader('Language-Team')); + $fr = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_fr.po'); + $this->assertNull($fr->getHeader('Language-Team')); + $this->assertStringContainsString("NOTE: no meta_l_fr in lang/ilias_en.lang", $stdout); + $pot = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst.pot'); + $this->assertNull($pot->getHeader('Language-Team')); + } + + /** A custom --pattern names the .po files and the .pot template (pattern without "%s", trimmed of "_-."). */ + public function testCustomPatternNamesThePoFilesAndThePotTemplate(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt( + $this->toolPathOf($root), + '--pattern=ilias_%s', + 'tst', + 'de', + $root . '/out' + ); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertFileExists($root . '/out/ilias_de.po'); + $this->assertFileExists($root . '/out/ilias.pot'); + $this->assertFileDoesNotExist($root . '/out/tst_de.po'); + $this->assertFileDoesNotExist($root . '/out/tst.pot'); + $entry = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/ilias_de.po')->find(null, 'greeting'); + $this->assertNotNull($entry); + $this->assertSame('Hallo', $entry->getTranslation()); + } + + /** An invalid --pattern fails with exit code 1 before the .lang files are read and writes nothing. */ + public function testAnInvalidPatternFailsWithExitOneAndWritesNothing(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt( + $this->toolPathOf($root), + '--pattern=no-placeholder-at-all', + 'tst', + 'de', + $root . '/out' + ); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('not a valid file name pattern', $stderr); + $this->assertSame([], glob($root . '/out/*') ?: [], 'nothing must be written for an invalid pattern'); + } + + private const string START = ""; + + /** The contribution --remove-from-lang looks for in a component class. */ + private static function contribution(string $module, string $further_arguments = ''): string + { + return '$contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => ' + . "new ComponentLanguageFileDirectory(\$this, '$module'$further_arguments);"; + } + + private static function componentSource(string $code): string + { + return "/Comp/lang (and Comp.php unless $source is null), the outputDir --remove-from-lang needs. */ + private function addComponent(string $root, ?string $source, string $vendor = 'ILIAS'): string + { + $directory = $root . '/components/' . $vendor . '/Comp'; + mkdir($directory . '/lang', 0775, true); + if ($source !== null) { + file_put_contents($directory . '/Comp.php', $source); + } + + return $directory . '/lang'; + } + + private function addRegisteredComponent(string $root, string $module): string + { + return $this->addComponent($root, self::componentSource(self::contribution($module))); + } + + /** @return array file name => content and file mode of everything in lang/ */ + private static function langSnapshot(string $root): array + { + clearstatcache(); + $snapshot = []; + foreach (glob($root . '/lang/*') ?: [] as $file) { + $snapshot[basename($file)] = [(string) file_get_contents($file), fileperms($file)]; + } + ksort($snapshot); + + return $snapshot; + } + + /** @return list the keys (msgids) of a written .po/.pot */ + private static function idsOf(string $file): array + { + return array_map( + static fn($entry): string => $entry->getId(), + TranslationCatalog::fromPoFile($file)->getEntries() + ); + } + + /** @return array, list, string}> lang files, options, expected stderr part */ + public static function failureProvider(): array + { + return [ + 'key missing from the reference language' => [ + ['de' => "tst#:#a#:#A\n", 'fr' => "tst#:#a#:#B\ntst#:#x#:#X\n"], [], 'fr: x', + ], + 'empty key in the reference language' => [ + ['de' => "tst#:#a#:#A\ntst#:##:#E\n"], [], 'de: "" (empty key)', + ], + 'identical duplicate without the option' => [ + ['de' => "tst#:#a#:#A\ntst#:#a#:#A\n"], [], 'de: a', + ], + 'duplicate with different values, also with the option' => [ + ['de' => "tst#:#a#:#A\ntst#:#a#:#B\n"], ['--skip-unmigratable-keys'], 'duplicate identifiers', + ], + 'duplicate differing only in its ### comment, also with the option' => [ + ['de' => "tst#:#a#:#A###x\ntst#:#a#:#A###y\n"], ['--skip-unmigratable-keys'], 'duplicate identifiers', + ], + 'module without any line (migrated already)' => [ + ['de' => "other#:#a#:#A\n", 'fr' => "other#:#a#:#B\n"], [], 'has no lines in any of the 2 lang/ilias_*.lang files', + ], + 'control character in a key is shown escaped' => [ + ['de' => "tst#:#a#:#A\n", 'fr' => "tst#:#a#:#B\ntst#:#x\033y#:#X\n"], [], 'fr: x\033y', + ], + 'unknown option' => [ + ['de' => "tst#:#a#:#A\n"], ['--remove-from-lang-typo'], "unknown option '--remove-from-lang-typo'", + ], + ]; + } + + /** + * A run failing before the .po files are written touches no .lang file, and creates no output directory without --remove-from-lang. + * + * @param array $files + * @param list $options + */ + #[DataProvider('failureProvider')] + public function testAFailedRunNeverTouchesALangFileNorWritesAnything(array $files, array $options, string $expected_stderr): void + { + $root = $this->buildFixtureRepo(); + $component_lang = $this->addRegisteredComponent($root, 'tst'); + foreach ($files as $lang_key => $content) { + $this->writeLangFile($root, $lang_key, $content); + } + $before = self::langSnapshot($root); + + [$exit_code, $stdout, $stderr] = $this->runToolAt( + $this->toolPathOf($root), + '--remove-from-lang', + ...$options, + ...['tst', 'de', $component_lang] + ); + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString($expected_stderr, $stderr); + $this->assertStringNotContainsString("\033", $stderr); + $this->assertSame($before, self::langSnapshot($root)); + $this->assertSame([], glob($component_lang . '/*') ?: [], 'nothing written'); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), ...$options, ...['tst', 'de', $root . '/newout']); + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertDirectoryDoesNotExist($root . '/newout'); + } + + /** @return array}> */ + public static function invalidArgumentsProvider(): array + { + return [ + 'short option' => [['-x', 'tst', 'de']], + 'misspelled long option' => [['--foo', 'tst', 'de']], + 'module with a path' => [['../x', 'de']], + 'reference language with a path' => [['tst', '../de']], + ]; + } + + /** @param list $arguments */ + #[DataProvider('invalidArgumentsProvider')] + public function testInvalidArgumentsFailWithExitOneAndWriteNothing(array $arguments): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#a#:#A\n"); + $before = self::langSnapshot($root); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), ...$arguments, ...[$root . '/newout']); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertNotSame('', $stderr); + $this->assertSame($before, self::langSnapshot($root)); + $this->assertDirectoryDoesNotExist($root . '/newout'); + $this->assertDirectoryDoesNotExist($root . '/x'); + } + + public function testWithoutTheOptionTheHintNamesItAndWithItEverythingUnmigratableIsReportedAndLeftOut(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#a#:#A\ntst#:#a#:#A\ntst#:##:#leer\ntst#:#k#1#:#V###note\n"); + $this->writeLangFile($root, 'fr', "tst#:#a#:#FA\ntst#:#c#:#C\ntst#:#c#:#C\ntst#:#x#y#:#X\ntst#:#k#1#:#FV\n"); + $before = self::langSnapshot($root); + + [$exit_code, , $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + $this->assertSame(1, $exit_code); + $this->assertStringContainsString('--skip-unmigratable-keys', $stderr); + $this->assertSame([], glob($root . '/out/*') ?: []); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--skip-unmigratable-keys', 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('WARNING', $stderr); + $this->assertStringContainsString(" de: \"\" (empty key)\n", $stderr); + $this->assertStringContainsString(" fr: c, x#y\n", $stderr, 'the key with # whole'); + $this->assertStringContainsString(" fr: c\n", $stderr, 'identical duplicate'); + $this->assertStringContainsString(" de: a\n", $stderr); + $this->assertStringContainsString('round-trip identically', $stdout); + $this->assertSame(['a', 'k#1'], self::idsOf($root . '/out/tst_de.po'), 'only the migratable keys'); + $this->assertSame(['a', 'k#1'], self::idsOf($root . '/out/tst_fr.po')); + $this->assertSame('note', TranslationCatalog::fromPoFile($root . '/out/tst_de.po')->find(null, 'k#1')?->getExtractedComments()[0] ?? null); + $this->assertSame($before, self::langSnapshot($root), 'without --remove-from-lang the .lang files stay'); + } + + /** A key left out anyway does not fail the run for a conflicting duplicate: warning with the option, failure without. */ + public function testADuplicateWithDifferentValuesOfAKeyThatIsLeftOutAnywayOnlyWarns(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#a#:#A\n"); + $this->writeLangFile($root, 'fr', "tst#:#a#:#B\ntst#:#x#:#1\ntst#:#x#:#2\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + $this->assertSame(1, $exit_code, $stdout . $stderr); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--skip-unmigratable-keys', 'tst', 'de', $root . '/out'); + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('WARNING', $stderr); + $this->assertStringContainsString(" fr: x\n", $stderr); + $this->assertSame(['a'], self::idsOf($root . '/out/tst_fr.po')); + } + + public function testTheSingularKeyGoesToTheFormThatServesOnlyOneWhateverItsNumber(): void + { + $root = $this->buildFixtureRepo(); + foreach (['de' => ['Eintraege', 'Ein Eintrag'], 'es' => ['Entradas', 'Una entrada'], 'ar' => ['AR viele', 'AR eins']] as $lang_key => [$plural, $singular]) { + $this->writeLangFile($root, $lang_key, "tst#:#item#:#$plural\ntst#:#item_singular#:#$singular\n"); + } + $this->writePluralsConfig( + $root, + [ + 'de' => 'nplurals=2; plural=(n != 1);', + 'es' => 'nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);', + 'ar' => 'nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));', + ], + ['tst' => ['item' => ['singular' => 'item_singular']]] + ); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $forms = static fn(string $lang_key): ?array => TranslationCatalog::fromPoFile($root . "/out/tst_$lang_key.po")->find(null, 'item')?->getPluralTranslations(); + $this->assertSame(['Ein Eintrag', 'Eintraege'], $forms('de')); + $this->assertSame(['Una entrada', 'Entradas', 'Entradas'], $forms('es')); + $this->assertSame(['AR viele', 'AR eins', 'AR viele', 'AR viele', 'AR viele', 'AR viele'], $forms('ar')); + } + + public function testATranslatedSingularEqualToTheOwnValueIsNotFuzzy(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein Eintrag\n"); + $this->writeLangFile($root, 'fr', "tst#:#item#:#Photo\ntst#:#item_singular#:#Photo\n"); + $this->writePluralsConfig( + $root, + ['de' => 'nplurals=2; plural=(n != 1);', 'fr' => 'nplurals=2; plural=(n != 1);'], + ['tst' => ['item' => ['singular' => 'item_singular']]] + ); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $item = TranslationCatalog::fromPoFile($root . '/out/tst_fr.po')->find(null, 'item'); + $this->assertSame(['Photo', 'Photo'], $item?->getPluralTranslations()); + $this->assertFalse($item->hasFlag('fuzzy'), 'both forms are translated, they just are equal'); + } + + public function testAKeyWithTheSyntaxOfAPluralFormIsRejectedOrLeftOut(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#a#:#A\ntst#:#item [0]#:#Form\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('FAILURE', $stderr); + $this->assertStringContainsString('item [0]', $stderr); + $this->assertSame([], glob($root . '/out/*') ?: [], 'nothing was written'); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--skip-unmigratable-keys', 'tst', 'de', $root . '/out'); + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('item [0]', $stderr); + $this->assertSame(['a'], self::idsOf($root . '/out/tst_de.po')); + } + + /** Byte-exact removal of the module's own lines only; line endings, mode and the header above the start marker stay. */ + public function testRemoveFromLangRemovesExactlyTheModulesLinesAndKeepsEveryOtherByte(): void + { + $root = $this->buildFixtureRepo(); + $out = $this->addRegisteredComponent($root, 'crs'); + $start = self::START; + $this->writeLangFile($root, 'de', "# header\n$start\ncrs#:#a#:#A\ncrsr#:#a#:#Other\ncrs#:#b#:#B###c\nxcrs#:#a#:#X\nmeta#:#crs#:#crs#:#M\n"); + $this->writeLangFile($root, 'fr', "# header\r\n$start\r\ncrs#:#a#:#FA\r\ncrs#:#b#:#FB\r\ncrsr#:#a#:#R\r\n"); + $this->writeLangFile($root, 'en', "$start\ncrsr#:#a#:#R\ncrs#:#a#:#EA\ncrs#:#b#:#EB"); + $this->writeLangFile($root, 'es', "crs#:#a#:#SA\ncrs#:#b#:#SB\nkeep#:#k#:#K\n"); + $this->writeLangFile($root, 'it', "$start\nmeta#:#x#:#y\n"); + $this->writeLangFile($root, 'ABC', "$start\ncrs#:#a#:#ignored\n"); + chmod($root . '/lang/ilias_de.lang', 0640); + $before = self::langSnapshot($root); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $after = self::langSnapshot($root); + $this->assertSame("# header\n$start\ncrsr#:#a#:#Other\nxcrs#:#a#:#X\nmeta#:#crs#:#crs#:#M\n", $after['ilias_de.lang'][0]); + $this->assertSame("# header\r\n$start\r\ncrsr#:#a#:#R\r\n", $after['ilias_fr.lang'][0]); + $this->assertSame("$start\ncrsr#:#a#:#R", $after['ilias_en.lang'][0], 'no final line break stays none'); + $this->assertSame("keep#:#k#:#K\n", $after['ilias_es.lang'][0]); + $this->assertSame($before['ilias_it.lang'], $after['ilias_it.lang']); + $this->assertSame($before['ilias_ABC.lang'], $after['ilias_ABC.lang']); + $this->assertSame(0640, $after['ilias_de.lang'][1] & 07777); + $this->assertSame(array_keys($before), array_keys($after), 'no temporary file left behind'); + $this->assertStringContainsString(sprintf("removed %5d line(s) of 'crs' from lang/ilias_de.lang", 2), $stdout); + $this->assertStringContainsString(sprintf("removed %5d line(s) of 'crs' from lang/ilias_en.lang", 2), $stdout); + $this->assertStringNotContainsString('ilias_it.lang', $stdout); + $this->assertFileExists($out . '/crs_de.po'); + $this->assertFileExists($out . '/crs.pot'); + } + + /** Left out lines (empty key, other language, identical duplicates) leave the .lang files too. */ + public function testRemoveFromLangAlsoRemovesTheLeftOutLines(): void + { + $root = $this->buildFixtureRepo(); + $out = $this->addRegisteredComponent($root, 'crs'); + $start = self::START; + $this->writeLangFile($root, 'de', "$start\ncrs#:#a#:#A\ncrs#:#a#:#A\ncrs#:##:#leer\ncrs#:#b#:#B\nkeep#:#k#:#K\n"); + $this->writeLangFile($root, 'fr', "$start\ncrs#:#a#:#FA\ncrs#:#extra#:#E\nkeep#:#k#:#K2\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt( + $this->toolPathOf($root), + '--skip-unmigratable-keys', + '--remove-from-lang', + 'crs', + 'de', + $out + ); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $after = self::langSnapshot($root); + $this->assertSame("$start\nkeep#:#k#:#K\n", $after['ilias_de.lang'][0]); + $this->assertSame("$start\nkeep#:#k#:#K2\n", $after['ilias_fr.lang'][0]); + $this->assertSame(['a', 'b'], self::idsOf($out . '/crs_fr.po')); + } + + /** A module line above the start marker is never removed: the run then changes no .lang file at all. */ + public function testRemoveFromLangChangesNoFileWhenOneFileHoldsALineThatWouldNotBeRemoved(): void + { + $root = $this->buildFixtureRepo(); + $out = $this->addRegisteredComponent($root, 'crs'); + $start = self::START; + $this->writeLangFile($root, 'de', "$start\ncrs#:#a#:#A\ncrs#:#b#:#B\n"); + $this->writeLangFile($root, 'fr', "crs#:#a#:#FA\n$start\ncrs#:#b#:#FB\n"); + $this->writeLangFile($root, 'en', "$start\ncrs#:#a#:#EA\n"); + $before = self::langSnapshot($root); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('ilias_fr.lang: 2 line(s) of the module were read, but 1 would be removed', $stderr); + $this->assertSame($before, self::langSnapshot($root)); + } + + /** A second run must not overwrite the now only-source .po/.pot with empty ones. */ + public function testASecondRunForAMigratedModuleFailsAndKeepsThePoFilesByteIdentical(): void + { + $root = $this->buildFixtureRepo(); + $out = $this->addRegisteredComponent($root, 'crs'); + $this->writeLangFile($root, 'de', self::START . "\ncrs#:#a#:#A\n"); + $this->writeLangFile($root, 'fr', self::START . "\ncrs#:#a#:#FA\n"); + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out); + $this->assertSame(0, $exit_code, $stdout . $stderr); + $po_files = self::files($out); + $this->assertCount(3, $po_files); + $lang_files = self::langSnapshot($root); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('has no lines in any of the 2 lang/ilias_*.lang files', $stderr); + $this->assertStringContainsString('most likely it is migrated already', $stderr); + $this->assertSame($po_files, self::files($out)); + $this->assertSame($lang_files, self::langSnapshot($root)); + } + + /** + * @return array, string}> outputDir ({root} replaced; null: none), + * content of Comp.php (null: no file), further options, expected stderr part + */ + public static function refusedTargetProvider(): array + { + $registered = self::componentSource(self::contribution('tst')); + + return [ + 'no outputDir' => [null, $registered, [], 'the outputDir argument is required'], + 'outputDir does not exist' => ['{root}/components/ILIAS/Comp/missing', $registered, [], 'does not exist'], + 'outputDir outside of components' => ['{root}/out', $registered, [], 'is not components///lang'], + 'outputDir is the component, not its lang/' => ['{root}/components/ILIAS/Comp', $registered, [], 'is not components///lang'], + 'component class missing' => ['{root}/components/ILIAS/Comp/lang', null, [], 'Comp.php was not found'], + 'contribution only in a comment' => [ + '{root}/components/ILIAS/Comp/lang', + self::componentSource("// " . self::contribution('tst') . "\n /* " . self::contribution('tst') . ' */'), + [], + "contributes no ComponentLanguageFileDirectory for module 'tst'", + ], + 'contribution for another module' => [ + '{root}/components/ILIAS/Comp/lang', + self::componentSource(self::contribution('tstx')), + [], + "contributes no ComponentLanguageFileDirectory for module 'tst'", + ], + 'contributed path is not lang/' => [ + '{root}/components/ILIAS/Comp/lang', + self::componentSource(self::contribution('tst', ", 'translations/'")), + [], + "from 'translations/', not from lang/", + ], + 'contributed pattern differs from the contribution default' => [ + '{root}/components/ILIAS/Comp/lang', + self::componentSource(self::contribution('tst', ", 'lang/', 'x_%s'")), + [], + "pattern 'x_%s', but the run uses 'tst_%s'", + ], + '--pattern differs from the contribution' => [ + '{root}/components/ILIAS/Comp/lang', + $registered, + ['--pattern=ilias_%s'], + "but the run uses 'ilias_%s'", + ], + ]; + } + + /** @param list $options */ + #[DataProvider('refusedTargetProvider')] + public function testRemoveFromLangRefusesATargetThatTheInstallationWouldNotReadAndWritesNothing( + ?string $output, + ?string $component_source, + array $options, + string $expected_stderr + ): void { + $root = $this->buildFixtureRepo(); + $component_lang = $this->addComponent($root, $component_source); + $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n"); + $this->writeLangFile($root, 'fr', self::START . "\ntst#:#a#:#FA\n"); + $before = self::langSnapshot($root); + $arguments = ['tst', 'de', ...($output === null ? [] : [str_replace('{root}', $root, $output)])]; + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', ...$options, ...$arguments); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString($expected_stderr, $stderr); + $this->assertStringContainsString('Nothing was written.', $stderr); + $this->assertSame($before, self::langSnapshot($root)); + $this->assertSame([], glob($component_lang . '/*') ?: []); + $this->assertSame([], glob($root . '/out/*') ?: []); + } + + /** @return array, string}> content of Comp.php, further options, written file */ + public static function acceptedTargetProvider(): array + { + return [ + 'one line, defaults' => [self::componentSource(self::contribution('tst')), [], 'tst_de.po'], + 'multi-line with fully qualified names and a trailing comma' => [ + self::componentSource( + "\$contribute[\\ILIAS\\Language\\ComponentTranslation\\LanguageFileDirectory::class] = fn(): \\X\\LanguageFileDirectory => \n" + . " new \\ILIAS\\Language\\ComponentTranslation\\ComponentLanguageFileDirectory(\n" + . " \$this,\n 'tst',\n 'lang/',\n 'x_%s',\n );" + ), + ['--pattern=x_%s'], + 'x_de.po', + ], + 'a comment with another module does not matter' => [ + self::componentSource('// ' . self::contribution('tstx') . "\n " . self::contribution('tst', ", 'lang'")), + [], + 'tst_de.po', + ], + ]; + } + + /** @param list $options */ + #[DataProvider('acceptedTargetProvider')] + public function testRemoveFromLangAcceptsAContributionInEveryNotationOfTheComponentClass(string $component_source, array $options, string $written): void + { + $root = $this->buildFixtureRepo(); + $component_lang = $this->addComponent($root, $component_source); + $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', ...$options, ...['tst', 'de', $component_lang]); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertFileExists($component_lang . '/' . $written); + $this->assertSame(self::START . "\n", self::langSnapshot($root)['ilias_de.lang'][0]); + } + + public function testRemoveFromLangAcceptsASymbolicLinkThatResolvesToTheComponentLangDirectory(): void + { + $root = $this->buildFixtureRepo(); + $component_lang = $this->addRegisteredComponent($root, 'tst'); + symlink($component_lang, $root . '/link'); + $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'tst', 'de', $root . '/link'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertFileExists($component_lang . '/tst_de.po'); + } + + /** A symlinked .lang file would fail only when written, after other files changed: --remove-from-lang refuses it up front. */ + public function testRemoveFromLangRefusesASymbolicLinkInLangButTheOptionlessRunStillReadsIt(): void + { + $root = $this->buildFixtureRepo(); + $component_lang = $this->addRegisteredComponent($root, 'tst'); + $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n"); + symlink($root . '/lang/ilias_de.lang', $root . '/lang/ilias_zz.lang'); + $before = self::langSnapshot($root); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'tst', 'de', $component_lang); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('ilias_zz.lang', $stderr); + $this->assertSame($before, self::langSnapshot($root)); + $this->assertTrue(is_link($root . '/lang/ilias_zz.lang')); + $this->assertSame([], glob($component_lang . '/*') ?: []); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertFileExists($root . '/out/tst_zz.po'); + $this->assertSame($before, self::langSnapshot($root)); + } + + private function writeMetaPo(string $root, string $content): void + { + mkdir($root . '/components/ILIAS/Meta/lang', 0775, true); + file_put_contents($root . '/components/ILIAS/Meta/lang/meta_en.po', $content); + } + + public function testLanguageTeamFallsBackToAShippedMetaPoAndAnUnreadableOneOnlyWarns(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "tst#:#a#:#A\n"); + $this->writeLangFile($root, 'en', "tst#:#a#:#A\n"); + $this->writeMetaPo($root, "msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\nmsgid \"meta_l_de\"\nmsgstr \"German\"\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertSame('German', TranslationCatalog::fromPoFile($root . '/out/tst_de.po')->getHeader('Language-Team')); + $this->assertNull(TranslationCatalog::fromPoFile($root . '/out/tst_en.po')->getHeader('Language-Team')); + + file_put_contents($root . '/components/ILIAS/Meta/lang/meta_en.po', "msgid \"meta_l_de\"\nmsgstr \"unterminated\n\"x\n garbage"); + MigratedPoFixture::removeDirectory($root . '/out'); + mkdir($root . '/out'); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('meta_en.po cannot be read for the language names', $stderr); + $this->assertFileExists($root . '/out/tst_de.po'); + } +} diff --git a/components/ILIAS/Language/tools/po-migration/.gitignore b/components/ILIAS/Language/tools/po-migration/.gitignore new file mode 100644 index 000000000000..56d7bb1d89ed --- /dev/null +++ b/components/ILIAS/Language/tools/po-migration/.gitignore @@ -0,0 +1,2 @@ +# scratch output of convert_module_to_po.php (default outputDir output/) +/output/ diff --git a/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php new file mode 100644 index 000000000000..58c7eac53dd4 --- /dev/null +++ b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php @@ -0,0 +1,946 @@ +.lang` files of one language module and writes, via the language component's + * TranslationCatalog adapter (so the tool writes exactly what the runtime reads): + * - A POT template (.pot, or the file name pattern without "%s", see --pattern) + * - One PO file per shipped language (_.po, or named by --pattern) + * + * The entries have no msgctxt: a file belongs to one module (the directory's prefix) and stays valid when the module + * is renamed (e.g. a plugin that becomes a component). The runtime still reads files with the module as msgctxt. + * + * No `.mo` file is written: the `.po` files are the shipped source of truth and are compiled by Setup's build + * (ShippedTranslationsBuild) into `artifacts/language/`. The per-installation overlay only holds local changes. + * + * Headers of an existing target `.po` are kept, so a re-run only changes what the `.lang` files changed. The + * "Plural-Forms" header comes from plurals.json next to this tool. "Language-Team" is the English language name + * from `meta_l_` of `lang/ilias_en.lang`, or, once "meta" is migrated, of a shipped + * `components///lang/meta_en.po` (default file name pattern only). + * + * The reference language (default "en") gives the msgids, their order and the `.pot`. A reference key that a + * language lacks or has empty gets the reference value, flagged "fuzzy" (fuzzy entries are shipped like all + * others). A key only other languages have cannot be taken over (see --skip-unmigratable-keys). + * + * Plural messages (plurals.json, "modules"): the tool invents no text, it distributes existing values (one taken + * from the reference language counts). Where the rule has a form for n = 1 alone (found by evaluating the rule: + * form 0 in en, de, es, it, cs, pl; form 1 in ar), that form gets the configured singular key's value - only if the + * language translated it (not empty, not from the reference language, no "new variable" marker) - and every other + * form the message's own value; otherwise every form is the own value. The msgid_plural is "_plural" unless + * configured. The fuzzy flag follows the own value, and is also set where the own value was copied into several + * forms - except in the source languages (reference language, de, en). The singular key stays an entry of its own. + * + * Deliberately declares no named classes or functions: this file lives inside the classmap-scanned components/ tree, + * and anything named here would end up in Composer's classmap and Setup's class discovery. Everything is a closure. + * + * Usage: php convert_module_to_po.php [--pattern=] [--skip-unmigratable-keys] [--remove-from-lang] + * [referenceLangKey (default "en")] [outputDir] + * --pattern file name of a language without ".po", "%s" = language key (default "_%s"); + * must match the pattern of the module's ComponentLanguageFileDirectory + * --skip-unmigratable-keys instead of failing: leave out (WARNING on stderr) every key the reference language + * does not have, the empty key and every key containing " [" (plural form syntax " [
]"), + * and use one occurrence of a key that occurs more than once with the same value and comment every + * time, as the installer reads them (other duplicates of a left out key only warn, all others fail); + * left out keys are not part of the round-trip check + * --remove-from-lang only after a completely successful run (every file written and round-tripped): remove every + * line of the module - the left out ones included - from every lang/ilias_.lang read (atomically + * per file, all other bytes unchanged; if one file cannot be processed, none is changed). Requires + * (checked first) an existing outputDir components///lang whose .php + * already contributes the module's ComponentLanguageFileDirectory, and no symbolic link in lang/ + * Unknown options (anything starting with "-") fail; module and reference language must match [A-Za-z0-9_]+. + * A module without any line in lang/ fails without writing anything (most likely migrated already). + * Example: php convert_module_to_po.php tos en ../../../TermsOfService/lang + */ + +// a build tool: never reachable through the web server +if (PHP_SAPI !== 'cli') { + exit(1); +} + +require dirname(__DIR__, 5) . '/vendor/composer/vendor/autoload.php'; + +use ILIAS\Language\ComponentTranslation\AtomicFileWriter; +use ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog; +use ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry; +use ILIAS\Language\ComponentTranslation\LegacyFuzzyMarker; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; +use ILIAS\Language\ComponentTranslation\PluralForms; + +/** + * Makes control characters (e.g. the ESC of a terminal escape sequence) visible in everything printed from a `.lang` + * file or the command line. + */ +$printable = static function (string $text): string { + return addcslashes($text, "\0..\37\177"); +}; + +/** + * Read once, so the lines and the hash checked by --remove-from-lang come from the same bytes. + */ +$read_file = static function (string $file): string { + $content = file_get_contents($file); + if ($content === false) { + throw new RuntimeException("Cannot read $file"); + } + + return $content; +}; + +/** + * @param string $content of the `.lang` file $file (only named in warnings) + * @return list [module, key, raw value] of every entry line + */ +$read_lines = static function (string $file, string $content): array { + $entries = []; + // split at "\n" only, like file() (a "\r" before it is trimmed below) + foreach (explode("\n", $content) as $number => $line) { + // trimmed like ILIAS' own .lang reader, or a value would keep trailing whitespace the database never had + $parts = explode('#:#', trim($line)); + if (count($parts) < 3) { + continue; + } + if (count($parts) > 3) { + // parsed like LanguageInstallationManager::insertLanguage(): the part after a further "#:#" is dropped + fwrite(STDERR, sprintf( + "WARNING: %s:%d contains \"#:#\" in its value - only the part before it is used, like the installer does.\n", + $file, + $number + 1 + )); + } + $entries[] = [$parts[0], $parts[1], $parts[2]]; + } + + return $entries; +}; + +/** + * @return list [key, raw value] of every line of $module in file order + */ +$module_lines = static function (string $file, string $content, string $module) use ($read_lines): array { + $lines = []; + foreach ($read_lines($file, $content) as [$mod, $key, $raw_value]) { + if ($mod === $module) { + $lines[] = [$key, $raw_value]; + } + } + + return $lines; +}; + +/** + * @param list $lines see $module_lines - a later line of the same key wins + * @return array + */ +$entries_of = static function (array $lines): array { + $entries = []; + foreach ($lines as [$key, $raw_value]) { + $comment = null; + $value = $raw_value; + $comment_pos = strpos($raw_value, '###'); + if ($comment_pos !== false) { + $value = substr($raw_value, 0, $comment_pos); + $comment = trim(substr($raw_value, $comment_pos + 3)); + } + $entries[$key] = ['value' => $value, 'comment' => $comment]; + } + + return $entries; +}; + +/** + * @return array + */ +$parse_module = static function (string $file, string $module) use ($read_file, $module_lines, $entries_of): array { + return $entries_of($module_lines($file, $read_file($file), $module)); +}; + +/** + * A legacy `.lang` file can define the same identifier twice for one module (uniqueness validation of the FR + * "PO-Files for improving language handling", 2.4). + * + * @param list $lines see $module_lines + * @return array identifier occurring more than once => whether all occurrences have the same value and + * "###" comment as the installer reads them (surrounding whitespace and a part after a further "#:#" ignored) + */ +$find_duplicate_keys = static function (array $lines): array { + $raw_values = []; + foreach ($lines as [$key, $raw_value]) { + $raw_values[$key][] = $raw_value; + } + $duplicates = []; + foreach ($raw_values as $key => $values) { + if (count($values) > 1) { + $duplicates[(string) $key] = count(array_unique($values)) === 1; + } + } + + return $duplicates; +}; + +/** + * @param array $keys + */ +$format_keys = static function (array $keys) use ($printable): string { + return implode(', ', array_map( + static fn(int|string $key): string => (string) $key === '' ? '"" (empty key)' : $printable((string) $key), + $keys + )); +}; + +/** + * $content without the lines of $module - exactly those $read_lines reports, and only behind the line + * "" (the installer ignores everything before it; without that line the whole file + * counts as body). Every other byte stays: header, comments, line endings, order, a missing final line break. + * + * @return array{0: string, 1: int} the new content and the number of removed lines + */ +$without_module_lines = static function (string $content, string $module): array { + $lines = preg_split('/(?<=\n)/', $content, -1, PREG_SPLIT_NO_EMPTY); + $in_body = true; + foreach ($lines as $line) { + if (trim($line) === '') { + $in_body = false; + break; + } + } + $kept = []; + $removed = 0; + foreach ($lines as $line) { + if (!$in_body) { + $in_body = trim($line) === ''; + $kept[] = $line; + continue; + } + $parts = explode('#:#', trim($line)); + if (count($parts) >= 3 && $parts[0] === $module) { + $removed++; + continue; + } + $kept[] = $line; + } + $new_content = implode('', $kept); + // the removed line was the last one and had no line break: the new last line loses its own + if ($removed > 0 && !str_ends_with($content, "\n") && str_ends_with($new_content, "\n")) { + $new_content = substr($new_content, 0, str_ends_with($new_content, "\r\n") ? -2 : -1); + } + + return [$new_content, $removed]; +}; + +/** + * Whether a `###` comment is the dated "not translated yet" marker (-> "#, fuzzy") rather than a note (-> "#."), see + * LegacyFuzzyMarker (shared with the installation). + */ +$is_fuzzy_marker = LegacyFuzzyMarker::matches(...); + +/** + * The form that serves n = 1 if it serves no other n, else null. Found by evaluating the rule (not its formula + * text): form 0 in en, de, es, it, cs, pl; form 1 in ar; null where that form also serves 0 (fr, pt), 21, 101 (ru, + * hr, sl) and in single-form languages. + */ +$singular_form_index = static function (PluralForms $plural_forms): ?int { + if ($plural_forms->getCount() < 2) { + return null; + } + $index = $plural_forms->formIndexFor(1); + foreach ([...range(0, 200), 1000, 1001, 1011, 100000, 1000000, 1000001, 2000000] as $n) { + if ($n !== 1 && $plural_forms->formIndexFor($n) === $index) { + return null; + } + } + + return $index; +}; +/** + * The form of the plural message $key that gets the singular key's value, or null: only if the singular key is + * translated (not empty, not from the reference language, no "new variable" marker) and the rule has a form for + * n = 1 alone. Else every form keeps the own value: an untranslated placeholder must not replace a translated form. + * + * @param array $entries the language's entries of the module + * @param array{singular?: string, plural_id?: string} $definition + * @param array $filled_keys keys whose value was taken from the reference language + */ +$singular_form_of = static function ( + string $key, + array $definition, + array $entries, + PluralForms $plural_forms, + array $filled_keys +) use ($singular_form_index, $is_fuzzy_marker): ?int { + $singular_key = $definition['singular'] ?? null; + if ( + ($entries[$key]['value'] ?? '') === '' + || $singular_key === null + || ($entries[$singular_key]['value'] ?? '') === '' + || isset($filled_keys[$singular_key]) + ) { + return null; + } + $singular_comment = $entries[$singular_key]['comment'] ?? null; + if ($singular_comment !== null && $is_fuzzy_marker($singular_comment)) { + return null; + } + + return $singular_form_index($plural_forms); +}; +/** + * @param array $entries + * @param array{singular?: string, plural_id?: string} $definition + * @param array $filled_keys + * @return list + */ +$plural_forms_of = static function ( + string $key, + array $definition, + array $entries, + PluralForms $plural_forms, + array $filled_keys +) use ($singular_form_of): array { + $forms = array_fill(0, $plural_forms->getCount(), $entries[$key]['value'] ?? ''); + $singular_form = $singular_form_of($key, $definition, $entries, $plural_forms, $filled_keys); + if ($singular_form !== null) { + $forms[$singular_form] = $entries[$definition['singular']]['value']; + } + + return $forms; +}; +/** + * Whether the own value of the plural message $key was copied into several forms (flagged fuzzy, see the file docblock). + * + * @param array $entries + * @param array{singular?: string, plural_id?: string} $definition + * @param array $filled_keys + */ +$copies_own_value = static function ( + string $key, + array $definition, + array $entries, + PluralForms $plural_forms, + array $filled_keys +) use ($singular_form_of): bool { + // counted by position, not by value: a singular equal to the own value is no copy + $own_forms = $plural_forms->getCount() + - ($singular_form_of($key, $definition, $entries, $plural_forms, $filled_keys) === null ? 0 : 1); + + return ($entries[$key]['value'] ?? '') !== '' && $own_forms > 1; +}; + +/** + * @param array $reference_entries msgid order + fallback extracted comments + * @param array|null $translation_entries null => POT (no msgstr, no fuzzy) + * @param array $existing_headers headers of an already existing target file + * @param array $plural_definitions msgid => definition + * @param string|null $language_name English name of the language, null => keep an existing "Language-Team" + * @param array $filled_keys keys whose value was taken from the reference language (-> fuzzy) + * @param bool $is_source_language original texts (see $source_lang_keys): a plural value copied into several forms is not fuzzy + */ +$build_catalog = static function ( + string $module, + string $lang_key, + array $reference_entries, + ?array $translation_entries, + array $existing_headers, + ?PluralForms $plural_forms, + array $plural_definitions, + ?string $language_name = null, + array $filled_keys = [], + bool $is_source_language = false +) use ($is_fuzzy_marker, $plural_forms_of, $copies_own_value): TranslationCatalog { + $is_template = $translation_entries === null; + $catalog = new TranslationCatalog(); + foreach ($existing_headers as $name => $value) { + $catalog->setHeader($name, $value); + } + if (!$is_template && $plural_forms !== null) { + $catalog->setHeader('Plural-Forms', $plural_forms->getHeader()); + } + $catalog->setHeader('MIME-Version', '1.0'); + $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8'); + $catalog->setHeader('Content-Transfer-Encoding', '8bit'); + $catalog->setHeader('X-Domain', $module); + if (!$is_template) { + $catalog->setHeader('Language', $lang_key); + } + if (!$is_template && $language_name !== null) { + $catalog->setHeader('Language-Team', $language_name); + } + + foreach ($reference_entries as $key => $ref) { + // no msgctxt, see the file docblock + $entry = new TranslationEntry(null, (string) $key); + $comment = $ref['comment']; + + $plural_definition = $plural_definitions[(string) $key] ?? null; + if ($plural_definition !== null) { + $entry->setPlural( + $plural_definition['plural_id'] ?? $key . '_plural', + $is_template ? ['', ''] : $plural_forms_of((string) $key, $plural_definition, $translation_entries, $plural_forms, $filled_keys) + ); + } + + if (!$is_template) { + $own = $translation_entries[$key] ?? null; + if ($plural_definition === null) { + $entry->translate($own['value'] ?? ''); + } + // deliberately no "original" comment (LocalChangeComments): that only exists in the per-installation overlay + $own_comment = $own['comment'] ?? null; + if (isset($filled_keys[(string) $key])) { + $entry->addFlag('fuzzy'); + } + if ($own_comment !== null && $is_fuzzy_marker($own_comment)) { + $entry->addFlag('fuzzy'); + } elseif ($own_comment !== null) { + $comment = $own_comment; + } + // the own value copied into several forms still needs a translation, except in a source language + if (!$is_source_language && $plural_definition !== null && $copies_own_value((string) $key, $plural_definition, $translation_entries, $plural_forms, $filled_keys)) { + $entry->addFlag('fuzzy'); + } + } + + if ($comment !== null && $comment !== '' && !$is_fuzzy_marker($comment)) { + $entry->addExtractedComment($comment); + } + + $catalog->add($entry); + } + + return $catalog; +}; + +/** + * Why $output_argument is no place --remove-from-lang may rely on, or null: it must be the existing + * components///lang whose component class contributes a ComponentLanguageFileDirectory for $module + * there with the file name pattern $pattern - otherwise the installation would not find the .po files and the module + * would lose its values. + * + * A text check without bootstrap: only `new ComponentLanguageFileDirectory($this, ''[, ''[, '']])` + * with string literals as positional arguments in an assignment to `$contribute[LanguageFileDirectory::class]` is + * recognised (comments ignored); any other form is refused. + */ +$removal_target_problem = static function (string $repo_root, ?string $output_argument, string $module, string $pattern) use ($printable): ?string { + if ($output_argument === null) { + return 'the outputDir argument is required - the existing lang/ directory of the component that contributes the module.'; + } + $resolved = realpath($output_argument); + if ($resolved === false || !is_dir($resolved)) { + return "outputDir '" . $printable($output_argument) . "' does not exist - the component's lang/ directory must exist before the run."; + } + $components = realpath($repo_root . '/components'); + if ( + $components === false + || preg_match('#\A' . preg_quote($components, '#') . '/([^/]+)/([^/]+)/lang\z#', $resolved, $m) !== 1 + ) { + return "outputDir '" . $printable($output_argument) . "' is not components///lang of this repository."; + } + $component_file = "$components/{$m[1]}/{$m[2]}/{$m[2]}.php"; + $source = is_file($component_file) ? file_get_contents($component_file) : false; + if ($source === false) { + return "the component class $component_file was not found."; + } + $code = ''; + foreach (token_get_all($source) as $token) { + if (!is_array($token) || !in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) { + $code .= is_array($token) ? $token[1] : $token; + } + } + $class = '(?:\\\\?(?:\w+\\\\)*)?'; + $string = static fn(int $quote, int $text): string => "(['\"])(?[^'\"]*)\\$quote"; + preg_match_all( + '/\$contribute\s*\[\s*' . $class . 'LanguageFileDirectory::class\s*\]\s*=[^;]*?new\s+' . $class + . 'ComponentLanguageFileDirectory\s*\(\s*\$this\s*,\s*' . $string(1, 1) + . '\s*(?:,\s*' . $string(3, 2) . '\s*)?(?:,\s*' . $string(5, 3) . '\s*)?,?\s*\)/', + $code, + $contributions, + PREG_SET_ORDER + ); + foreach ($contributions as $contribution) { + if ($contribution['t1'] !== $module) { + continue; + } + $path_inside = ($contribution['t2'] ?? '') === '' ? 'lang/' : $contribution['t2']; + $contributed_pattern = ($contribution['t3'] ?? '') === '' ? $module . '_%s' : $contribution['t3']; + if (trim($path_inside, '/') !== 'lang') { + return "$component_file contributes module '$module' from '" . $printable($path_inside) . "', not from lang/."; + } + if ($contributed_pattern !== $pattern) { + return "$component_file contributes module '$module' with the file name pattern '" . $printable($contributed_pattern) + . "', but the run uses '" . $printable($pattern) . "' (see --pattern)."; + } + + return null; + } + + return "$component_file contributes no ComponentLanguageFileDirectory for module '$module' (expected: " + . "\$contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory(\$this, '$module'); " + . "register the contribution first)."; +}; + +$existing_headers = static function (string $file): array { + try { + return is_file($file) ? TranslationCatalog::fromPoFile($file)->getHeaders() : []; + } catch (RuntimeException) { + return []; + } +}; + +$write = static function (string $file, string $content): void { + if (file_put_contents($file, $content) === false) { + throw new RuntimeException("Cannot write $file"); + } +}; + +// outputDir defaults to this tool's output/ scratch folder; for a module whose component already contributes +// a ComponentLanguageFileDirectory pass that component's lang/ directory, e.g. for "tos": +// php convert_module_to_po.php tos en ../../../TermsOfService/lang + +$arguments = []; +$pattern = null; +$skip_unmigratable_keys = false; +$remove_from_lang = false; +foreach (array_slice($argv, 1) as $argument) { + if (str_starts_with($argument, '--pattern=')) { + $pattern = substr($argument, strlen('--pattern=')); + continue; + } + if ($argument === '--skip-unmigratable-keys') { + $skip_unmigratable_keys = true; + continue; + } + if ($argument === '--remove-from-lang') { + $remove_from_lang = true; + continue; + } + // a mistyped option must not end up as module name or output directory + if (str_starts_with($argument, '-')) { + fwrite(STDERR, "FAILURE: unknown option '" . $printable($argument) . "' (known: --pattern=, --skip-unmigratable-keys, --remove-from-lang).\n"); + exit(1); + } + $arguments[] = $argument; +} +$module = $arguments[0] ?? 'tos'; +$reference_lang_key = $arguments[1] ?? 'en'; +// a module name is a lng_data module: it names the output directory and the files +if (preg_match('/\A[A-Za-z0-9_]+\z/', $module) !== 1 || preg_match('/\A[A-Za-z0-9_]+\z/', $reference_lang_key) !== 1) { + fwrite(STDERR, "FAILURE: module and reference language may only consist of A-Z, a-z, 0-9 and \"_\".\n"); + exit(1); +} +$pattern ??= $module . '_%s'; +try { + MigratedLanguageFilePaths::assertValidShippedFileNamePattern($pattern); +} catch (InvalidArgumentException $e) { + fwrite(STDERR, $e->getMessage() . "\n"); + exit(1); +} + +$repo_root = dirname(__DIR__, 5); + +// --remove-from-lang removes the module's only other source: first check that the .po files land where the installation reads them +if ($remove_from_lang) { + $target_problem = $removal_target_problem($repo_root, $arguments[2] ?? null, $module, $pattern); + if ($target_problem !== null) { + fwrite(STDERR, "FAILURE: --remove-from-lang: $target_problem Nothing was written.\n"); + exit(1); + } +} + +$plural_config_file = __DIR__ . '/plurals.json'; +$plural_config = ['plural_forms' => [], 'modules' => []]; +if (is_file($plural_config_file)) { + try { + $plural_config = json_decode((string) file_get_contents($plural_config_file), true, 16, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + fwrite(STDERR, "FAILURE: $plural_config_file is no valid JSON: {$e->getMessage()}\n"); + exit(1); + } +} else { + echo "NOTE: no plurals.json next to the tool - no Plural-Forms headers and no plural messages are written.\n"; +} +$plural_rules = []; +foreach ($plural_config['plural_forms'] ?? [] as $lang_key => $header) { + try { + $plural_rules[(string) $lang_key] = PluralForms::fromHeader((string) $header); + } catch (InvalidArgumentException $e) { + fwrite(STDERR, "FAILURE: invalid Plural-Forms of '$lang_key' in $plural_config_file: {$e->getMessage()}\n"); + exit(1); + } +} +/** @var array $plural_definitions */ +$plural_definitions = array_map( + static fn(mixed $definition): array => is_array($definition) ? $definition : [], + $plural_config['modules'][$module] ?? [] +); +$output_dir = isset($arguments[2]) ? rtrim($arguments[2], '/') : (__DIR__ . '/output/' . $module); +$lang_dir = $repo_root . '/lang'; + +$lang_files = glob($lang_dir . '/ilias_*.lang'); +sort($lang_files); +if ($lang_files === []) { + fwrite(STDERR, "No lang files found under $lang_dir\n"); + exit(1); +} + +$per_language = []; +// lang_key => path, hash and number of the module's lines of every file read (for --remove-from-lang) +$read_files = []; +// lang_key => identifier => whether all occurrences are the same, see $find_duplicate_keys +$duplicates = []; +$unsafe_files = []; +foreach ($lang_files as $file) { + if (preg_match('/ilias_([a-z]+)\.lang\z/', $file, $m) !== 1) { + fwrite(STDERR, "WARNING: skipping $file - no language key in its name.\n"); + continue; + } + // a symbolic link would only fail when written, after other files are changed already + if (is_link($file) || !is_file($file)) { + if ($remove_from_lang) { + $unsafe_files[] = $file; + continue; + } + if (!is_file($file)) { + fwrite(STDERR, "WARNING: skipping $file - no regular file.\n"); + continue; + } + } + $lang_key = $m[1]; + $content = $read_file($file); + $lines = $module_lines($file, $content, $module); + $per_language[$lang_key] = $entries_of($lines); + $read_files[$lang_key] = ['path' => $file, 'hash' => hash('sha256', $content), 'lines' => count($lines)]; + $duplicates[$lang_key] = $find_duplicate_keys($lines); +} +if ($unsafe_files !== []) { + fwrite(STDERR, "FAILURE: --remove-from-lang only changes regular files, these are symbolic links or no files (nothing was written):\n " + . implode("\n ", $unsafe_files) . "\n"); + exit(1); +} + +// a module removed with --remove-from-lang before (or a misspelled module name): nothing to convert, +// and the existing .po/.pot - the module's only source now - must stay as they are +if (array_sum(array_column($read_files, 'lines')) === 0) { + fwrite(STDERR, "FAILURE: module '$module' has no lines in any of the " . count($read_files) . " lang/ilias_*.lang files under $lang_dir" + . " - most likely it is migrated already (its lines removed with --remove-from-lang), or the module name is misspelled." + . " Nothing was written; existing .po/.pot files are unchanged.\n"); + exit(1); +} + +if (!isset($per_language[$reference_lang_key])) { + fwrite(STDERR, "Reference language '$reference_lang_key' not found.\n"); + exit(1); +} +$reference_keys = array_map('strval', array_keys($per_language[$reference_lang_key])); + +// fail before writing anything rather than silently keeping one of the occurrences, except on request for keys whose +// occurrences are all the same or that are left out anyway +$duplicate_report = []; +$identical_duplicate_report = []; +foreach ($duplicates as $lang_key => $keys) { + foreach ($keys as $key => $identical) { + $left_out = (string) $key === '' || str_contains((string) $key, ' [') || !in_array((string) $key, $reference_keys, true); + if ($skip_unmigratable_keys && ($identical || $left_out)) { + $identical_duplicate_report[$lang_key][] = $key; + } else { + $duplicate_report[$lang_key][] = $key; + } + } +} +if ($duplicate_report !== []) { + fwrite(STDERR, "FAILURE: module '$module' has duplicate identifiers in the source .lang files (nothing was written):\n"); + foreach ($duplicate_report as $lang_key => $duplicates) { + fwrite(STDERR, " $lang_key: " . $format_keys($duplicates) . "\n"); + } + if (!$skip_unmigratable_keys) { + fwrite(STDERR, " (--skip-unmigratable-keys keeps one occurrence of a key whose occurrences all have the same value)\n"); + } + exit(1); +} +if ($identical_duplicate_report !== []) { + fwrite(STDERR, "WARNING: module '$module' has duplicate identifiers in the source .lang files - all occurrences the same, one is used; or left out anyway, see below (--skip-unmigratable-keys):\n"); + foreach ($identical_duplicate_report as $lang_key => $duplicates) { + fwrite(STDERR, " $lang_key: " . $format_keys($duplicates) . "\n"); + } +} + +// the catalogs are built from the reference keys: a key only another language has would be dropped silently. The +// empty key (msgid "" is the PO header) and a key containing " [" (the form syntax of PluralFormKey) cannot be migrated. +$unmigratable_keys = []; +foreach ($per_language as $lang_key => $entries) { + $keys = array_map('strval', array_keys($entries)); + $unmigratable = array_values(array_diff($keys, $reference_keys)); + if (isset($entries[''])) { + $unmigratable = array_values(array_unique(['', ...$unmigratable])); + } + $form_like = array_filter($keys, static fn(string $key): bool => str_contains($key, ' [')); + $unmigratable = array_values(array_unique([...$unmigratable, ...$form_like])); + if ($unmigratable !== []) { + $unmigratable_keys[$lang_key] = $unmigratable; + } +} +if ($unmigratable_keys !== [] && !$skip_unmigratable_keys) { + fwrite(STDERR, "FAILURE: module '$module' has keys that the reference language '$reference_lang_key' does not have, the empty key, or a key containing \" [\" (the syntax of a plural form) (nothing was written):\n"); + foreach ($unmigratable_keys as $lang_key => $keys) { + fwrite(STDERR, " $lang_key: " . $format_keys($keys) . "\n"); + } + fwrite(STDERR, " (--skip-unmigratable-keys leaves these keys out instead)\n"); + exit(1); +} +if ($unmigratable_keys !== []) { + fwrite(STDERR, "WARNING: module '$module' has keys that the reference language '$reference_lang_key' does not have, the empty key, or a key containing \" [\" (the syntax of a plural form) - left out (--skip-unmigratable-keys):\n"); + foreach ($unmigratable_keys as $lang_key => $keys) { + fwrite(STDERR, " $lang_key: " . $format_keys($keys) . "\n"); + foreach ($keys as $key) { + unset($per_language[$lang_key][$key]); + } + } +} + +$reference_entries = $per_language[$reference_lang_key]; +// languages whose values are original texts, not translations: the reference language and the mandatory languages of +// AddLanguageEntry - a plural value copied into several forms is not flagged fuzzy there +$source_lang_keys = array_values(array_unique([$reference_lang_key, 'de', 'en'])); +if ($reference_entries === []) { + $languages_with_lines = array_keys(array_filter($read_files, static fn(array $read): bool => $read['lines'] > 0)); + fwrite(STDERR, "FAILURE: module '$module' has no (migratable) entries in the reference language '$reference_lang_key'," + . " but lines in " . implode(', ', $languages_with_lines) . ". Nothing was written.\n"); + exit(1); +} + +$own_entry_counts = array_map('count', $per_language); +$filled = []; +foreach ($per_language as $lang_key => $entries) { + if ($lang_key === $reference_lang_key) { + continue; + } + foreach ($reference_entries as $key => $ref) { + $key = (string) $key; + if ($ref['value'] !== '' && ($entries[$key]['value'] ?? '') === '') { + $per_language[$lang_key][$key] = ['value' => $ref['value'], 'comment' => $entries[$key]['comment'] ?? null]; + $filled[$lang_key][$key] = true; + } + } +} + +$plural_problems = []; +foreach ($plural_definitions as $key => $definition) { + if (!isset($reference_entries[$key])) { + $plural_problems[] = "plural message '$key' is no key of the module"; + } + if (isset($definition['singular']) && !isset($reference_entries[$definition['singular']])) { + $plural_problems[] = "singular key '{$definition['singular']}' of '$key' is no key of the module"; + } +} +if ($plural_definitions !== []) { + foreach (array_keys($per_language) as $lang_key) { + if (!isset($plural_rules[$lang_key])) { + $plural_problems[] = "no Plural-Forms for language '$lang_key'"; + } + } +} +if ($plural_problems !== []) { + fwrite(STDERR, "FAILURE: plurals.json does not fit module '$module' (nothing was written):\n " . implode("\n ", array_map($printable, $plural_problems)) . "\n"); + exit(1); +} + +$language_names = []; +$english_lang_file = $repo_root . '/lang/ilias_en.lang'; +if (is_file($english_lang_file)) { + foreach ($parse_module($english_lang_file, 'meta') as $key => $entry) { + $name = trim($entry['value']); + if (str_starts_with((string) $key, 'meta_l_') && $name !== '') { + $language_names[substr((string) $key, strlen('meta_l_'))] = $name; + } + } +} +// once "meta" is migrated, its shipped English .po is the source (default file name in a component's lang/ directory) +if ($language_names === []) { + foreach (glob($repo_root . '/components/*/*/lang/meta_en.po') ?: [] as $meta_po) { + try { + foreach (TranslationCatalog::fromPoFile($meta_po)->getEntries() as $entry) { + $name = trim($entry->getTranslation()); + if (in_array($entry->getContext(), [null, 'meta'], true) && str_starts_with($entry->getId(), 'meta_l_') && $name !== '') { + $language_names[substr($entry->getId(), strlen('meta_l_'))] = $name; + } + } + } catch (RuntimeException $e) { + fwrite(STDERR, "WARNING: $meta_po cannot be read for the language names: {$e->getMessage()}\n"); + } + } +} +foreach (array_keys($per_language) as $lang_key) { + if (!isset($language_names[$lang_key])) { + echo "NOTE: no meta_l_$lang_key in lang/ilias_en.lang (or a shipped meta_en.po) - the Language-Team header of '$lang_key' is left as it is.\n"; + } +} + +if (!is_dir($output_dir) && !mkdir($output_dir, 0775, true) && !is_dir($output_dir)) { + throw new RuntimeException("Cannot create $output_dir"); +} + +$pot_path = $output_dir . '/' . MigratedLanguageFilePaths::templateFileName($pattern, $module); +$write($pot_path, $build_catalog($module, '', $reference_entries, null, $existing_headers($pot_path), null, $plural_definitions)->toPoString()); + +$report = []; +foreach ($per_language as $lang_key => $entries) { + $po_path = $output_dir . '/' . sprintf($pattern, $lang_key) . '.po'; + $write( + $po_path, + $build_catalog( + $module, + $lang_key, + $reference_entries, + $entries, + $existing_headers($po_path), + $plural_rules[$lang_key] ?? null, + $plural_definitions, + $language_names[$lang_key] ?? null, + $filled[$lang_key] ?? [], + in_array($lang_key, $source_lang_keys, true) + )->toPoString() + ); + + // self-check: read the file back; every value (also one taken from the reference language, flagged fuzzy) must match and nothing else may be in it + $parsed = TranslationCatalog::fromPoFile($po_path); + $po_mismatches = []; + $expected_keys = array_unique(array_merge( + array_map('strval', array_keys($reference_entries)), + array_map('strval', array_keys($entries)) + )); + foreach ($expected_keys as $key) { + $expected = $entries[$key]['value'] ?? ''; + $parsed_entry = $parsed->find(null, $key); + // find(null, ...) also finds msgctxt "" (same library id) - only an entry without msgctxt counts + if ($parsed_entry?->getContext() !== null) { + $po_mismatches[] = $key; + continue; + } + if (isset($filled[$lang_key][$key]) && $parsed_entry?->hasFlag('fuzzy') !== true) { + $po_mismatches[] = $key . ' (not fuzzy)'; + continue; + } + $plural_definition = $plural_definitions[$key] ?? null; + if ($plural_definition === null) { + if ($parsed_entry?->isPlural() !== false || $parsed_entry->getTranslation() !== $expected) { + $po_mismatches[] = $key; + } + continue; + } + $rule = $plural_rules[$lang_key]; + $expected_forms = $plural_forms_of($key, $plural_definition, $entries, $rule, $filled[$lang_key] ?? []); + $parsed_forms = array_pad(array_slice($parsed_entry?->getPluralTranslations() ?? [], 0, $rule->getCount()), $rule->getCount(), ''); + if ( + $parsed_entry?->getPluralId() !== ($plural_definition['plural_id'] ?? $key . '_plural') + || count($expected_forms) !== $rule->getCount() + || $parsed_forms !== $expected_forms + || $rule->defaultValueOf($parsed_forms) !== $expected + ) { + $po_mismatches[] = $key . ' (plural)'; + } + } + foreach ($parsed->getEntries() as $parsed_entry) { + if ($parsed_entry->getContext() !== null || !in_array($parsed_entry->getId(), $expected_keys, true)) { + $po_mismatches[] = '(unexpected) ' . $parsed_entry->getId(); + } + } + + $fuzzy_count = 0; + foreach ($entries as $e) { + if ($e['comment'] !== null && $is_fuzzy_marker($e['comment'])) { + $fuzzy_count++; + } + } + + $report[] = [ + 'lang' => $lang_key, + 'entries' => $own_entry_counts[$lang_key], + 'fuzzy' => $fuzzy_count, + 'filled' => count($filled[$lang_key] ?? []), + 'po_ok' => $po_mismatches === [], + 'po_mismatches' => $po_mismatches, + ]; +} + +// entries: the language's own lines of the module; fuzzy: of those, marked "new variable"; +// filled: reference keys the language lacks or has empty, written with the reference value as fuzzy +printf("%-6s %8s %8s %8s %8s\n", 'lang', 'entries', 'fuzzy', 'filled', 'po_ok'); +$all_ok = true; +foreach ($report as $row) { + printf("%-6s %8d %8d %8d %8s\n", $row['lang'], $row['entries'], $row['fuzzy'], $row['filled'], $row['po_ok'] ? 'yes' : 'NO'); + if (!$row['po_ok']) { + $all_ok = false; + fwrite(STDERR, " po_mismatches: " . $printable(implode(', ', $row['po_mismatches'])) . "\n"); + } +} + +echo "\n"; +echo $all_ok + ? "OK: all " . count($report) . " languages round-trip identically through PO (" + . array_sum(array_column($report, 'filled')) . " entries filled from '$reference_lang_key', flagged fuzzy).\n" + : "FAILURE: at least one language did not round-trip correctly, see stderr above.\n"; + +if (!$all_ok || !$remove_from_lang) { + if (!$all_ok && $remove_from_lang) { + fwrite(STDERR, "--remove-from-lang: no .lang file was changed.\n"); + } + exit($all_ok ? 0 : 1); +} + +// --remove-from-lang: the module's .po files become its only source; all new contents are computed and checked first +$new_lang_contents = []; +$removal_problems = []; +foreach ($read_files as $lang_key => $read) { + $content = file_get_contents($read['path']); + if ($content === false || hash('sha256', $content) !== $read['hash']) { + $removal_problems[] = "{$read['path']} could not be read again or changed during the run"; + continue; + } + [$new_content, $removed] = $without_module_lines($content, $module); + // e.g. a line of the module above "" - read, but never removed + if ($removed !== $read['lines']) { + $removal_problems[] = "{$read['path']}: {$read['lines']} line(s) of the module were read, but $removed would be removed"; + continue; + } + if ($removed > 0) { + $new_lang_contents[$lang_key] = [$new_content, $removed]; + } +} +if ($removal_problems !== []) { + fwrite(STDERR, "FAILURE: --remove-from-lang: no .lang file was changed (the .po/.pot files are written):\n " . implode("\n ", $removal_problems) . "\n"); + exit(1); +} + +echo "\n"; +$removed_total = 0; +foreach ($new_lang_contents as $lang_key => [$new_content, $removed]) { + $path = $read_files[$lang_key]['path']; + try { + AtomicFileWriter::write($path, $new_content, $lang_dir); + } catch (RuntimeException $e) { + fwrite(STDERR, "FAILURE: --remove-from-lang: {$e->getMessage()} - the files listed above are changed already, the others not.\n"); + exit(1); + } + $removed_total += $removed; + printf("removed %5d line(s) of '%s' from %s\n", $removed, $module, 'lang/' . basename($path)); +} +echo "OK: removed $removed_total line(s) of module '$module' from " . count($new_lang_contents) . " .lang file(s).\n"; + +exit(0); diff --git a/components/ILIAS/Language/tools/po-migration/plurals.json b/components/ILIAS/Language/tools/po-migration/plurals.json new file mode 100644 index 000000000000..2a5ae5e1e87e --- /dev/null +++ b/components/ILIAS/Language/tools/po-migration/plurals.json @@ -0,0 +1,44 @@ +{ + "_comment": "Input of convert_module_to_po.php, see README.md (\"plurals.json\"). plural_forms: the Plural-Forms header written into every generated .po of the language. Source: CLDR 48.2 (release-48-2, 2026-03-17), integer categories; checked for all 31 languages against n=0..2000 and large values (31/31 OK). CLDR main (49, unreleased) changes vi to 2 forms (nplurals=2; plural=n > 1) - to be followed with release 49. Changing a rule is safe for installations: local form values in existing overlays are taken over by meaning, not by index (see README.md, \"Changing a rule\"). modules: per module the plural messages - msgid => {\"singular\": key whose value - only where the language translated it - becomes the form the rule of the language selects for n = 1 and no other n; every other form gets the message's own value (optional), \"plural_id\": msgid_plural (optional, default _plural)}.", + "plural_forms": { + "ar": "nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));", + "bg": "nplurals=2; plural=n != 1;", + "cs": "nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);", + "da": "nplurals=2; plural=n != 1;", + "de": "nplurals=2; plural=n != 1;", + "el": "nplurals=2; plural=n != 1;", + "en": "nplurals=2; plural=n != 1;", + "es": "nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);", + "et": "nplurals=2; plural=n != 1;", + "fa": "nplurals=2; plural=n > 1;", + "fr": "nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);", + "hr": "nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);", + "hu": "nplurals=2; plural=n != 1;", + "it": "nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);", + "ja": "nplurals=1; plural=0;", + "ka": "nplurals=2; plural=n != 1;", + "lt": "nplurals=3; plural=(n % 10 == 1 && (n % 100 < 11 || n % 100 > 19)) ? 0 : ((n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? 1 : 2);", + "nl": "nplurals=2; plural=n != 1;", + "pl": "nplurals=3; plural=(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);", + "pt": "nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);", + "ro": "nplurals=3; plural=(n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2);", + "ru": "nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);", + "sk": "nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);", + "sl": "nplurals=4; plural=(n % 100 == 1) ? 0 : ((n % 100 == 2) ? 1 : ((n % 100 == 3 || n % 100 == 4) ? 2 : 3));", + "sq": "nplurals=2; plural=n != 1;", + "sr": "nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);", + "sv": "nplurals=2; plural=n != 1;", + "tr": "nplurals=2; plural=n != 1;", + "uk": "nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);", + "vi": "nplurals=1; plural=0;", + "zh": "nplurals=1; plural=0;" + }, + "modules": { + "poll": { + "poll_population": { + "singular": "poll_population_singular" + }, + "poll_vote_error_multi": {} + } + } +} diff --git a/components/ILIAS/Language/tools/po-migration/scan_shipped_case_duplicates.php b/components/ILIAS/Language/tools/po-migration/scan_shipped_case_duplicates.php new file mode 100644 index 000000000000..ff1e61e6f8ef --- /dev/null +++ b/components/ILIAS/Language/tools/po-migration/scan_shipped_case_duplicates.php @@ -0,0 +1,111 @@ +')`/`txt("")`). + * + * Such a pair collides in lng_data, whose primary key (module, identifier, lang_key) compares case-insensitively + * (utf8mb3_general_ci): the database holds one row, PHP two keys, see README.md. The collation also folds accents, + * so identifiers are compared lower-cased and transliterated to ASCII (iconv, if available) - an approximation. + * + * Usage (ILIAS root): php components/ILIAS/Language/tools/po-migration/scan_shipped_case_duplicates.php + * + * Declares no named classes or functions, to stay out of Composer's classmap (as convert_module_to_po.php). Read-only. + */ + +$ilias_root = dirname(__DIR__, 5); + +// lower case and, where iconv can, without accents - roughly how utf8mb3_general_ci compares +$fold = static function (string $text): string { + $lower = mb_strtolower($text); + $ascii = function_exists('iconv') ? @iconv('UTF-8', 'ASCII//TRANSLIT', $lower) : false; + + return is_string($ascii) && $ascii !== '' ? strtolower($ascii) : $lower; +}; + +// folded module#:#identifier => spelling => file => line; only spellings within the same file collide +$occurrences = []; +$files = glob($ilias_root . '/lang/ilias_*.lang') ?: []; +sort($files); +foreach ($files as $file) { + $started = false; + foreach (file($file, FILE_IGNORE_NEW_LINES) ?: [] as $number => $line) { + if (!$started) { + $started = trim($line) === ''; + continue; + } + $parts = explode('#:#', trim($line)); + if (count($parts) < 3) { + continue; + } + $folded = $fold($parts[0]) . '#:#' . $fold($parts[1]); + $occurrences[$folded][$parts[1]][basename($file)] ??= $number + 1; + } +} +$duplicates = []; +foreach ($occurrences as $folded => $spellings) { + $files_per_spelling = array_map('array_keys', $spellings); + $colliding_files = count($spellings) > 1 ? array_values(array_intersect(...array_values($files_per_spelling))) : []; + if ($colliding_files !== []) { + $duplicates[$folded] = [$spellings, $colliding_files]; + } +} +ksort($duplicates); + +$callers_of = static function (string $identifier) use ($ilias_root): array { + $callers = []; + foreach (["txt('" . $identifier . "'", 'txt("' . $identifier . '"'] as $pattern) { + $output = []; + exec(sprintf( + 'git -C %s grep -n -F %s -- %s 2>/dev/null', + escapeshellarg($ilias_root), + escapeshellarg($pattern), + escapeshellarg('components') + ), $output); + foreach ($output as $hit) { + $callers[] = implode(':', array_slice(explode(':', $hit), 0, 2)); + } + } + + return array_values(array_unique($callers)); +}; + +echo sprintf("%d Identifier mit Varianten, die sich in derselben Sprachdatei nur in Groß-/Kleinschreibung unterscheiden\n\n", count($duplicates)); +echo "| Modul | Varianten | Betroffene Dateien | Fundstellen (Datei:Zeile) | Aufrufer (`txt('…')`) |\n|---|---|---|---|---|\n"; +foreach ($duplicates as $folded => [$spellings, $colliding_files]) { + $module = explode('#:#', $folded)[0]; + $variants = []; + $places = []; + $callers = []; + $example = $colliding_files[0]; + foreach ($spellings as $spelling => $lines) { + $variants[] = '`' . $spelling . '`'; + $places[] = '`' . $spelling . '`: lang/' . $example . ':' . $lines[$example]; + $found = $callers_of((string) $spelling); + $callers[] = '`' . $spelling . '`: ' . ($found === [] ? 'keine' : implode(', ', array_slice($found, 0, 5)) . (count($found) > 5 ? sprintf(' … (+%d)', count($found) - 5) : '')); + } + $affected = count($colliding_files) >= 25 + ? 'fast alle (' . count($colliding_files) . ')' + : implode(', ', array_map(static fn(string $f): string => substr($f, 6, -5), $colliding_files)); + echo sprintf("| `%s` | %s | %s | %s | %s |\n", $module, implode(', ', $variants), $affected, implode('
', $places), implode('
', $callers)); +} diff --git a/components/ILIAS/Language/tools/po-migration/scan_shipped_cross_module_duplicates.php b/components/ILIAS/Language/tools/po-migration/scan_shipped_cross_module_duplicates.php new file mode 100644 index 000000000000..a783637d75dd --- /dev/null +++ b/components/ILIAS/Language/tools/po-migration/scan_shipped_cross_module_duplicates.php @@ -0,0 +1,177 @@ +')`/`txt("")`). + * + * txt($topic) knows no module: the module loaded last wins, for the legacy and the migrated (.po) path alike, see + * README.md. Identifiers are compared exactly; values without the `###` comment, trimmed; empty values are ignored. + * + * Usage (ILIAS root): php components/ILIAS/Language/tools/po-migration/scan_shipped_cross_module_duplicates.php + * + * Declares no named classes or functions, to stay out of Composer's classmap (as convert_module_to_po.php). Read-only. + */ + +$ilias_root = dirname(__DIR__, 5); + +// language => module => identifier => value +$values = []; +$files = glob($ilias_root . '/lang/ilias_*.lang') ?: []; +sort($files); +foreach ($files as $file) { + $language = substr(basename($file), 6, -5); + $started = false; + foreach (file($file, FILE_IGNORE_NEW_LINES) ?: [] as $line) { + if (!$started) { + $started = trim($line) === ''; + continue; + } + $parts = explode('#:#', trim($line)); + if (count($parts) < 3) { + continue; + } + $values[$language][$parts[0]][$parts[1]] = trim(explode('###', $parts[2])[0]); + } +} + +// identifier => module => true +$modules_of = []; +foreach ($values as $modules) { + foreach ($modules as $module => $identifiers) { + foreach (array_keys($identifiers) as $identifier) { + $modules_of[(string) $identifier][$module] = true; + } + } +} +$duplicates = array_filter($modules_of, static fn(array $modules): bool => count($modules) > 1); +ksort($duplicates, SORT_STRING); + +// modules shipped as .po (the .pot is named after the module with the default file name pattern) +$migrated = array_map( + static fn(string $pot): string => basename($pot, '.pot'), + glob($ilias_root . '/components/*/*/lang/*.pot') ?: [] +); + +// same text apart from case, whitespace and punctuation ("Activity-ID" = "Activity ID") +$normalise = static fn(string $value): string => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $value)); + +$callers_of = static function (string $identifier) use ($ilias_root): int { + $callers = []; + foreach (["txt('" . $identifier . "'", 'txt("' . $identifier . '"'] as $pattern) { + $output = []; + exec(sprintf( + 'git -C %s grep -n -F %s -- %s 2>/dev/null', + escapeshellarg($ilias_root), + escapeshellarg($pattern), + escapeshellarg('components') + ), $output); + array_push($callers, ...$output); + } + + return count(array_unique($callers)); +}; + +$kinds = [ + 'meaning' => 'Bedeutung (en)', + 'cosmetic' => 'kosmetisch (en)', + 'translation' => 'nur Übersetzung', + 'same' => 'gleich', +]; +$rows = []; +$count_per_kind = array_fill_keys(array_keys($kinds), 0); +$count_per_combination = []; +foreach ($duplicates as $identifier => $modules) { + $modules = array_keys($modules); + sort($modules); + $differing = []; + foreach ($values as $language => $per_module) { + $texts = []; + foreach ($modules as $module) { + $text = $per_module[$module][$identifier] ?? ''; + if ($text !== '') { + $texts[] = $text; + } + } + if (count(array_unique($texts)) > 1) { + $differing[] = $language; + } + } + $english = []; + foreach ($modules as $module) { + $text = $values['en'][$module][$identifier] ?? ''; + if ($text !== '') { + $english[$module] = $text; + } + } + $kind = match (true) { + $differing === [] => 'same', + !in_array('en', $differing, true) => 'translation', + count(array_unique(array_map($normalise, $english))) <= 1 => 'cosmetic', + default => 'meaning', + }; + $count_per_kind[$kind]++; + $combination = implode(' + ', $modules); + $count_per_combination[$combination] = ($count_per_combination[$combination] ?? 0) + 1; + $rows[] = [(string) $identifier, $modules, $kind, $differing, $english]; +} +arsort($count_per_combination); + +$code = static fn(string $text): string => '`' . $text . '`'; +$cell = static fn(string $text): string => str_replace(['|', "\n"], ['\|', ' '], $text); + +echo sprintf("%d Identifier in mehr als einem Modul (von %d)\n\n", count($duplicates), count($modules_of)); +echo "| Art | Anzahl |\n|---|---|\n"; +foreach ($kinds as $kind => $label) { + echo sprintf("| %s | %d |\n", $label, $count_per_kind[$kind]); +} +echo "\nHäufigste Modulkombinationen (ab 3 Identifiern)\n\n| Module | Anzahl |\n|---|---|\n"; +foreach ($count_per_combination as $combination => $count) { + if ($count >= 3) { + echo sprintf("| %s | %d |\n", implode(' + ', array_map($code, explode(' + ', $combination))), $count); + } +} +echo "\n| Identifier | Module | Art | Abweichende Sprachen | en-Werte | Aufrufer (`txt('…')`) |\n|---|---|---|---|---|---|\n"; +foreach ($rows as [$identifier, $modules, $kind, $differing, $english]) { + $module_cells = array_map( + static fn(string $module): string => $code($module) . (in_array($module, $migrated, true) ? ' (po)' : ''), + $modules + ); + $english_cells = []; + foreach ($english as $module => $text) { + $english_cells[] = $code($module) . ': ' . $cell($text); + } + $languages = match (true) { + $differing === [] => '–', + count($differing) >= 25 => 'fast alle (' . count($differing) . ')', + default => implode(', ', $differing), + }; + echo sprintf( + "| %s | %s | %s | %s | %s | %d |\n", + $code($identifier), + implode(', ', $module_cells), + $kinds[$kind], + $languages, + implode('
', $english_cells), + $callers_of($identifier) + ); +} diff --git a/components/ILIAS/Language/tools/po-migration/scan_shipped_markup.php b/components/ILIAS/Language/tools/po-migration/scan_shipped_markup.php new file mode 100644 index 000000000000..6b3e537fdaa1 --- /dev/null +++ b/components/ILIAS/Language/tools/po-migration/scan_shipped_markup.php @@ -0,0 +1,73 @@ +'; + continue; + } + $parts = explode('#:#', $line); + if (count($parts) < 3) { + continue; + } + $violations = $policy->findViolations(explode('###', $parts[2])[0]); + if ($violations === []) { + continue; + } + $total++; + $key = $parts[0] . '#:#' . $parts[1]; + $by_key[$key]['langs'][] = $lang; + foreach ($violations as $violation) { + $by_key[$key]['violations'][$violation] = true; + } + } +} +ksort($by_key); + +echo sprintf("%d Werte in %d Keys\n\n", $total, count($by_key)); +echo "| Key | Sprachen | Verstoß |\n|---|---|---|\n"; +foreach ($by_key as $key => $data) { + $langs = count($data['langs']) >= 25 + ? 'fast alle (' . count($data['langs']) . ')' + : implode(', ', $data['langs']); + $violations = implode('; ', array_map( + static fn(string $violation): string => str_replace('|', '\\|', $violation), + array_keys($data['violations']) + )); + echo sprintf("| `%s` | %s | %s |\n", $key, $langs, htmlspecialchars($violations, ENT_NOQUOTES)); +} From 04833cd833774359852da082acc42ffc470743b0 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:23:39 +0200 Subject: [PATCH 034/103] TermsOfService: Serve the translations from PO files TermsOfService contributes its lang directory and ships the tos module as generated .pot/.po files. The Language tests that read these shipped files are added with them. --- .../Catalog/TranslationCatalogPoTest.php | 31 + .../tests/ConvertModuleToPoToolTest.php | 21 + .../PoMigrationLoadLanguageModuleTest.php | 542 ++++++++++++++++++ .../ILIAS/TermsOfService/TermsOfService.php | 8 +- components/ILIAS/TermsOfService/lang/tos.pot | 57 ++ .../ILIAS/TermsOfService/lang/tos_ar.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_bg.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_cs.po | 66 +++ .../ILIAS/TermsOfService/lang/tos_da.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_de.po | 60 ++ .../ILIAS/TermsOfService/lang/tos_el.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_en.po | 60 ++ .../ILIAS/TermsOfService/lang/tos_es.po | 60 ++ .../ILIAS/TermsOfService/lang/tos_et.po | 70 +++ .../ILIAS/TermsOfService/lang/tos_fa.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_fr.po | 65 +++ .../ILIAS/TermsOfService/lang/tos_hr.po | 66 +++ .../ILIAS/TermsOfService/lang/tos_hu.po | 60 ++ .../ILIAS/TermsOfService/lang/tos_it.po | 65 +++ .../ILIAS/TermsOfService/lang/tos_ja.po | 60 ++ .../ILIAS/TermsOfService/lang/tos_ka.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_lt.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_nl.po | 70 +++ .../ILIAS/TermsOfService/lang/tos_pl.po | 66 +++ .../ILIAS/TermsOfService/lang/tos_pt.po | 66 +++ .../ILIAS/TermsOfService/lang/tos_ro.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_ru.po | 70 +++ .../ILIAS/TermsOfService/lang/tos_sk.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_sl.po | 66 +++ .../ILIAS/TermsOfService/lang/tos_sq.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_sr.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_sv.po | 65 +++ .../ILIAS/TermsOfService/lang/tos_tr.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_uk.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_vi.po | 77 +++ .../ILIAS/TermsOfService/lang/tos_zh.po | 77 +++ 36 files changed, 2848 insertions(+), 1 deletion(-) create mode 100644 components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php create mode 100644 components/ILIAS/TermsOfService/lang/tos.pot create mode 100644 components/ILIAS/TermsOfService/lang/tos_ar.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_bg.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_cs.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_da.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_de.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_el.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_en.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_es.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_et.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_fa.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_fr.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_hr.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_hu.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_it.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_ja.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_ka.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_lt.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_nl.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_pl.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_pt.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_ro.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_ru.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_sk.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_sl.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_sq.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_sr.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_sv.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_tr.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_uk.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_vi.po create mode 100644 components/ILIAS/TermsOfService/lang/tos_zh.po diff --git a/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php index 49ea280f7221..101796d4baea 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogPoTest.php @@ -36,6 +36,8 @@ */ class TranslationCatalogPoTest extends TestCase { + private const string TOS_LANG_DIRECTORY = __DIR__ . '/../../../../TermsOfService/lang'; + private ?string $directory = null; protected function tearDown(): void @@ -492,6 +494,35 @@ public function testTheWriterLayoutMatchesTheShippedFiles(): void ); } + /** + * Property: every shipped `.po`/`.pot` of the migrated tos module is read by fromPoFile() and + * re-serialized byte-identically (convert_module_to_po.php and the overlay rely on that), and + * its `.mo` serves exactly the non-empty translations of the `.po`. + */ + public function testEveryShippedTosFileRoundTripsByteIdentically(): void + { + $files = glob(self::TOS_LANG_DIRECTORY . '/*.{po,pot}', GLOB_BRACE) ?: []; + $this->assertNotSame([], $files, 'the shipped tos files were not found'); + + foreach ($files as $file) { + $catalog = TranslationCatalog::fromPoFile($file); + $this->assertSame(file_get_contents($file), $catalog->toPoString(), basename($file)); + + $expected = []; + foreach ($catalog->getEntries() as $entry) { + // written without msgctxt by convert_module_to_po.php (the module is the directory's) + $this->assertNull($entry->getContext(), basename($file)); + if ($entry->getTranslation() !== '') { + $expected[$entry->getId()] = $entry->getTranslation(); + } + } + $actual = \MigratedPoFixture::readMoTranslations($catalog->toMoString()); + ksort($expected, SORT_STRING); + ksort($actual, SORT_STRING); + $this->assertSame($expected, $actual, basename($file)); + } + } + public function testReadsMultilineStringsCrLfLineEndingsAndABom(): void { $po = "\xEF\xBB\xBFmsgid \"\"\r\nmsgstr \"\"\r\n\"Language: de\\n\"\r\n\r\n" diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php index 082440acb129..f548a02ada37 100644 --- a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -28,6 +28,7 @@ class ConvertModuleToPoToolTest extends TestCase { private const string TOOL = __DIR__ . '/../tools/po-migration/convert_module_to_po.php'; + private const string SHIPPED_DIRECTORY = __DIR__ . '/../../TermsOfService/lang'; private string $directory; @@ -119,6 +120,26 @@ private static function files(string $directory): array return $files; } + /** The tool keeps the headers of an existing target file, so it runs over a copy of the shipped files. */ + public function testReconvertingTosReproducesTheShippedFilesByteForByte(): void + { + $shipped = self::files(self::SHIPPED_DIRECTORY); + $this->assertNotSame([], $shipped, 'the shipped tos files were not found'); + foreach ($shipped as $name => $content) { + file_put_contents($this->directory . '/out/' . $name, $content); + } + + [$exit_code, $stdout, $stderr] = $this->runTool('tos', 'en', $this->directory . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertSame('', $stderr, 'no warnings, notices or deprecations'); + $this->assertStringContainsString('round-trip identically through PO', $stdout); + $this->assertSame(array_keys($shipped), array_keys(self::files($this->directory . '/out')), 'no file added or lost'); + foreach (self::files($this->directory . '/out') as $name => $content) { + $this->assertSame($shipped[$name], $content, $name); + } + } + /** Every language's keys must be a subset of the reference language's, else the run fails before writing. */ public function testFailsWithExitOneAndWritesNothingWhenALanguageHasAKeyMissingFromTheReferenceLanguage(): void { diff --git a/components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php b/components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php new file mode 100644 index 000000000000..52cff0c96cd4 --- /dev/null +++ b/components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php @@ -0,0 +1,542 @@ +fixture_directory) && is_dir($this->fixture_directory)) { + array_map('unlink', glob($this->fixture_directory . '/*') ?: []); + rmdir($this->fixture_directory); + } + if (isset($this->fixture_directory)) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . basename($this->fixture_directory)); + } + + // remove only a .mo this test compiled, never an existing one (e.g. a local installation of 'de') + if ($this->created_real_tos_mo !== null && is_file($this->created_real_tos_mo)) { + unlink($this->created_real_tos_mo); + $overlay_dir = dirname($this->created_real_tos_mo); + if (is_dir($overlay_dir) && glob($overlay_dir . '/*') === []) { + rmdir($overlay_dir); + } + } + $this->created_real_tos_mo = null; + + // never remove a foreign CLIENT_DATA_DIR + if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + private ?string $fixture_directory = null; + private ?string $created_real_tos_mo = null; + /** True if this test defined CLIENT_DATA_DIR itself; tearDown() removes only such a root. */ + private bool $created_client_data_dir_root = false; + + /** + * Compiles tos_de.mo at the overlay location under CLIENT_DATA_DIR, as an installation of 'de' would (only .pot/.po + * are shipped). Cleaned up in tearDown(). + */ + private function ensureRealTosDeMoFileExists(): void + { + $created = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + $this->created_client_data_dir_root = $this->created_client_data_dir_root || $created; + } + + /** A migrated module with test-local .mo content (not tos's real keys), built at the overlay location. Cleaned up in tearDown(). */ + private function contributeFixtureModule(string $module, \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog $translations): LanguageFileDirectory + { + $created = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + $this->created_client_data_dir_root = $this->created_client_data_dir_root || $created; + $this->fixture_directory ??= __DIR__ . '/tmp-fixtures-' . bin2hex(random_bytes(4)); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + MigratedPoFixture::writeShippedPo($relative_path, $module, 'de', $translations); + + return new class ($module, $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + } + + /** contributeFixtureModule() with independent shipped `.po` and overlay `.mo` content, to prove the array_replace() merge. */ + private function contributeFixtureModuleWithDistinctShippedAndOverlay( + string $module, + \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog $shipped, + \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog $overlay + ): LanguageFileDirectory { + $created = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + $this->created_client_data_dir_root = $this->created_client_data_dir_root || $created; + $this->fixture_directory ??= __DIR__ . '/tmp-fixtures-' . bin2hex(random_bytes(4)); + + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + MigratedPoFixture::writePair(rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . $module . '/de/' . $module . '_de', $overlay); + MigratedPoFixture::writeShippedPo($relative_path, $module, 'de', $shipped); + + return new class ($module, $relative_path) implements LanguageFileDirectory { + public function __construct(private string $prefix, private string $path) + { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + return $this->path; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + }; + } + + /** Overlay-only keys are added, shipped-only keys kept, keys of both won by the overlay. */ + public function testMergesTheOverlayOntoTheShippedStateKeepingKeysUniqueToEitherSide(): void + { + $shipped = MigratedPoFixture::catalog('merge', [ + 'only_shipped' => 'Nur im Shipped-Stand', + 'both' => 'Shipped-Wert', + ]); + $overlay = MigratedPoFixture::catalog('merge', [ + 'only_overlay' => 'Nur im Overlay', + 'both' => 'Lokal geändert', + ]); + $directory = $this->contributeFixtureModuleWithDistinctShippedAndOverlay('merge', $shipped, $overlay); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + $this->registerDirectoryManager($directory); + + $result = $this->callLoadFromMigratedLanguageFile('merge', 'de'); + + $this->assertSame('Nur im Shipped-Stand', $result['only_shipped'] ?? null); + $this->assertSame('Lokal geändert', $result['both'] ?? null); + $this->assertSame('Nur im Overlay', $result['only_overlay'] ?? null); + $this->assertCount(3, $result); + } + + private function buildLanguageWithoutRunningConstructor(string $lang_key, ?string $lang_default = null): ilLanguage + { + global $DIC; + if ($DIC->offsetExists(LanguageFileDirectoryManager::class)) { + MigratedPoFixture::build($DIC[LanguageFileDirectoryManager::class], (string) ILIAS_ABSOLUTE_PATH); + } + $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $reflected = new ReflectionObject($language); + $reflected->getProperty('lang_key')->setValue($language, $lang_key); + $reflected->getProperty('lang_user')->setValue($language, $lang_key); + $reflected->getProperty('lang_default')->setValue($language, $lang_default ?? $lang_key); + + return $language; + } + + private function registerDirectoryManager(LanguageFileDirectory ...$contributed): void + { + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + ...$contributed + ) + ); + } + + /** A match in _lookupEntry() reaches ilLanguage::isUsageLogEnabled(), which reads $DIC->clientIni() and ->database(). */ + private function stubUsageLogDependencies(): void + { + $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + } + + /** What the runtime serves for $module/$lang_key, see MigratedPoFixture::servedTexts(). */ + private function callLoadFromMigratedLanguageFile(string $module, string $lang_key): ?array + { + return MigratedPoFixture::servedTexts($module, $lang_key); + } + + public function testFullyIntegratedThroughTheLoadLanguageModuleThatCallersActuallyUse(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + $language = $this->buildLanguageWithoutRunningConstructor('de'); + $language->loadLanguageModule('tos'); + + $this->assertContains('tos', $language->loaded_modules); + $this->assertSame('Nutzungsvereinbarung', $language->txt('tos_agreement')); + $this->assertSame( + 'Nutzungsvereinbarung zugestimmt am', + $language->txt('tos_agree_date') + ); + } + + public function testReadsAllSeventeenKnownTosKeysForTheReferenceLanguage(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + $language = $this->buildLanguageWithoutRunningConstructor('de'); + $text = $this->callLoadFromMigratedLanguageFile('tos', 'de'); + + $this->assertIsArray($text); + $this->assertCount(17, $text); + $this->assertArrayHasKey('tos_withdrawal_usr_deletion_desc', $text); + } + + public function testFallsBackToNullWhenNoComponentContributesThatPrefix(): void + { + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + $this->assertNull($this->callLoadFromMigratedLanguageFile('common', 'de')); + } + + /** "Migrated for a language" means a shipped `.po` exists: its absence makes this null, not a missing overlay `.mo`. */ + public function testFallsBackToNullWhenDirectoryIsContributedButHasNoShippedPoForThisLanguage(): void + { + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + $this->assertNull($this->callLoadFromMigratedLanguageFile('tos', 'xx')); + } + + public function testFallsBackToNullWhenNoDirectoryManagerIsRegisteredAtAll(): void + { + $this->assertNull($this->callLoadFromMigratedLanguageFile('tos', 'de')); + } + + /** + * An invalid $lang_key (path traversal included) makes MigratedLanguageFilePaths throw \InvalidArgumentException, + * which is caught: it is never migrated. It arrives untrusted via the public _lookupEntry(). + * + * @param non-empty-string $invalid_lang_key + */ + #[DataProvider('invalidLangKeys')] + public function testFallsBackToNullForAnInvalidLangKeyInsteadOfThrowing(string $invalid_lang_key): void + { + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + $this->assertNull($this->callLoadFromMigratedLanguageFile('tos', $invalid_lang_key)); + } + + /** + * _lookupEntry() must fall through to the lng_data lookup for an invalid $a_lang_key, not throw. + * + * @param non-empty-string $invalid_lang_key + */ + #[DataProvider('invalidLangKeys')] + public function testLookupEntryFallsBackToTheDatabaseForAnInvalidLangKey(string $invalid_lang_key): void + { + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + $this->stubUsageLogDependencies(); + $statement = $this->createStub(ilDBStatement::class); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . (string) $value . "'"); + $db->method('query')->willReturn($statement); + $db->method('fetchAssoc')->willReturn(['value' => 'from the database']); + $this->setGlobalVariable('ilDB', $db); + + $this->assertSame( + 'from the database', + ilLanguage::_lookupEntry($invalid_lang_key, 'tos', 'sometopic') + ); + } + + /** @return array */ + public static function invalidLangKeys(): array + { + return [ + 'path traversal' => ['../x'], + 'uppercase' => ['DE'], + 'locale-style with region' => ['de_DE'], + 'empty' => [''], + ]; + } + + /** A cross-language read (txtlng()) of a migrated module's topic also comes from the build, via _lookupEntry(). */ + public function testTxtlngReadsTheMigratedMoFileForALanguageOtherThanTheCurrentSessionLanguage(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + // the session language "en" differs from the requested "de" to force the cross-language branch + $language = $this->buildLanguageWithoutRunningConstructor('en'); + + $this->assertSame('Nutzungsvereinbarung', $language->txtlng('tos', 'tos_agreement', 'de')); + } + + /** txt()'s fallback-module branch also goes through _lookupEntry(); lang_key === lang_default takes the "default language" branch. */ + public function testTxtsFallbackModuleParameterReadsTheMigratedMoFileTooWhenTheTopicIsNotAlreadyLoaded(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + $language = $this->buildLanguageWithoutRunningConstructor('de'); + + $this->assertSame('Nutzungsvereinbarung', $language->txt('tos_agreement', 'tos')); + } + + /** + * The build is checked before $cached_modules: ilCachedLanguage is always active and a migrated module keeps its + * lng_modules row (dual-write), so the old order never read the build in production. + */ + public function testTheMigratedMoFileWinsOverStaleCachedModulesFromIlCachedLanguage(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + $this->registerDirectoryManager( + new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') + ); + + $language = $this->buildLanguageWithoutRunningConstructor('de'); + (new ReflectionObject($language))->getProperty('cached_modules')->setValue( + $language, + ['tos' => ['tos_agreement' => 'STALE PRE-MIGRATION TEXT FROM lng_modules CACHE']] + ); + + $language->loadLanguageModule('tos'); + + $this->assertSame('Nutzungsvereinbarung', $language->txt('tos_agreement')); + } + + /** A non-migrated module is still served from cached_modules. */ + public function testFallsBackToCachedModulesForANonMigratedModule(): void + { + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + + $language = $this->buildLanguageWithoutRunningConstructor('de'); + (new ReflectionObject($language))->getProperty('cached_modules')->setValue( + $language, + ['not_migrated' => ['some_topic' => 'From the whole-language DB cache']] + ); + + $language->loadLanguageModule('not_migrated'); + + $this->assertSame('From the whole-language DB cache', $language->txt('some_topic')); + } + + + + + + private static function truncate(string $strategy, string $mo): string + { + return match ($strategy) { + 'first_10_bytes' => substr($mo, 0, 10), + 'first_40_bytes' => substr($mo, 0, 40), + 'first_half' => substr($mo, 0, intdiv(strlen($mo), 2)), + 'all_but_last_3_bytes' => substr($mo, 0, -3), + 'empty' => '', + default => throw new \LogicException('Unknown truncation strategy "' . $strategy . '".'), + }; + } + + /** + * Mirrors what ilLanguage's constructor stores, without constructing one (which pulls in unrelated $DIC services). + * + * @param list $lang_keys + */ + private function setInstalledLanguages(array $lang_keys): void + { + (new ReflectionProperty(ilLanguage::class, 'installed_languages'))->setValue(null, $lang_keys); + } + + public function testMigratedStateIsServedForAnInstalledLanguage(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->registerDirectoryManager(new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos')); + $this->setInstalledLanguages(['de', 'en']); + + $this->assertSame( + 'Nutzungsvereinbarung', + $this->callLoadFromMigratedLanguageFile('tos', 'de')['tos_agreement'] + ); + } + + /** A language not in the installed-languages list falls back to lng_modules, even if its shipped `.po` and overlay are readable. */ + public function testMigratedStateFallsBackToNullForALanguageNotInTheInstalledLanguagesList(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->registerDirectoryManager(new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos')); + $this->setInstalledLanguages(['en', 'fr']); + + $this->assertNull($this->callLoadFromMigratedLanguageFile('tos', 'de')); + } + + /** The restriction applies through _lookupEntry() too: a not-installed language falls through to the database. */ + public function testTxtlngFallsBackToTheDatabaseForALanguageNotInTheInstalledLanguagesList(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->registerDirectoryManager(new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos')); + $this->setInstalledLanguages(['en']); + $this->stubUsageLogDependencies(); + $statement = $this->createStub(ilDBStatement::class); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . (string) $value . "'"); + $db->method('query')->willReturn($statement); + $db->method('fetchAssoc')->willReturn(['value' => 'from the database']); + $this->setGlobalVariable('ilDB', $db); + + $language = $this->buildLanguageWithoutRunningConstructor('en'); + + $this->assertSame('from the database', $language->txtlng('tos', 'tos_agreement', 'de')); + } + + /** forgetInstalledLanguage() takes effect for the rest of the request: it also drops what was cached for the language. */ + public function testForgetInstalledLanguageStopsServingTheMigratedStateWithinTheSameRequest(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->registerDirectoryManager(new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos')); + $this->setInstalledLanguages(['de']); + $before = $this->callLoadFromMigratedLanguageFile('tos', 'de'); + $this->assertSame('Nutzungsvereinbarung', $before['tos_agreement']); + + ilLanguage::forgetInstalledLanguage('de'); + + $this->assertNull( + $this->callLoadFromMigratedLanguageFile('tos', 'de'), + 'both the stale cache entry and the list membership must be gone' + ); + } + + /** Without a known list (no ilLanguage constructed in this request) nothing is restricted. */ + public function testWithoutAnyKnownInstalledLanguagesListNoRestrictionApplies(): void + { + $this->ensureRealTosDeMoFileExists(); + $this->registerDirectoryManager(new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos')); + + $this->assertSame( + 'Nutzungsvereinbarung', + $this->callLoadFromMigratedLanguageFile('tos', 'de')['tos_agreement'] + ); + } + + + + + /** Must skip instead of writing into a foreign CLIENT_DATA_DIR (e.g. /var/iliasdata); needs its own process so setUp() has not defined it. */ + #[RunInSeparateProcess] + public function testRefusesToWriteIntoAClientDataDirOutsideSysTempDir(): void + { + $foreign_dir = __DIR__ . '/tmp-not-a-temp-dir-' . bin2hex(random_bytes(4)); + $this->assertFalse(str_starts_with($foreign_dir, sys_get_temp_dir() . '/')); + mkdir($foreign_dir, 0775, true); + define('CLIENT_DATA_DIR', $foreign_dir); + + try { + try { + $this->ensureRealTosDeMoFileExists(); + $this->fail('ensureRealTosDeMoFileExists() must refuse to write into a CLIENT_DATA_DIR outside sys_get_temp_dir().'); + } catch (SkippedWithMessageException $e) { + $this->assertStringContainsString($foreign_dir, $e->getMessage()); + } + + $this->assertSame([], array_diff(scandir($foreign_dir) ?: [], ['.', '..'])); + } finally { + if (is_dir($foreign_dir)) { + array_map('unlink', glob($foreign_dir . '/*') ?: []); + rmdir($foreign_dir); + } + } + } +} diff --git a/components/ILIAS/TermsOfService/TermsOfService.php b/components/ILIAS/TermsOfService/TermsOfService.php index 7df098e76d30..d7ae45a6a980 100644 --- a/components/ILIAS/TermsOfService/TermsOfService.php +++ b/components/ILIAS/TermsOfService/TermsOfService.php @@ -20,6 +20,9 @@ namespace ILIAS; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; + class TermsOfService implements Component\Component { public function init( @@ -32,6 +35,9 @@ public function init( array | \ArrayAccess &$pull, array | \ArrayAccess &$internal, ): void { - // ... + $contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory( + $this, + 'tos' + ); } } diff --git a/components/ILIAS/TermsOfService/lang/tos.pot b/components/ILIAS/TermsOfService/lang/tos.pot new file mode 100644 index 000000000000..8b2c9e926ddf --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos.pot @@ -0,0 +1,57 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"MIME-Version: 1.0\n" +"X-Domain: tos\n" + +msgid "tos_accept_usr_agreement_anonymous" +msgstr "" + +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "" + +msgid "tos_account_reg_not_possible" +msgstr "" + +msgid "tos_agree_date" +msgstr "" + +msgid "tos_agreement" +msgstr "" + +msgid "tos_disabled_no_docs_left" +msgstr "" + +msgid "tos_last_reset_date" +msgstr "" + +msgid "tos_never_reset" +msgstr "" + +msgid "tos_no_documents_exist" +msgstr "" + +msgid "tos_no_documents_exist_cant_save" +msgstr "" + +msgid "tos_reset_for_all_users" +msgstr "" + +msgid "tos_reset_successful" +msgstr "" + +msgid "tos_status_desc" +msgstr "" + +msgid "tos_status_enable" +msgstr "" + +msgid "tos_sure_reset_tos" +msgstr "" + +msgid "tos_withdrawal_usr_deletion" +msgstr "" + +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "" diff --git a/components/ILIAS/TermsOfService/lang/tos_ar.po b/components/ILIAS/TermsOfService/lang/tos_ar.po new file mode 100644 index 000000000000..5a0bb7456eb7 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_ar.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ar\n" +"Language-Team: Arabic\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_bg.po b/components/ILIAS/TermsOfService/lang/tos_bg.po new file mode 100644 index 000000000000..5c893138b23c --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_bg.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: bg\n" +"Language-Team: Bulgarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_cs.po b/components/ILIAS/TermsOfService/lang/tos_cs.po new file mode 100644 index 000000000000..48342424c8c5 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_cs.po @@ -0,0 +1,66 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: cs\n" +"Language-Team: Czech\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "Vlastní registrace není možná kvůli chybějící uživatelské smlouvě. Další informace vám poskytne správce systému ." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Smluvní podmínky" + +msgid "tos_disabled_no_docs_left" +msgstr "Uživatelské smlouvy jsou nyní zakázány, protože všechny dokumenty byly vymazány. Před opětovným povolením uživatelských smluv vytvořte alespoň jeden dokument." + +msgid "tos_last_reset_date" +msgstr "Podmínky služby byly resetovány na %s." + +msgid "tos_never_reset" +msgstr "Podmínky služby nebyly dosud resetovány." + +msgid "tos_no_documents_exist" +msgstr "V současné době neexistují žádné podmínky služeb pro žádný z nainstalovaných jazyků." + +msgid "tos_no_documents_exist_cant_save" +msgstr "V současné době neexistují žádné podmínky služeb pro žádný z nainstalovaných jazyků. Přidejte alespoň jeden dokument pro každý z nainstalovaných jazyků." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "Podmínky služby byly úspěšně resetovány." + +msgid "tos_status_desc" +msgstr "Pokud jsou aktivováni, uživatelé jsou nuceni přijmout podmínky služby dříve, než jim je povoleno vstoupit do ILIAS." + +msgid "tos_status_enable" +msgstr "Povolit" + +msgid "tos_sure_reset_tos" +msgstr "Opravdu chcete resetovat podmínky služby pro všechny uživatele v systému? To platí i pro ty účty, které se používají pro webové služby SOAP, chatový server nebo úkol cron." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_da.po b/components/ILIAS/TermsOfService/lang/tos_da.po new file mode 100644 index 000000000000..57b6ffbd4069 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_da.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: da\n" +"Language-Team: Danish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_de.po b/components/ILIAS/TermsOfService/lang/tos_de.po new file mode 100644 index 000000000000..03615c747f84 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_de.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: de\n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Nutzungsvereinbarung" + +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Bevor Sie zu ILIAS wechseln, akzeptieren Sie die folgende Nutzungsvereinbarung." + +msgid "tos_account_reg_not_possible" +msgstr "Eine Selbstregistrierung ist aktuell nicht möglich, da keine Nutzungsvereinbarung angeboten wird. Bitte kontaktieren Sie die technische Betreuung für weitere Informationen." + +msgid "tos_agree_date" +msgstr "Nutzungsvereinbarung zugestimmt am" + +msgid "tos_agreement" +msgstr "Nutzungsvereinbarung" + +msgid "tos_disabled_no_docs_left" +msgstr "Die Nutzungsvereinbarung wurde deaktiviert, da alle Dokumente gelöscht wurden. Erstellen Sie mindestens ein Dokument, bevor Sie die Nutzungsvereinbarung wieder aktivieren." + +msgid "tos_last_reset_date" +msgstr "Die Nutzungsvereinbarung wurde zuletzt am %s zurückgesetzt. Setzen Sie die Nutzungsvereinbarung nur zurück, wenn sich die Dokumente in der Zwischenzeit geändert haben und eine Zustimmung zu dieser geänderten Vereinbarung gewünscht ist." + +msgid "tos_never_reset" +msgstr "Die Nutzungsvereinbarung wurde bisher noch nicht zurückgesetzt. Setzen Sie die Nutzungsvereinbarung nur zurück, wenn sich die Dokumente in der Zwischenzeit geändert haben und eine Zustimmung zu dieser geänderten Vereinbarung gewünscht ist." + +msgid "tos_no_documents_exist" +msgstr "Aktuell liegen keine Dokumente für die Nutzungsvereinbarung vor." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Aktuell liegt keine Nutzungsvereinbarung vor. Bitte erstellen Sie mindestens ein Dokument um den Service zu aktivieren." + +msgid "tos_reset_for_all_users" +msgstr "Nutzungsvereinbarung zurücksetzen" + +msgid "tos_reset_successful" +msgstr "Die Nutzungsvereinbarung wurde erfolgreich zurückgesetzt." + +msgid "tos_status_desc" +msgstr "Benutzer werden zum Unterzeichnen der Nutzungsvereinbarung aufgefordert, bevor sie ILIAS nutzen können." + +msgid "tos_status_enable" +msgstr "Nutzungsvereinbarung aktivieren" + +msgid "tos_sure_reset_tos" +msgstr "Sind Sie sicher, dass die Nutzungsvereinbarung für alle Benutzer im System zurückgesetzt werden soll? Dies betrifft auch jene Anmeldekonten, die z.B. für die SOAP-Web-Services genutzt werden." + +msgid "tos_withdrawal_usr_deletion" +msgstr "Löschung des ILIAS-Kontos beim Widerruf der Nutzungsvereinbarung" + +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "Wird die Zustimmung zur Nutzungsvereinbarung widerrufen, erfolgt automatisch die Löschung des ILIAS-Kontos." diff --git a/components/ILIAS/TermsOfService/lang/tos_el.po b/components/ILIAS/TermsOfService/lang/tos_el.po new file mode 100644 index 000000000000..413931781670 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_el.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: el\n" +"Language-Team: Greek\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_en.po b/components/ILIAS/TermsOfService/lang/tos_en.po new file mode 100644 index 000000000000..1b3db68e56bc --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_en.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" +"Language-Team: English\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "Self-registration is currently not possible because there is no Terms of Service agreement available. Please contact your system administrator for further information." + +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Terms of Service" + +msgid "tos_disabled_no_docs_left" +msgstr "Terms of Service documents can no longer be displayed, because all ToS documents have been deleted. Users will no longer be required to agree to a ToS document when registering an account. Please create at least one document before re-checking the ‘Display Terms of Service’ box." + +msgid "tos_last_reset_date" +msgstr "The Terms of Service were reset on %s. Only reset the Terms of Service if changes have been made to the ToS document(s) and you require all users to agree to the new ToS." + +msgid "tos_never_reset" +msgstr "The Terms of Service have not yet been reset. Only reset the Terms of Service if changes have been made to the ToS document(s) and you require all users to agree to the new ToS." + +msgid "tos_no_documents_exist" +msgstr "There are currently no Terms of Service documents available." + +msgid "tos_no_documents_exist_cant_save" +msgstr "There are currently no Terms of Service documents available. Please add at least one document in order to activate this service." + +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "Terms of Service successfully reset." + +msgid "tos_status_desc" +msgstr "Display relevant language-based Terms of Service documents at the end of the registration form for new users and, if applicable, upon the user’s first login. Users are required to accept the Terms of Service before they can enter ILIAS." + +msgid "tos_status_enable" +msgstr "Display Terms of Service" + +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the Terms of Service for all users in the system? This also applies, for example, to those accounts that are used for the SOAP web services." + +msgid "tos_withdrawal_usr_deletion" +msgstr "Account deletion upon ToS withdrawal" + +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_es.po b/components/ILIAS/TermsOfService/lang/tos_es.po new file mode 100644 index 000000000000..1a5138ed1d7c --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_es.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: es\n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: tos\n" + +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Condiciones del servicio" + +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Antes de continuar a ILIAS, debe aceptar las siguientes Condiciones del servicio." + +msgid "tos_account_reg_not_possible" +msgstr "El registro autónomo no es posible actualmente porque no hay un acuerdo de Condiciones del servicio disponible. Por favor, contacte con su administrador del sistema para más información." + +msgid "tos_agree_date" +msgstr "Condiciones aceptadas el" + +msgid "tos_agreement" +msgstr "Condiciones del servicio" + +msgid "tos_disabled_no_docs_left" +msgstr "Los documentos de Condiciones del servicio ya no pueden mostrarse porque todos han sido eliminados. Ya no se requerirá a los usuarios aceptar un documento de Condiciones al registrar una cuenta. Por favor, cree al menos un documento antes de volver a marcar la casilla 'Mostrar Condiciones del servicio'." + +msgid "tos_last_reset_date" +msgstr "Las Condiciones del Servicio se restablecieron el %s. Solo restablezca las Condiciones del Servicio si se han realizado cambios en los documentos de los TyC y requiere que todos los usuarios acepten los nuevos términos." + +msgid "tos_never_reset" +msgstr "Las Condiciones del Servicio aún no se han restablecido. Solo restablezca las Condiciones del Servicio si se han realizado cambios en los documentos de los TyC y requiere que todos los usuarios acepten los nuevos términos." + +msgid "tos_no_documents_exist" +msgstr "Actualmente no hay documentos de Condiciones del Servicio disponibles." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Actualmente no hay documentos de Condiciones del Servicio disponibles. Por favor, añada al menos un documento para activar este servicio." + +msgid "tos_reset_for_all_users" +msgstr "Restablecer Condiciones del Servicio" + +msgid "tos_reset_successful" +msgstr "Condiciones del Servicio restablecidas con éxito." + +msgid "tos_status_desc" +msgstr "Muestra los documentos de Condiciones del Servicio pertinentes basados en el idioma al final del formulario de registro para nuevos usuarios y, si procede, en el primer inicio de sesión del usuario. Los usuarios deben aceptar las Condiciones del Servicio antes de poder entrar en ILIAS." + +msgid "tos_status_enable" +msgstr "Mostrar Condiciones del Servicio" + +msgid "tos_sure_reset_tos" +msgstr "¿Está seguro de que desea restablecer las Condiciones del Servicio para todos los usuarios del sistema? Esto también se aplica, por ejemplo, a aquellas cuentas que se utilizan para los servicios web SOAP." + +msgid "tos_withdrawal_usr_deletion" +msgstr "Eliminación de la cuenta al retirar los TyC" + +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "Si un usuario retira su aceptación de un documento de Condiciones del Servicio aceptado previamente, esto resultará en la eliminación de la cuenta del usuario." diff --git a/components/ILIAS/TermsOfService/lang/tos_et.po b/components/ILIAS/TermsOfService/lang/tos_et.po new file mode 100644 index 000000000000..4e16fb6b0f31 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_et.po @@ -0,0 +1,70 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: et\n" +"Language-Team: Estonian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +msgid "tos_status_enable" +msgstr "Enable" + +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_fa.po b/components/ILIAS/TermsOfService/lang/tos_fa.po new file mode 100644 index 000000000000..179377eab8ce --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_fa.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fa\n" +"Language-Team: Persian (Farsi)\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_fr.po b/components/ILIAS/TermsOfService/lang/tos_fr.po new file mode 100644 index 000000000000..ce8907474c79 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_fr.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fr\n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Conditions d'Utilisation" + +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +msgid "tos_last_reset_date" +msgstr "Les conditions d'utilisation ont été réinitialisées le %s." + +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +msgid "tos_no_documents_exist" +msgstr "Il n'y a actuellement aucune condition d'utilisation définie pour les langues installées." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Il n’y a actuellement aucun terme de service pour aucune des langues installées. Veuillez ajouter au moins un document pour l’une des langues installées." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +msgid "tos_status_desc" +msgstr "Lorsque activé, les utilisateurs doivent accepter les conditions d'utilisation avant de pouvoir entrer dans ILIAS." + +msgid "tos_status_enable" +msgstr "Activer" + +msgid "tos_sure_reset_tos" +msgstr "Etes-vous sûr de vouloir réinitialiser les conditions d'utilisation pour tous les utilisateurs du système ? Ceci s'applique aussi aux comptes qui sont utilisés pour les WebServices SOAP, serveur de Chat et tâches Cron." + +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion on Terms Of Service Withdrawal" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_hr.po b/components/ILIAS/TermsOfService/lang/tos_hr.po new file mode 100644 index 000000000000..0e927fb4d3c5 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_hr.po @@ -0,0 +1,66 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hr\n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "Samoregistracija nije moguća zbog nedostatka korisničkog sporazuma. Za dodatne informacije obratite se svom administratoru sustava." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Uvjeti pružanja usluge" + +msgid "tos_disabled_no_docs_left" +msgstr "Korisnički ugovori sada su onemogućeni jer su svi dokumenti izbrisani. Izradite barem jedan dokument prije ponovnog omogućavanja korisničkih sporazuma." + +msgid "tos_last_reset_date" +msgstr "Uvjeti korištenja usluge resetirani su dana %s." + +msgid "tos_never_reset" +msgstr "Uvjeti korištenja usluge još nisu resetirani." + +msgid "tos_no_documents_exist" +msgstr "Trenutačno uopće ne postoje dokumenti o uvjetima korištenja usluge." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Trenutačno uopće ne postoje dokumenti o uvjetima korištenja. Prije aktiviranja ove usluge dodajte barem jedan dokument .." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "Uvjeti korištenja usluge uspješno su resetirani." + +msgid "tos_status_desc" +msgstr "Ako je aktivirano, korisnici su prisiljeni prihvatiti uvjete korištenja usluge prije nego što uđu u ILIAS." + +msgid "tos_status_enable" +msgstr "Omogući" + +msgid "tos_sure_reset_tos" +msgstr "Jeste li sigurni da želite resetirati uvjete korištenja usluge za sve korisnike u sustavu? To se također odnosi na one račune koji se na primjer koriste za internetsku uslugu SOAP." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion on Terms Of Service Withdrawal " + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_hu.po b/components/ILIAS/TermsOfService/lang/tos_hu.po new file mode 100644 index 000000000000..056299c2e3a3 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_hu.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hu\n" +"Language-Team: Hungarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Szolgáltatási feltételek" + +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Mielőtt továbblép az ILIAS-hoz, elfogadja az alábbi Szolgáltatási feltételeket" + +msgid "tos_account_reg_not_possible" +msgstr "Nem regisztrálhatja saját magát, mert hiányzik a Szolgáltatási feltételek. További információkért vegye fel a kapcsolatot a rendszerüzemeltetőkkel." + +msgid "tos_agree_date" +msgstr "Elfogadás időpontja" + +msgid "tos_agreement" +msgstr "Szolgáltatási feltételek" + +msgid "tos_disabled_no_docs_left" +msgstr "A felhasználási feltételeket kikapcsoltuk, mert az összes dokumentumot letörölték. Legalább egy dokumentumot hozzon létre a felhasználási feltételek újbóli bekapcsolása előtt." + +msgid "tos_last_reset_date" +msgstr "A szolgáltatási feltételek alapértékre állítva: %s." + +msgid "tos_never_reset" +msgstr "A Szolgáltatási feltételeket még sosem állították még alapértékre." + +msgid "tos_no_documents_exist" +msgstr "Jelenleg egy telepített nyelvhez sincs szolgáltatási feltétel." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Jelenleg egy telepített nyelvhez sincs szolgáltatási feltétel. Kérem, legalább egy nyelvhez hozzon létre." + +msgid "tos_reset_for_all_users" +msgstr "Szolgáltatási feltételek elfogadásainak törlése" + +msgid "tos_reset_successful" +msgstr "Szolgáltatási feltételek elfogadásait sikeresen törölte." + +msgid "tos_status_desc" +msgstr "A felhasználóknak az ILAS-ba belépés előtt el kell fogadniuk a Szolgáltatási feltételeket." + +msgid "tos_status_enable" +msgstr "Engedélyezés" + +msgid "tos_sure_reset_tos" +msgstr "Biztos, hogy törli az összes felhasználó Szolgáltatási feltételeit? Ez azokra a fiókokra is hatással van, melyeket SOAP webszolgáltatások, a csevegőszerver, vagy az ütemezett feladatok használnak." + +msgid "tos_withdrawal_usr_deletion" +msgstr "ILIAS-fiók törlése a Szolgáltatási feltételek elutasítás esetén" + +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "Ha egy felhasználó visszavonja egy korábbi Szolgáltatási feltételek elfogadását, az ILIAS-fiókja törlését vonja maga után." diff --git a/components/ILIAS/TermsOfService/lang/tos_it.po b/components/ILIAS/TermsOfService/lang/tos_it.po new file mode 100644 index 000000000000..c0134561ec3e --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_it.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: it\n" +"Language-Team: Italian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "Una registrazione automatica non è possibile a causa del contratto utente mancante. Per favore contatta il tuoamministratore di sistema per maggiori informazioni." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Termini di servizio" + +msgid "tos_disabled_no_docs_left" +msgstr "I contratti utente sono ora disabilitati, poiché tutti i documenti sono stati eliminati. Si prega di creare almeno un documento prima di abilitare nuovamente gli accordi utente." + +msgid "tos_last_reset_date" +msgstr "Gli accordi utente sono stati reimpostati l'ultima volta il %s. Reimposta il contratto utente solo se nel frattempo è cambiato e desideri approvare il contratto modificato." + +msgid "tos_never_reset" +msgstr "I termini di servizio non sono ancora stati ripristinati." + +msgid "tos_no_documents_exist" +msgstr "Attualmente non ci sono termini di servizio per ogni lingua installata." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Attualmente non ci sono termini di servizio per ogni lingua installata. Si prega di aggiungere almeno un documento per ogni lingua installata." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "I termini di servizio sono stati ripristinati correttamente." + +msgid "tos_status_desc" +msgstr "Se attivato, gli utenti sono costretti ad accettare i termini di servizio prima di poter entrare in ILIAS." + +msgid "tos_status_enable" +msgstr "Attiva i contratti di utilizzo" + +msgid "tos_sure_reset_tos" +msgstr "Sei sicuro di voler ripristinare i termini di servizio per tutti gli utenti del sistema? Questo vale anche per quegli account che vengono utilizzati per i servizi web SOAP, il server di chat o il cron job." + +msgid "tos_withdrawal_usr_deletion" +msgstr "Eliminazione dell'account in caso di ritiro dei termini di servizio" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_ja.po b/components/ILIAS/TermsOfService/lang/tos_ja.po new file mode 100644 index 000000000000..a7ec17565871 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_ja.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ja\n" +"Language-Team: Japanese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: tos\n" + +msgid "tos_accept_usr_agreement_anonymous" +msgstr "利用規約" + +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "ILIASを利用する前に次の利用規約を承諾するものとします。" + +msgid "tos_account_reg_not_possible" +msgstr "ユーザ許諾が無いので自己登録はできません。詳細に関してはシステム管理者へコンタクトしてください。" + +msgid "tos_agree_date" +msgstr "使用許諾 承諾日" + +msgid "tos_agreement" +msgstr "サービス使用許諾" + +msgid "tos_disabled_no_docs_left" +msgstr "全てのドキュメントが削除されたのでユーザ許諾書は現在無効です。ユーザ許諾書を再度有効にする前に一つはドキュメントを作成してください。" + +msgid "tos_last_reset_date" +msgstr "サービス使用許諾は%sにリセットされました。" + +msgid "tos_never_reset" +msgstr "サービス規約はまだリセットされていません。" + +msgid "tos_no_documents_exist" +msgstr "現在インストール済言語のサービス使用許諾書はありません" + +msgid "tos_no_documents_exist_cant_save" +msgstr "インストール済の言語にはサービス用語が現在何もありません。どれかインストール済の言語用に一つはドキュメントを追加してください。" + +msgid "tos_reset_for_all_users" +msgstr "サービス使用許諾をリセット" + +msgid "tos_reset_successful" +msgstr "サービス規約は正しくリセットされました。" + +msgid "tos_status_desc" +msgstr "アクティブにするとユーザはILIASログイン前にサービス使用許諾への承諾を要求されます。" + +msgid "tos_status_enable" +msgstr "有効" + +msgid "tos_sure_reset_tos" +msgstr "本当にシステム内の全ユーザのサービス使用許諾をリセットしますか?SOAP webサービス、chatサービス、クローンjobを使用しているユーザアカウントに対しても適用される事になります。" + +msgid "tos_withdrawal_usr_deletion" +msgstr "利用許諾撤回の削除" + +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "ユーザが事前に承諾したサービス使用許諾ドキュメントの承諾を破棄すると、ユーザのアカウント削除につながります。" diff --git a/components/ILIAS/TermsOfService/lang/tos_ka.po b/components/ILIAS/TermsOfService/lang/tos_ka.po new file mode 100644 index 000000000000..e643db11579f --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_ka.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ka\n" +"Language-Team: Georgian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_lt.po b/components/ILIAS/TermsOfService/lang/tos_lt.po new file mode 100644 index 000000000000..d916257e5b9e --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_lt.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: lt\n" +"Language-Team: Lithuanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && (n % 100 < 11 || n % 100 > 19)) ? 0 : ((n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_nl.po b/components/ILIAS/TermsOfService/lang/tos_nl.po new file mode 100644 index 000000000000..43aa0909b021 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_nl.po @@ -0,0 +1,70 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: nl\n" +"Language-Team: Dutch\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Gebruikersovereenkomst" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +msgid "tos_last_reset_date" +msgstr "De gebruikersovereenkomst is gereset op %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +msgid "tos_no_documents_exist" +msgstr "Er zijn momenteel geen gebruikersvoorwaarden voor de geïnstalleerde talen." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Momenteel zijn er geen gebruikersovereenkomsten voor enige geïnstalleerde taal. Voeg tenminste een overeenkomst toe voor een van deze talen." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +msgid "tos_status_desc" +msgstr "Indien ingeschakeld zijn gebruikers verplicht de gebruikersovereenkomst te accepteren voor ze ILIAS kunnen gebruiken." + +msgid "tos_status_enable" +msgstr "Inschakelen" + +msgid "tos_sure_reset_tos" +msgstr "Weet je zeker dat je de gebruikersovereenkomst wilt resetten voor alle gebruikers in het systeem? Dit geldt zan ook voor die accounts doe worden gebruikt voor de SOAP web services, de chat server. en de cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_pl.po b/components/ILIAS/TermsOfService/lang/tos_pl.po new file mode 100644 index 000000000000..6efafb25170c --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_pl.po @@ -0,0 +1,66 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pl\n" +"Language-Team: Polish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Zasady użytkowania" + +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +msgid "tos_last_reset_date" +msgstr "Zasady użytkowania zostały po raz ostatni resetowane w dniu %s." + +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +msgid "tos_no_documents_exist" +msgstr "Aktualnie brak zasad użytkowania dostępnych w zapisanych językach." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Aktualnie brak zasad użytkowania dostępnych w zapisanych językach. Dlatego nie możesz aktywować tej usługi." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +msgid "tos_status_desc" +msgstr "Jeśli ta opcja jest aktywna, użytkownicy przed korzystaniem z ILIAS proszeni są o podpisanie zasad użytkowania." + +msgid "tos_status_enable" +msgstr "Aktywuj" + +msgid "tos_sure_reset_tos" +msgstr "Jesteś pewien, że zasady użytkowania mają zostać zresetowane w systemie dla wszystkich użytkowników? Dotyczy to również tych kont użytkowników, które są ewentualnie wykorzystywane do usług sieciowych SOAP, serwerów czatowych i zadań Cron." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_pt.po b/components/ILIAS/TermsOfService/lang/tos_pt.po new file mode 100644 index 000000000000..7dc61f380569 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_pt.po @@ -0,0 +1,66 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pt\n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "Não é possível registar-se a si próprio porque falta um acordo do utilizador. Entre em contacto com o administrador do seu sistema para mais informação." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Termos de serviço" + +msgid "tos_disabled_no_docs_left" +msgstr "Os acordos do utilizador estão agora desativados porque todos os documentos foram apagados. Crie pelo menos um documento antes de ativar novamente os acordos do utilizador." + +msgid "tos_last_reset_date" +msgstr "Os termos de serviço foram repostos em %s." + +msgid "tos_never_reset" +msgstr "Os termos de serviço ainda não foram repostos." + +msgid "tos_no_documents_exist" +msgstr "Atualmente não há termos de serviço para qualquer idioma instalado." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Atualmente não há termos de serviço para qualquer idioma instalado. Adicione pelo menos um documento para qualquer idioma instalado." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "Os termos de serviço foram repostos com sucesso." + +msgid "tos_status_desc" +msgstr "Se estiver ativado, os utilizadores são forçados a aceitar os termos de serviço antes de poderem entrar em ILIAS." + +msgid "tos_status_enable" +msgstr "Ativar" + +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion on Terms Of Service Withdrawal" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_ro.po b/components/ILIAS/TermsOfService/lang/tos_ro.po new file mode 100644 index 000000000000..4555d72235d3 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_ro.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ro\n" +"Language-Team: Romanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_ru.po b/components/ILIAS/TermsOfService/lang/tos_ru.po new file mode 100644 index 000000000000..ffce18fedd11 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_ru.po @@ -0,0 +1,70 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ru\n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Правила сервиса" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +msgid "tos_status_enable" +msgstr "Enable" + +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_sk.po b/components/ILIAS/TermsOfService/lang/tos_sk.po new file mode 100644 index 000000000000..c2cc1ac9aa45 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_sk.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sk\n" +"Language-Team: Slovak\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_sl.po b/components/ILIAS/TermsOfService/lang/tos_sl.po new file mode 100644 index 000000000000..aa99e282c26b --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_sl.po @@ -0,0 +1,66 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sl\n" +"Language-Team: Slovenian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=4; plural=(n % 100 == 1) ? 0 : ((n % 100 == 2) ? 1 : ((n % 100 == 3 || n % 100 == 4) ? 2 : 3));\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "Samoregistracija trenutno ni mogoča zaradi pomanjkanja uporabniške pogodbe. Za nadaljnje informacije se obrnite na svojega Sistemskega administratorja>." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Pogoji uporabe" + +msgid "tos_disabled_no_docs_left" +msgstr "Pogoji uporabe so bili deaktivirani, ker so bili vsi dokumenti izbrisani. Ustvarite vsaj en dokument, preden ponovno aktivirate pogoje uporabe." + +msgid "tos_last_reset_date" +msgstr "Pogoji uporabe se bili nazadnje ponastavljeni dne %s. Pogoje uporabe ponastavite samo takrat, ko so se ti v vmesnem času spremenili in je zaželeno soglasje za te spremenjene pogoje." + +msgid "tos_never_reset" +msgstr "Pogoji uporabe do sedaj še niso bili ponastavljeni. Pogoje uporabe ponastavite samo takrat, ko so se ti v vmesnem času spremenili in je zaželeno soglasje za te spremenjene pogoje." + +msgid "tos_no_documents_exist" +msgstr "Trenutno ni na voljo nobenih dokumentov o pogojih uporabe." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Trenutno ni na voljo nobenih pogojev uporabe. Ustvarite vsaj en dokument, da aktivirate storitev." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "Pogoji uporabe so bili uspešno ponastavljeni." + +msgid "tos_status_desc" +msgstr "Uporabniki morajo podpisati pogoje uporabe, preden lahko začnejo uporabljati ILIAS." + +msgid "tos_status_enable" +msgstr "Aktiviraj pogoje uporabe" + +msgid "tos_sure_reset_tos" +msgstr "Ali ste prepričani, da želite ponastaviti pogoje uporabe za vse uporabnike v sistemu? To velja tudi za tiste uporabniške račune, ki se npr. uporabljajo za internetno storitev SOAP." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion on Terms Of Service Withdrawal" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_sq.po b/components/ILIAS/TermsOfService/lang/tos_sq.po new file mode 100644 index 000000000000..ce3b1daab122 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_sq.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sq\n" +"Language-Team: Albanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_sr.po b/components/ILIAS/TermsOfService/lang/tos_sr.po new file mode 100644 index 000000000000..8a69c1cefca2 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_sr.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sr\n" +"Language-Team: Serbian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_sv.po b/components/ILIAS/TermsOfService/lang/tos_sv.po new file mode 100644 index 000000000000..169aa6e0a2dc --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_sv.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sv\n" +"Language-Team: Swedish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +msgid "tos_account_reg_not_possible" +msgstr "Självregistrering är för närvarande inte möjlig eftersom inget användaravtal erbjuds i denna installation. Kontakta din systemadministratör för mer information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +msgid "tos_agreement" +msgstr "Använda avtal" + +msgid "tos_disabled_no_docs_left" +msgstr "Användaravtalen har inaktiverats eftersom alla dokument har raderats. Skapa minst ett dokument innan du återaktiverar användaravtalen." + +msgid "tos_last_reset_date" +msgstr "Användaravtalen återställdes senast den %s. Återställ endast användaravtalen om de har ändrats under tiden och samtycke till detta ändrade avtal önskas." + +msgid "tos_never_reset" +msgstr "Användaravtalen har ännu inte återställts. Återställ endast användaravtalen om de har ändrats under tiden och samtycke till detta ändrade avtal önskas." + +msgid "tos_no_documents_exist" +msgstr "Det finns för närvarande inga tillgängliga dokument om användningsavtal." + +msgid "tos_no_documents_exist_cant_save" +msgstr "Det finns för närvarande inga tillgängliga användaravtal. Vänligen skapa minst ett dokument för att aktivera tjänsten." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +msgid "tos_reset_successful" +msgstr "Användaravtalen har återställts framgångsrikt." + +msgid "tos_status_desc" +msgstr "Användare ombeds att underteckna användaravtalet innan de kan använda ILIAS." + +msgid "tos_status_enable" +msgstr "Aktivera användningsavtal" + +msgid "tos_sure_reset_tos" +msgstr "Är du säker på att användaravtalen för alla användare i systemet ska återställas? Detta gäller även de inloggningskonton som används för SOAP-webbtjänsterna, till exempel." + +msgid "tos_withdrawal_usr_deletion" +msgstr "Radering av ILIAS-kontot efter återkallelse" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_tr.po b/components/ILIAS/TermsOfService/lang/tos_tr.po new file mode 100644 index 000000000000..6ce25729e5d5 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_tr.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: tr\n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_uk.po b/components/ILIAS/TermsOfService/lang/tos_uk.po new file mode 100644 index 000000000000..8225f3834cd0 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_uk.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: uk\n" +"Language-Team: Ukrainian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_vi.po b/components/ILIAS/TermsOfService/lang/tos_vi.po new file mode 100644 index 000000000000..666b915b193a --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_vi.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: vi\n" +"Language-Team: Vietnamese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." diff --git a/components/ILIAS/TermsOfService/lang/tos_zh.po b/components/ILIAS/TermsOfService/lang/tos_zh.po new file mode 100644 index 000000000000..fb58fe65ee54 --- /dev/null +++ b/components/ILIAS/TermsOfService/lang/tos_zh.po @@ -0,0 +1,77 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: zh\n" +"Language-Team: Chinese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: tos\n" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_accept_usr_agreement_anonymous_intro" +msgstr "Before you proceed to ILIAS you accept the following Terms of Service." + +#, fuzzy +msgid "tos_account_reg_not_possible" +msgstr "A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information." + +#, fuzzy +msgid "tos_agree_date" +msgstr "ToS agreed on" + +#, fuzzy +msgid "tos_agreement" +msgstr "Terms of Service" + +#, fuzzy +msgid "tos_disabled_no_docs_left" +msgstr "The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again." + +#, fuzzy +msgid "tos_last_reset_date" +msgstr "The terms of service have been reset on %s." + +#, fuzzy +msgid "tos_never_reset" +msgstr "The terms of service have not been reset, yet." + +#, fuzzy +msgid "tos_no_documents_exist" +msgstr "Currently there are no terms of service for any of the installed languages." + +#, fuzzy +msgid "tos_no_documents_exist_cant_save" +msgstr "Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages." + +#, fuzzy +msgid "tos_reset_for_all_users" +msgstr "Reset Terms of Service" + +#, fuzzy +msgid "tos_reset_successful" +msgstr "The terms of service have been reset successfully." + +#, fuzzy +msgid "tos_status_desc" +msgstr "If activated, users are forced to accept the terms of service before they can enter ILIAS." + +#, fuzzy +msgid "tos_status_enable" +msgstr "Enable" + +#, fuzzy +msgid "tos_sure_reset_tos" +msgstr "Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job." + +#, fuzzy +msgid "tos_withdrawal_usr_deletion" +msgstr "Account Deletion Upon Withdrawal From Terms of Service" + +#, fuzzy +msgid "tos_withdrawal_usr_deletion_desc" +msgstr "If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account." From 0961ca82f4755e47ed952d44d3ee68411629c62d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:24:33 +0200 Subject: [PATCH 035/103] Poll: Serve the translations from PO files Poll contributes its lang directory and ships the poll module as .pot/.po files, poll_population as a plural message. Poll itself still calls txt(). poll_import is left out: English, the reference language, lacks it and no code uses it (last present in lang/ilias_*.lang of caab0272843). --- .../tests/ConvertModuleToPoToolTest.php | 27 ++ components/ILIAS/Poll/Poll.php | 6 + components/ILIAS/Poll/lang/poll.pot | 214 +++++++++++++ components/ILIAS/Poll/lang/poll_ar.po | 266 ++++++++++++++++ components/ILIAS/Poll/lang/poll_bg.po | 285 +++++++++++++++++ components/ILIAS/Poll/lang/poll_cs.po | 234 ++++++++++++++ components/ILIAS/Poll/lang/poll_da.po | 261 ++++++++++++++++ components/ILIAS/Poll/lang/poll_de.po | 217 +++++++++++++ components/ILIAS/Poll/lang/poll_el.po | 285 +++++++++++++++++ components/ILIAS/Poll/lang/poll_en.po | 217 +++++++++++++ components/ILIAS/Poll/lang/poll_es.po | 221 ++++++++++++++ components/ILIAS/Poll/lang/poll_et.po | 233 ++++++++++++++ components/ILIAS/Poll/lang/poll_fa.po | 285 +++++++++++++++++ components/ILIAS/Poll/lang/poll_fr.po | 234 ++++++++++++++ components/ILIAS/Poll/lang/poll_hr.po | 234 ++++++++++++++ components/ILIAS/Poll/lang/poll_hu.po | 218 +++++++++++++ components/ILIAS/Poll/lang/poll_it.po | 234 ++++++++++++++ components/ILIAS/Poll/lang/poll_ja.po | 215 +++++++++++++ components/ILIAS/Poll/lang/poll_ka.po | 258 ++++++++++++++++ components/ILIAS/Poll/lang/poll_lt.po | 287 ++++++++++++++++++ components/ILIAS/Poll/lang/poll_nl.po | 234 ++++++++++++++ components/ILIAS/Poll/lang/poll_pl.po | 234 ++++++++++++++ components/ILIAS/Poll/lang/poll_pt.po | 234 ++++++++++++++ components/ILIAS/Poll/lang/poll_ro.po | 287 ++++++++++++++++++ components/ILIAS/Poll/lang/poll_ru.po | 259 ++++++++++++++++ components/ILIAS/Poll/lang/poll_sk.po | 287 ++++++++++++++++++ components/ILIAS/Poll/lang/poll_sl.po | 236 ++++++++++++++ components/ILIAS/Poll/lang/poll_sq.po | 285 +++++++++++++++++ components/ILIAS/Poll/lang/poll_sr.po | 287 ++++++++++++++++++ components/ILIAS/Poll/lang/poll_sv.po | 232 ++++++++++++++ components/ILIAS/Poll/lang/poll_tr.po | 258 ++++++++++++++++ components/ILIAS/Poll/lang/poll_uk.po | 287 ++++++++++++++++++ components/ILIAS/Poll/lang/poll_vi.po | 283 +++++++++++++++++ components/ILIAS/Poll/lang/poll_zh.po | 255 ++++++++++++++++ 34 files changed, 8089 insertions(+) create mode 100644 components/ILIAS/Poll/lang/poll.pot create mode 100644 components/ILIAS/Poll/lang/poll_ar.po create mode 100644 components/ILIAS/Poll/lang/poll_bg.po create mode 100644 components/ILIAS/Poll/lang/poll_cs.po create mode 100644 components/ILIAS/Poll/lang/poll_da.po create mode 100644 components/ILIAS/Poll/lang/poll_de.po create mode 100644 components/ILIAS/Poll/lang/poll_el.po create mode 100644 components/ILIAS/Poll/lang/poll_en.po create mode 100644 components/ILIAS/Poll/lang/poll_es.po create mode 100644 components/ILIAS/Poll/lang/poll_et.po create mode 100644 components/ILIAS/Poll/lang/poll_fa.po create mode 100644 components/ILIAS/Poll/lang/poll_fr.po create mode 100644 components/ILIAS/Poll/lang/poll_hr.po create mode 100644 components/ILIAS/Poll/lang/poll_hu.po create mode 100644 components/ILIAS/Poll/lang/poll_it.po create mode 100644 components/ILIAS/Poll/lang/poll_ja.po create mode 100644 components/ILIAS/Poll/lang/poll_ka.po create mode 100644 components/ILIAS/Poll/lang/poll_lt.po create mode 100644 components/ILIAS/Poll/lang/poll_nl.po create mode 100644 components/ILIAS/Poll/lang/poll_pl.po create mode 100644 components/ILIAS/Poll/lang/poll_pt.po create mode 100644 components/ILIAS/Poll/lang/poll_ro.po create mode 100644 components/ILIAS/Poll/lang/poll_ru.po create mode 100644 components/ILIAS/Poll/lang/poll_sk.po create mode 100644 components/ILIAS/Poll/lang/poll_sl.po create mode 100644 components/ILIAS/Poll/lang/poll_sq.po create mode 100644 components/ILIAS/Poll/lang/poll_sr.po create mode 100644 components/ILIAS/Poll/lang/poll_sv.po create mode 100644 components/ILIAS/Poll/lang/poll_tr.po create mode 100644 components/ILIAS/Poll/lang/poll_uk.po create mode 100644 components/ILIAS/Poll/lang/poll_vi.po create mode 100644 components/ILIAS/Poll/lang/poll_zh.po diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php index f548a02ada37..625b4e72c94d 100644 --- a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -29,6 +29,7 @@ class ConvertModuleToPoToolTest extends TestCase { private const string TOOL = __DIR__ . '/../tools/po-migration/convert_module_to_po.php'; private const string SHIPPED_DIRECTORY = __DIR__ . '/../../TermsOfService/lang'; + private const string POLL_SHIPPED_DIRECTORY = __DIR__ . '/../../Poll/lang'; private string $directory; @@ -140,6 +141,32 @@ public function testReconvertingTosReproducesTheShippedFilesByteForByte(): void } } + /** + * Plural pilot "poll": re-running reproduces the shipped files byte for byte, including single-form "ja". + * A "n==1 -> 0, else 1" language gets msgstr[0] from the configured singular key and msgstr[1] from the + * message's own value; otherwise every form gets the message's own value. + */ + public function testReconvertingPollReproducesTheShippedFilesByteForByteIncludingPluralForms(): void + { + $shipped = self::files(self::POLL_SHIPPED_DIRECTORY); + $this->assertNotSame([], $shipped, 'the shipped poll files were not found'); + $this->assertStringContainsString('msgid_plural "poll_population_plural"', $shipped['poll_de.po'] ?? '', 'precondition: poll_de.po has the expected plural message'); + foreach ($shipped as $name => $content) { + file_put_contents($this->directory . '/out/' . $name, $content); + } + + [$exit_code, $stdout, $stderr] = $this->runTool('--skip-unmigratable-keys', 'poll', 'en', $this->directory . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + // poll_import is missing in en, so the shipped files leave it out + $this->assertMatchesRegularExpression('/\AWARNING: [^\n]+\n( [a-z]+: poll_import\n)+\z/', $stderr, 'only poll_import is left out, no notices or deprecations'); + $this->assertStringContainsString('(0 entries filled from \'en\', flagged fuzzy)', $stdout); + $this->assertSame(array_keys($shipped), array_keys(self::files($this->directory . '/out')), 'no file added or lost'); + foreach (self::files($this->directory . '/out') as $name => $content) { + $this->assertSame($shipped[$name], $content, $name); + } + } + /** Every language's keys must be a subset of the reference language's, else the run fails before writing. */ public function testFailsWithExitOneAndWritesNothingWhenALanguageHasAKeyMissingFromTheReferenceLanguage(): void { diff --git a/components/ILIAS/Poll/Poll.php b/components/ILIAS/Poll/Poll.php index 7c33f90fd168..52af9272985e 100644 --- a/components/ILIAS/Poll/Poll.php +++ b/components/ILIAS/Poll/Poll.php @@ -22,6 +22,8 @@ use ILIAS\Setup\Agent as SetupAgent; use ILIAS\Refinery\Factory as RefineryFactory; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; class Poll implements Component\Component { @@ -38,5 +40,9 @@ public function init( $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\ComponentJS($this, "ilPoll.js"); $contribute[SetupAgent::class] = fn() => new \ilPollSetupAgent($pull[RefineryFactory::class]); + $contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory( + $this, + 'poll' + ); } } diff --git a/components/ILIAS/Poll/lang/poll.pot b/components/ILIAS/Poll/lang/poll.pot new file mode 100644 index 000000000000..d71f7eaba5f6 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll.pot @@ -0,0 +1,214 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"MIME-Version: 1.0\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "" + +msgid "poll_activation_online_info" +msgstr "" + +msgid "poll_add" +msgstr "" + +msgid "poll_anonymous_warning" +msgstr "" + +msgid "poll_answer" +msgstr "" + +msgid "poll_answer_selected_alt_text" +msgstr "" + +msgid "poll_answers" +msgstr "" + +msgid "poll_barchart" +msgstr "" + +msgid "poll_block_message_already_voted" +msgstr "" + +msgid "poll_block_message_no_answers" +msgstr "" + +msgid "poll_block_results_available_on" +msgstr "" + +msgid "poll_cannot_set_online_no_answers" +msgstr "" + +msgid "poll_chart_votes" +msgstr "" + +msgid "poll_comments" +msgstr "" + +msgid "poll_copy" +msgstr "" + +msgid "poll_delete_votes" +msgstr "" + +msgid "poll_delete_votes_sure" +msgstr "" + +msgid "poll_edit" +msgstr "" + +msgid "poll_edit_question" +msgstr "" + +msgid "poll_image" +msgstr "" + +msgid "poll_limit_not_below_answer_count" +msgstr "" + +msgid "poll_limit_number_of_answers" +msgstr "" + +msgid "poll_max_number_of_answers" +msgstr "" + +msgid "poll_max_number_of_answers_info" +msgstr "" + +msgid "poll_mode" +msgstr "" + +msgid "poll_mode_anonymous" +msgstr "" + +msgid "poll_mode_anonymous_info" +msgstr "" + +msgid "poll_mode_personal" +msgstr "" + +msgid "poll_mode_personal_info" +msgstr "" + +msgid "poll_new" +msgstr "" + +msgid "poll_non_anonymous_warning" +msgstr "" + +msgid "poll_notification_activated" +msgstr "" + +msgid "poll_notification_deactivated" +msgstr "" + +msgid "poll_notification_subscribe" +msgstr "" + +msgid "poll_notification_unsubscribe" +msgstr "" + +msgid "poll_percentage" +msgstr "" + +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "" +msgstr[1] "" + +msgid "poll_population_singular" +msgstr "" + +msgid "poll_question" +msgstr "" + +msgid "poll_result" +msgstr "" + +msgid "poll_result_answers" +msgstr "" + +msgid "poll_result_sorting" +msgstr "" + +msgid "poll_result_sorting_answers" +msgstr "" + +msgid "poll_result_sorting_votes" +msgstr "" + +msgid "poll_result_users" +msgstr "" + +msgid "poll_show_results_as" +msgstr "" + +msgid "poll_sortorder" +msgstr "" + +msgid "poll_stacked_chart" +msgstr "" + +msgid "poll_view_results" +msgstr "" + +msgid "poll_view_results_after_period" +msgstr "" + +msgid "poll_view_results_after_period_impossible" +msgstr "" + +msgid "poll_view_results_after_vote" +msgstr "" + +msgid "poll_view_results_always" +msgstr "" + +msgid "poll_view_results_never" +msgstr "" + +msgid "poll_vote" +msgstr "" + +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "" +msgstr[1] "" + +msgid "poll_vote_error_multi_no_answer" +msgstr "" + +msgid "poll_vote_error_single" +msgstr "" + +msgid "poll_vote_notification_body" +msgstr "" + +msgid "poll_vote_notification_link" +msgstr "" + +msgid "poll_vote_notification_reason" +msgstr "" + +msgid "poll_vote_notification_subject" +msgstr "" + +msgid "poll_votes_no_edit" +msgstr "" + +msgid "poll_voting_period_and_results" +msgstr "" + +msgid "poll_voting_period_ended_info" +msgstr "" + +msgid "poll_voting_period_full_info" +msgstr "" + +msgid "poll_voting_period_info" +msgstr "" + +msgid "poll_voting_period_limited" +msgstr "" diff --git a/components/ILIAS/Poll/lang/poll_ar.po b/components/ILIAS/Poll/lang/poll_ar.po new file mode 100644 index 000000000000..3cb8a7533df8 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_ar.po @@ -0,0 +1,266 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ar\n" +"Language-Team: Arabic\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Current Votes" + +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "Copy Poll" + +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" +msgstr[2] "%s Votes" +msgstr[3] "%s Votes" +msgstr[4] "%s Votes" +msgstr[5] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Question" + +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Display Results" + +msgid "poll_view_results_after_period" +msgstr "After Voting Period" + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +msgid "poll_view_results_always" +msgstr "Always" + +msgid "poll_view_results_never" +msgstr "Never" + +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." +msgstr[2] "Please select no more than %s answers." +msgstr[3] "Please select no more than %s answers." +msgstr[4] "Please select no more than %s answers." +msgstr[5] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_bg.po b/components/ILIAS/Poll/lang/poll_bg.po new file mode 100644 index 000000000000..82421d31197f --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_bg.po @@ -0,0 +1,285 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: bg\n" +"Language-Team: Bulgarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_cs.po b/components/ILIAS/Poll/lang/poll_cs.po new file mode 100644 index 000000000000..91e44aaaa88d --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_cs.po @@ -0,0 +1,234 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: cs\n" +"Language-Team: Czech\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Aktuální hlasy" + +msgid "poll_activation_online_info" +msgstr "Aktivujte toto nastavení ke zpřístupnění hlasování uživatelům." + +msgid "poll_add" +msgstr "Přidat anketu" + +msgid "poll_anonymous_warning" +msgstr "Vaše jméno není ve výsledcích ankety viditelné." + +msgid "poll_answer" +msgstr "Odpověď" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Možné odpovědi" + +msgid "poll_barchart" +msgstr "Sloupcový graf" + +msgid "poll_block_message_already_voted" +msgstr "V této anketě jste již hlasoval(a)." + +msgid "poll_block_message_no_answers" +msgstr "Tato anketa není kompletní." + +msgid "poll_block_results_available_on" +msgstr "Tyto výsledky budou dostupné na %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Komentáře" + +msgid "poll_copy" +msgstr "Kopírovat anketu" + +msgid "poll_delete_votes" +msgstr "Smazat všechny hlasy" + +msgid "poll_delete_votes_sure" +msgstr "Skutečně chcete smazat všechny hlasy?" + +msgid "poll_edit" +msgstr "Upravit anketu" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Obrázek" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Maximální počet odpovědí na účastníka" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Režim" + +msgid "poll_mode_anonymous" +msgstr "Anonymní" + +msgid "poll_mode_anonymous_info" +msgstr "Nelze zpětně vysledovat, jak účastníci hlasovali." + +msgid "poll_mode_personal" +msgstr "Osobní" + +msgid "poll_mode_personal_info" +msgstr "Seznam účastníků s jejich příslušnými hlasy je k dispozici při dostatečném povolení." + +msgid "poll_new" +msgstr "Přidat novou anketu" + +msgid "poll_non_anonymous_warning" +msgstr "Vaše jméno a hlas jsou viditelné správcům ve výsledcích." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Aktivovat oznámení" + +msgid "poll_notification_unsubscribe" +msgstr "Deaktivovat oznámení" + +msgid "poll_percentage" +msgstr "Aktuální procento" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Hlasů" +msgstr[1] "%s Hlasů" +msgstr[2] "%s Hlasů" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Otázka" + +msgid "poll_result" +msgstr "Výsledky hlasování" + +msgid "poll_result_answers" +msgstr "Hlasy" + +msgid "poll_result_sorting" +msgstr "Třídění" + +msgid "poll_result_sorting_answers" +msgstr "V pořadí odpovědí" + +msgid "poll_result_sorting_votes" +msgstr "Podle počtu hlasů (sestupně)" + +msgid "poll_result_users" +msgstr "Účastníci" + +msgid "poll_show_results_as" +msgstr "Zobrazit výsledky jako" + +msgid "poll_sortorder" +msgstr "Pořadí" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Zobrazit výsledky" + +msgid "poll_view_results_after_period" +msgstr "Po době určené k hlasování" + +msgid "poll_view_results_after_period_impossible" +msgstr "Doba hlasování není omezena." + +msgid "poll_view_results_after_vote" +msgstr "Po hlasování" + +msgid "poll_view_results_always" +msgstr "Vždy" + +msgid "poll_view_results_never" +msgstr "Nikdy" + +msgid "poll_vote" +msgstr "Hlas" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Vyberte více než %s odpovědí." +msgstr[1] "Vyberte více než %s odpovědí." +msgstr[2] "Vyberte více než %s odpovědí." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Vyberte prosím 1 odpověď." + +msgid "poll_vote_notification_body" +msgstr "součástí následující ankety bylo hlasování." + +msgid "poll_vote_notification_link" +msgstr "Odkaz na Anketu" + +msgid "poll_vote_notification_reason" +msgstr "Dostáváte tento e-mail, protože jste aktivovali upozornění na výše uvedenou anketu." + +msgid "poll_vote_notification_subject" +msgstr "Anketa \"%s\": nové hlasování" + +msgid "poll_votes_no_edit" +msgstr "Tato anketa již obsahuje hlasy. Nemůžete tuto anketu upravovat, dokud tyto hlasy nesmažete." + +msgid "poll_voting_period_and_results" +msgstr "Období hlasování a výsledky" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Doba hlasování od %s do %s" + +msgid "poll_voting_period_info" +msgstr "Termín pro hlasování: %s" + +msgid "poll_voting_period_limited" +msgstr "Omezeno" diff --git a/components/ILIAS/Poll/lang/poll_da.po b/components/ILIAS/Poll/lang/poll_da.po new file mode 100644 index 000000000000..8468d00b3ef3 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_da.po @@ -0,0 +1,261 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: da\n" +"Language-Team: Danish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Stemmer indtil nu" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +msgid "poll_answer" +msgstr "Valg" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Mulige valg" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "Du har allerede stemt i afstemningen" + +msgid "poll_block_message_no_answers" +msgstr "Afstemningen er ufuldstændig" + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "Kopier afstemning" + +msgid "poll_delete_votes" +msgstr "Slet alle stemmer" + +msgid "poll_delete_votes_sure" +msgstr "Er du sikker på du vil slette alle stemmer?" + +msgid "poll_edit" +msgstr "Ret afstemning" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Billede" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +msgid "poll_new" +msgstr "Lav ny afstemning" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +msgid "poll_percentage" +msgstr "Nuværende procent" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s stemmer" +msgstr[1] "%s stemmer" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Spørgsmål" + +msgid "poll_result" +msgstr "Afstemningsresultat" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +msgid "poll_sortorder" +msgstr "Sortering" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Vis resultat" + +msgid "poll_view_results_after_period" +msgstr "Efter stemmeperiode" + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "Efter stemmeafgivelse" + +msgid "poll_view_results_always" +msgstr "Altid" + +msgid "poll_view_results_never" +msgstr "Aldrig" + +msgid "poll_vote" +msgstr "Stem" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +msgid "poll_votes_no_edit" +msgstr "Afstemningen kan ikke ændres på grund af de eksisternde stemmer." + +msgid "poll_voting_period_and_results" +msgstr "Afstemningsperiode og resultat" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +msgid "poll_voting_period_limited" +msgstr "Begrænset" diff --git a/components/ILIAS/Poll/lang/poll_de.po b/components/ILIAS/Poll/lang/poll_de.po new file mode 100644 index 000000000000..ac6909be0c76 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_de.po @@ -0,0 +1,217 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: de\n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Aktuelle Stimmen" + +msgid "poll_activation_online_info" +msgstr "Diese Abstimmung verfügbar machen." + +msgid "poll_add" +msgstr "Abstimmung anlegen" + +msgid "poll_anonymous_warning" +msgstr "Dies ist eine anonyme Abstimmung. Ihre Stimme wird gespeichert, Ihr Name aber nicht in den Abstimmungsergebnissen angezeigt." + +msgid "poll_answer" +msgstr "Antwort" + +msgid "poll_answer_selected_alt_text" +msgstr "Ausgewählt" + +msgid "poll_answers" +msgstr "Mögliche Antworten" + +msgid "poll_barchart" +msgstr "Balkendiagramm" + +msgid "poll_block_message_already_voted" +msgstr "Sie haben bereits abgestimmt." + +msgid "poll_block_message_no_answers" +msgstr "Diese Abstimmung ist nicht komplett." + +msgid "poll_block_results_available_on" +msgstr "Die Ergebnisse werden %s veröffentlicht." + +msgid "poll_cannot_set_online_no_answers" +msgstr "Der Status kann nicht auf "online (aktiviert)" geändert werden, da der Abstimmung noch keine Frage hinzugefügt wurde!" + +msgid "poll_chart_votes" +msgstr "Stimmen" + +msgid "poll_comments" +msgstr "Kommentare" + +msgid "poll_copy" +msgstr "Abstimmung kopieren" + +msgid "poll_delete_votes" +msgstr "Alle Stimmabgaben löschen" + +msgid "poll_delete_votes_sure" +msgstr "Sind Sie sicher, dass Sie alle Stimmabgaben löschen möchten?" + +msgid "poll_edit" +msgstr "Abstimmung bearbeiten" + +msgid "poll_edit_question" +msgstr "Frage bearbeiten" + +msgid "poll_image" +msgstr "Bild" + +msgid "poll_limit_not_below_answer_count" +msgstr "Die maximale Anzahl der auswählbaren Antworten muss geringer sein als die Gesamtzahl der möglichen Antworten." + +msgid "poll_limit_number_of_answers" +msgstr "Anzahl der auswählbaren Antworten begrenzen" + +msgid "poll_max_number_of_answers" +msgstr "Maximale Anzahl pro Person" + +msgid "poll_max_number_of_answers_info" +msgstr "Sie dürfen bis zu %s Antworten auswählen." + +msgid "poll_mode" +msgstr "Modus" + +msgid "poll_mode_anonymous" +msgstr "Ohne Namen / Anonym" + +msgid "poll_mode_anonymous_info" +msgstr "Im Reiter „Abstimmungsergebnisse“ werden keine Namen aufgeführt. Systeminterne IDs der teilnehmenden Personen werden in der Datenbank gespeichert." + +msgid "poll_mode_personal" +msgstr "Mit Namen" + +msgid "poll_mode_personal_info" +msgstr "Im Reiter „Abstimmungsergebnisse“ werden die Namen der teilnehmenden Personen mit ihren Antworten aufgeführt. Personen, die Zugriff auf diesen Reiter haben, können sehen, wer welche Antwort gegeben hat." + +msgid "poll_new" +msgstr "Neue Abstimmung anlegen" + +msgid "poll_non_anonymous_warning" +msgstr "Ihr Name und Votum sind für die Administration in den Ergebnissen sichtbar." + +msgid "poll_notification_activated" +msgstr "Benachrichtigungen aktiviert." + +msgid "poll_notification_deactivated" +msgstr "Benachrichtigungen deaktiviert." + +msgid "poll_notification_subscribe" +msgstr "Benachrichtigungen aktivieren" + +msgid "poll_notification_unsubscribe" +msgstr "Benachrichtigungen deaktivieren" + +msgid "poll_percentage" +msgstr "Aktueller Anteil" + +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "Eine Stimmabgabe" +msgstr[1] "%s Stimmabgaben" + +msgid "poll_population_singular" +msgstr "Eine Stimmabgabe" + +msgid "poll_question" +msgstr "Frage" + +msgid "poll_result" +msgstr "Abstimmungsergebnisse" + +msgid "poll_result_answers" +msgstr "Stimmen" + +msgid "poll_result_sorting" +msgstr "Sortierung" + +msgid "poll_result_sorting_answers" +msgstr "In der Reihenfolge der Antworten" + +msgid "poll_result_sorting_votes" +msgstr "Nach Anzahl der Stimmen (absteigend)" + +msgid "poll_result_users" +msgstr "Pro Person" + +msgid "poll_show_results_as" +msgstr "Ergebnisse anzeigen als" + +msgid "poll_sortorder" +msgstr "Sortierung" + +msgid "poll_stacked_chart" +msgstr "Stapeldiagramm" + +msgid "poll_view_results" +msgstr "Ergebnisse anzeigen" + +msgid "poll_view_results_after_period" +msgstr "Nach Stimmperiode" + +msgid "poll_view_results_after_period_impossible" +msgstr "Damit die Abstimmungsergebnisse erst nach Ende der Stimmperiode gezeigt werden können, müssen Sie oben eine zeitlich begrenzte Stimmperiode einrichten." + +msgid "poll_view_results_after_vote" +msgstr "Nach Stimmabgabe" + +msgid "poll_view_results_always" +msgstr "Immer" + +msgid "poll_view_results_never" +msgstr "Niemals" + +msgid "poll_vote" +msgstr "Abstimmen" + +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Wählen Sie maximal %s Antworten aus." +msgstr[1] "Wählen Sie maximal %s Antworten aus." + +msgid "poll_vote_error_multi_no_answer" +msgstr "Wählen Sie mindestens eine Antwort aus." + +msgid "poll_vote_error_single" +msgstr "Wählen Sie eine Antwort aus." + +msgid "poll_vote_notification_body" +msgstr "die folgende Abstimmung hat eine Stimme erhalten" + +msgid "poll_vote_notification_link" +msgstr "Link zur Abstimmung" + +msgid "poll_vote_notification_reason" +msgstr "Sie erhalten diese Nachricht, da Sie die Benachrichtigungsfunktion für die oben genannte Abstimmung aktiviert haben." + +msgid "poll_vote_notification_subject" +msgstr "Abstimmung „%s“ – neue Stimmabgabe" + +msgid "poll_votes_no_edit" +msgstr "Diese Abstimmung beinhaltet bereits Stimmen. Sie kann nicht bearbeitet werden so lange Sie die Stimmen nicht löschen." + +msgid "poll_voting_period_and_results" +msgstr "Stimmperiode und Ergebnisse" + +msgid "poll_voting_period_ended_info" +msgstr "Das Ende der Abstimmung war %s." + +msgid "poll_voting_period_full_info" +msgstr "Die Abstimmung in dieser Umfrage ist vom %s bis zum %s möglich" + +msgid "poll_voting_period_info" +msgstr "Die Frist für die Abstimmung endet am %s." + +msgid "poll_voting_period_limited" +msgstr "Zeitlich begrenzte Stimmperiode" diff --git a/components/ILIAS/Poll/lang/poll_el.po b/components/ILIAS/Poll/lang/poll_el.po new file mode 100644 index 000000000000..8604c0fbfc95 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_el.po @@ -0,0 +1,285 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: el\n" +"Language-Team: Greek\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_en.po b/components/ILIAS/Poll/lang/poll_en.po new file mode 100644 index 000000000000..d8036c07ceb7 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_en.po @@ -0,0 +1,217 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" +"Language-Team: English\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Current Votes" + +msgid "poll_activation_online_info" +msgstr "Make this poll accessible to users." + +msgid "poll_add" +msgstr "Add Poll" + +msgid "poll_anonymous_warning" +msgstr "This is an anonymous poll. Individual votes are recorded, but your name will not be shown in the poll results." + +msgid "poll_answer" +msgstr "Answer" + +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Possible Answers" + +msgid "poll_barchart" +msgstr "Bar chart" + +msgid "poll_block_message_already_voted" +msgstr "You have already voted in this poll." + +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +msgid "poll_block_results_available_on" +msgstr "The results will be available: %s." + +msgid "poll_cannot_set_online_no_answers" +msgstr "This poll cannot be set to "Online" because it doesn’t have a question!" + +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "Copy Poll" + +msgid "poll_delete_votes" +msgstr "Delete All Votes Cast" + +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete the votes of all users who have taken part in this poll?" + +msgid "poll_edit" +msgstr "Edit Poll" + +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Image" + +msgid "poll_limit_not_below_answer_count" +msgstr "The maximum number of selectable answer options must be smaller than the total number of possible answers." + +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers Selectable" + +msgid "poll_max_number_of_answers" +msgstr "Maximum Selectable per Participant" + +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Mode" + +msgid "poll_mode_anonymous" +msgstr "Without names / anonymous" + +msgid "poll_mode_anonymous_info" +msgstr "The names of poll participants are not shown in the 'Results' tab. Please note: participant user IDs are, however, recorded in the database." + +msgid "poll_mode_personal" +msgstr "With names" + +msgid "poll_mode_personal_info" +msgstr "The names of poll participants and their respective answers are listed in the ‘Results’ tab. Users with access to this tab can inspect who has given which answer." + +msgid "poll_new" +msgstr "Add New Poll" + +msgid "poll_non_anonymous_warning" +msgstr "Your name and how you have voted will be visible to anyone with administration permissions for this poll." + +msgid "poll_notification_activated" +msgstr "Notifications activated." + +msgid "poll_notification_deactivated" +msgstr "Notifications deactivated" + +msgid "poll_notification_subscribe" +msgstr "Activate Notifications" + +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notifications" + +msgid "poll_percentage" +msgstr "Current Percentage" + +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "One vote cast" +msgstr[1] "%s votes cast" + +msgid "poll_population_singular" +msgstr "One vote cast" + +msgid "poll_question" +msgstr "Question" + +msgid "poll_result" +msgstr "Results" + +msgid "poll_result_answers" +msgstr "Votes" + +msgid "poll_result_sorting" +msgstr "Presentation Order" + +msgid "poll_result_sorting_answers" +msgstr "In the same order as the possible answers" + +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +msgid "poll_result_users" +msgstr "Participants" + +msgid "poll_show_results_as" +msgstr "Results Format" + +msgid "poll_sortorder" +msgstr "Order" + +msgid "poll_stacked_chart" +msgstr "Stacked chart" + +msgid "poll_view_results" +msgstr "Show Results" + +msgid "poll_view_results_after_period" +msgstr "When voting period has ended" + +msgid "poll_view_results_after_period_impossible" +msgstr "In order for the results to be first shown when the voting period has ended, you need to establish a limited voting period by entering dates above." + +msgid "poll_view_results_after_vote" +msgstr "After participant has voted" + +msgid "poll_view_results_always" +msgstr "Always" + +msgid "poll_view_results_never" +msgstr "Never" + +msgid "poll_vote" +msgstr "Vote" + +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least one of the answers." + +msgid "poll_vote_error_single" +msgstr "Please select one of the answers." + +msgid "poll_vote_notification_body" +msgstr "A vote has been cast in the following poll:" + +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +msgid "poll_vote_notification_reason" +msgstr "You have received this e-mail because you activated notifications for the poll mentioned above." + +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\" – new vote cast" + +msgid "poll_votes_no_edit" +msgstr "This poll has already received votes. You cannot edit the poll unless you delete these votes." + +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Voting in this poll will be possible from %s until %s" + +msgid "poll_voting_period_info" +msgstr "The deadline for voting is %s." + +msgid "poll_voting_period_limited" +msgstr "Limited Voting Period" diff --git a/components/ILIAS/Poll/lang/poll_es.po b/components/ILIAS/Poll/lang/poll_es.po new file mode 100644 index 000000000000..eb8461acc5f2 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_es.po @@ -0,0 +1,221 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: es\n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Votos actuales" + +msgid "poll_activation_online_info" +msgstr "Active esta configuración para que la encuesta sea accesible a los usuarios." + +msgid "poll_add" +msgstr "Añadir encuesta" + +msgid "poll_anonymous_warning" +msgstr "Los votos individuales se registran, pero su nombre no se mostrará en los resultados de la encuesta." + +msgid "poll_answer" +msgstr "Respuesta" + +msgid "poll_answer_selected_alt_text" +msgstr "Seleccionado" + +msgid "poll_answers" +msgstr "Respuestas posibles" + +msgid "poll_barchart" +msgstr "Gráfico de barras" + +msgid "poll_block_message_already_voted" +msgstr "Ya ha votado en esta encuesta." + +msgid "poll_block_message_no_answers" +msgstr "Esta encuesta está incompleta." + +msgid "poll_block_results_available_on" +msgstr "Los resultados estarán disponibles el %s." + +msgid "poll_cannot_set_online_no_answers" +msgstr "¡El estado no se puede cambiar a "en línea" porque esta encuesta no tiene pregunta!" + +msgid "poll_chart_votes" +msgstr "Votos" + +msgid "poll_comments" +msgstr "Comentarios" + +msgid "poll_copy" +msgstr "Copiar Encuesta" + +msgid "poll_delete_votes" +msgstr "Eliminar Votos de Todos los Usuarios" + +msgid "poll_delete_votes_sure" +msgstr "¿Está seguro de que desea eliminar los votos de todos los usuarios que participaron en esta encuesta?" + +msgid "poll_edit" +msgstr "Editar Encuesta" + +msgid "poll_edit_question" +msgstr "Editar Pregunta" + +msgid "poll_image" +msgstr "Imagen" + +msgid "poll_limit_not_below_answer_count" +msgstr "El límite de respuestas debe ser inferior al número de respuestas posibles." + +msgid "poll_limit_number_of_answers" +msgstr "Limitar Número de Respuestas por Participante" + +msgid "poll_max_number_of_answers" +msgstr "Máx. Respuestas" + +msgid "poll_max_number_of_answers_info" +msgstr "Puede elegir hasta %s respuestas." + +msgid "poll_mode" +msgstr "Modo" + +msgid "poll_mode_anonymous" +msgstr "Sin Nombres / Anónimo" + +msgid "poll_mode_anonymous_info" +msgstr "Los nombres de los participantes de la encuesta no se muestran en la pestaña 'Resultados'. Tenga en cuenta: los ID de usuario de los participantes, sin embargo, se registran en la base de datos." + +msgid "poll_mode_personal" +msgstr "Con Nombres" + +msgid "poll_mode_personal_info" +msgstr "En la pestaña 'Resultados' se enumeran los nombres de los participantes y sus respectivas respuestas. Los usuarios con acceso a esta pestaña pueden inspeccionar quién ha dado cada respuesta." + +msgid "poll_new" +msgstr "Añadir Nueva Encuesta" + +msgid "poll_non_anonymous_warning" +msgstr "Su nombre y voto son visibles para los administradores en los resultados." + +msgid "poll_notification_activated" +msgstr "Notificación Activada" + +msgid "poll_notification_deactivated" +msgstr "Notificación Desactivada" + +msgid "poll_notification_subscribe" +msgstr "Activar Notificación" + +msgid "poll_notification_unsubscribe" +msgstr "Desactivar Notificación" + +msgid "poll_percentage" +msgstr "Porcentaje Actual" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "1 Participante" +msgstr[1] "%s Participantes" +msgstr[2] "%s Participantes" + +msgid "poll_population_singular" +msgstr "1 Participante" + +msgid "poll_question" +msgstr "Pregunta" + +msgid "poll_result" +msgstr "Resultados" + +msgid "poll_result_answers" +msgstr "Votos" + +msgid "poll_result_sorting" +msgstr "Ordenación" + +msgid "poll_result_sorting_answers" +msgstr "En el orden de las respuestas" + +msgid "poll_result_sorting_votes" +msgstr "Por número de votos (descendente)" + +msgid "poll_result_users" +msgstr "Participantes" + +msgid "poll_show_results_as" +msgstr "Mostrar resultados como" + +msgid "poll_sortorder" +msgstr "Orden" + +msgid "poll_stacked_chart" +msgstr "Gráfico apilado" + +msgid "poll_view_results" +msgstr "Mostrar resultados" + +msgid "poll_view_results_after_period" +msgstr "Después del periodo de votación" + +msgid "poll_view_results_after_period_impossible" +msgstr "El periodo de votación no está limitado." + +msgid "poll_view_results_after_vote" +msgstr "Después de votar" + +msgid "poll_view_results_always" +msgstr "Siempre" + +msgid "poll_view_results_never" +msgstr "Nunca" + +msgid "poll_vote" +msgstr "Votar" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Por favor, seleccione no más de %s respuestas." +msgstr[1] "Por favor, seleccione no más de %s respuestas." +msgstr[2] "Por favor, seleccione no más de %s respuestas." + +msgid "poll_vote_error_multi_no_answer" +msgstr "Por favor, seleccione al menos 1 respuesta." + +msgid "poll_vote_error_single" +msgstr "Por favor, seleccione 1 respuesta." + +msgid "poll_vote_notification_body" +msgstr "la siguiente encuesta ha recibido un voto." + +msgid "poll_vote_notification_link" +msgstr "Enlace a la encuesta" + +msgid "poll_vote_notification_reason" +msgstr "Usted recibe este correo electrónico porque activó las notificaciones para la encuesta mencionada anteriormente." + +msgid "poll_vote_notification_subject" +msgstr "Encuesta \"%s\": nuevo voto" + +msgid "poll_votes_no_edit" +msgstr "Esta encuesta ya contiene votos. No puede editar la encuesta hasta que elimine estos votos." + +msgid "poll_voting_period_and_results" +msgstr "Periodo de votación y resultados" + +msgid "poll_voting_period_ended_info" +msgstr "El periodo de votación terminó el %s." + +msgid "poll_voting_period_full_info" +msgstr "Periodo de votación del %s al %s" + +msgid "poll_voting_period_info" +msgstr "La fecha límite para votar es el %s." + +msgid "poll_voting_period_limited" +msgstr "Periodo de votación limitado" diff --git a/components/ILIAS/Poll/lang/poll_et.po b/components/ILIAS/Poll/lang/poll_et.po new file mode 100644 index 000000000000..86c12427a867 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_et.po @@ -0,0 +1,233 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: et\n" +"Language-Team: Estonian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Current Votes" + +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +msgid "poll_add" +msgstr "Add Poll" + +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Possible Answers" + +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "Copy Poll" + +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Mode" + +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +msgid "poll_mode_personal" +msgstr "Personal" + +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +msgid "poll_new" +msgstr "Add New Poll" + +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Question" + +msgid "poll_result" +msgstr "Voting Result" + +msgid "poll_result_answers" +msgstr "Votes" + +msgid "poll_result_sorting" +msgstr "Sorting" + +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +msgid "poll_result_users" +msgstr "Participants" + +msgid "poll_show_results_as" +msgstr "Show Results as" + +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Display Results" + +msgid "poll_view_results_after_period" +msgstr "After Voting Period" + +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +msgid "poll_view_results_always" +msgstr "Always" + +msgid "poll_view_results_never" +msgstr "Never" + +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_fa.po b/components/ILIAS/Poll/lang/poll_fa.po new file mode 100644 index 000000000000..a0fe71f7bcf7 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_fa.po @@ -0,0 +1,285 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fa\n" +"Language-Team: Persian (Farsi)\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_fr.po b/components/ILIAS/Poll/lang/poll_fr.po new file mode 100644 index 000000000000..e3ab1a8b7d06 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_fr.po @@ -0,0 +1,234 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fr\n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Votes Actuels" + +msgid "poll_activation_online_info" +msgstr "Lorsque hors-ligne, seuls les administrateurs peuvent voir et éditer le vote." + +msgid "poll_add" +msgstr "Ajouter Vote" + +msgid "poll_anonymous_warning" +msgstr "Votre nom n'est pas visible dans les résultats du sondage." + +msgid "poll_answer" +msgstr "Réponse" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Réponses Possibles" + +msgid "poll_barchart" +msgstr "Graphique à barres" + +msgid "poll_block_message_already_voted" +msgstr "Vous avez déjà voté." + +msgid "poll_block_message_no_answers" +msgstr "Ce vote est incomplet." + +msgid "poll_block_results_available_on" +msgstr "Les résultats seront disponibles le %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Commentaires" + +msgid "poll_copy" +msgstr "Copier Vote" + +msgid "poll_delete_votes" +msgstr "Supprimer Tous les Votes" + +msgid "poll_delete_votes_sure" +msgstr "Etes-vous sûr de vouloir supprimer tous les votes ?" + +msgid "poll_edit" +msgstr "Editer Vote" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Nombre maximum de réponses par participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Mode" + +msgid "poll_mode_anonymous" +msgstr "Anonyme" + +msgid "poll_mode_anonymous_info" +msgstr "Les votes ne peuvent pas être attribués aux participants." + +msgid "poll_mode_personal" +msgstr "Personnel" + +msgid "poll_mode_personal_info" +msgstr "Une liste de participants avec leurs votes respectifs est disponible à condition d’avoir suffisamment de permissions." + +msgid "poll_new" +msgstr "Ajouter Nouveau Vote" + +msgid "poll_non_anonymous_warning" +msgstr "Votre nom et le vote sont visibles pour les administrateurs dans les résultats." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Activer la notification" + +msgid "poll_notification_unsubscribe" +msgstr "Désactiver la notification" + +msgid "poll_percentage" +msgstr "Pourcentage Actuel" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" +msgstr[2] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Question" + +msgid "poll_result" +msgstr "Résultats du Vote" + +msgid "poll_result_answers" +msgstr "Votes" + +msgid "poll_result_sorting" +msgstr "Triage" + +msgid "poll_result_sorting_answers" +msgstr "Dans l’ordre des réponses" + +msgid "poll_result_sorting_votes" +msgstr "Par nombre de votes (décroissant)" + +msgid "poll_result_users" +msgstr "Participants" + +msgid "poll_show_results_as" +msgstr "Montrer les résultats sous forme de" + +msgid "poll_sortorder" +msgstr "Ordre" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Afficher Résultats" + +msgid "poll_view_results_after_period" +msgstr "Après Période de Vote" + +msgid "poll_view_results_after_period_impossible" +msgstr "La période de vote n’a pas de limite." + +msgid "poll_view_results_after_vote" +msgstr "Après Vote" + +msgid "poll_view_results_always" +msgstr "Toujours" + +msgid "poll_view_results_never" +msgstr "Jamais" + +msgid "poll_vote" +msgstr "Voter" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Veuillez ne pas sélectionnez plus de% réponses." +msgstr[1] "Veuillez ne pas sélectionnez plus de% réponses." +msgstr[2] "Veuillez ne pas sélectionnez plus de% réponses." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "veuillez sélectionner 1 réponse." + +msgid "poll_vote_notification_body" +msgstr "le sondage suivant a reçu un vote." + +msgid "poll_vote_notification_link" +msgstr "Lien vers sondage" + +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +msgid "poll_vote_notification_subject" +msgstr "Sondage «%s»: nouveau vote" + +msgid "poll_votes_no_edit" +msgstr "Des votes ont déjà été soumis. Vous ne pouvez pas éditer l'objet à moins de supprimer les votes soumis." + +msgid "poll_voting_period_and_results" +msgstr "Période de Vote et Résultats" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Période de vote de %s à %s" + +msgid "poll_voting_period_info" +msgstr "Date limite de vote : %s" + +msgid "poll_voting_period_limited" +msgstr "Limité" diff --git a/components/ILIAS/Poll/lang/poll_hr.po b/components/ILIAS/Poll/lang/poll_hr.po new file mode 100644 index 000000000000..52683b0a3057 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_hr.po @@ -0,0 +1,234 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hr\n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Trenutačni glasovi" + +msgid "poll_activation_online_info" +msgstr "Aktiviraj ovu postavku kako bi glasovanje bilo dostupno korisnicima." + +msgid "poll_add" +msgstr "Dodaj glasovanje" + +msgid "poll_anonymous_warning" +msgstr "Vaše ime nije vidljivo u rezultatima glasovanja." + +msgid "poll_answer" +msgstr "Odgovor" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Mogući odgovori" + +msgid "poll_barchart" +msgstr "Grafikon" + +msgid "poll_block_message_already_voted" +msgstr "Već ste glasali u ovom glasovanju." + +msgid "poll_block_message_no_answers" +msgstr "Ovo glasovanje je nepotpuno." + +msgid "poll_block_results_available_on" +msgstr "Rezultati će biti dostupni %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Komentari" + +msgid "poll_copy" +msgstr "Kopiraj glasovanje" + +msgid "poll_delete_votes" +msgstr "Izbriši sve glasove" + +msgid "poll_delete_votes_sure" +msgstr "Jeste li sigurni da želite izbrisati sve glasove?" + +msgid "poll_edit" +msgstr "Uredi glasovanje" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Slika" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Maksimalan broj odgovora po sudioniku" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Način rada" + +msgid "poll_mode_anonymous" +msgstr "Anonimno" + +msgid "poll_mode_anonymous_info" +msgstr "Glasovi se ne mogu pratiti unatrag do sudionika." + +msgid "poll_mode_personal" +msgstr "Osobno" + +msgid "poll_mode_personal_info" +msgstr "Popis sudionika s njihovim glasovima dostupan je kad postoji odgovarajuće odobrenje." + +msgid "poll_new" +msgstr "Dodaj novo glasovanje" + +msgid "poll_non_anonymous_warning" +msgstr "Vaše ime i glas vidljivi su za administratore u rezultatima." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Aktiviraj obavijest" + +msgid "poll_notification_unsubscribe" +msgstr "Deaktiviraj obavijest" + +msgid "poll_percentage" +msgstr "Trenutni postotak" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s sudionika" +msgstr[1] "%s sudionika" +msgstr[2] "%s sudionika" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Pitanje" + +msgid "poll_result" +msgstr "Rezultati" + +msgid "poll_result_answers" +msgstr "Glasovi" + +msgid "poll_result_sorting" +msgstr "Sortiranje" + +msgid "poll_result_sorting_answers" +msgstr "Redoslijedom odgovora" + +msgid "poll_result_sorting_votes" +msgstr "Po broju glasova (silazno)" + +msgid "poll_result_users" +msgstr "Sudionici" + +msgid "poll_show_results_as" +msgstr "Prikaži rezultate kao" + +msgid "poll_sortorder" +msgstr "Narudžba" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Prikaži rezultate" + +msgid "poll_view_results_after_period" +msgstr "Nakon razdoblja glasanja" + +msgid "poll_view_results_after_period_impossible" +msgstr "Razdoblje glasanja nije ograničeno." + +msgid "poll_view_results_after_vote" +msgstr "Nakon glasanja" + +msgid "poll_view_results_always" +msgstr "Uvijek" + +msgid "poll_view_results_never" +msgstr "Nikada" + +msgid "poll_vote" +msgstr "Glasanje" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Molimo odaberite najviše %s odgovora." +msgstr[1] "Molimo odaberite najviše %s odgovora." +msgstr[2] "Molimo odaberite najviše %s odgovora." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Odaberite 1 odgovor." + +msgid "poll_vote_notification_body" +msgstr "sljedeće glasovanje je dobilo jedan glas." + +msgid "poll_vote_notification_link" +msgstr "Poveznica na glasovanje" + +msgid "poll_vote_notification_reason" +msgstr "Ovu e-poštu primate jer ste aktivirali obavijesti za gore spomenuto glasovanje." + +msgid "poll_vote_notification_subject" +msgstr "Glasovanje \"%s\": novi glas" + +msgid "poll_votes_no_edit" +msgstr "Ovo glasovanje već sadrži glasove. Ne možete uređivati glasovanje dok ne izbrišete ove glasove." + +msgid "poll_voting_period_and_results" +msgstr "Razdoblje glasanja i rezultati" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Razdoblje glasanja od %s do %s" + +msgid "poll_voting_period_info" +msgstr "Rok za glasanje: %s" + +msgid "poll_voting_period_limited" +msgstr "Ograničeno razdoblje glasanja" diff --git a/components/ILIAS/Poll/lang/poll_hu.po b/components/ILIAS/Poll/lang/poll_hu.po new file mode 100644 index 000000000000..1aea9a054b67 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_hu.po @@ -0,0 +1,218 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hu\n" +"Language-Team: Hungarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Szavazatok száma" + +msgid "poll_activation_online_info" +msgstr "Kapcsolja be, hogy a szavazást elérhessék a felhasználók." + +msgid "poll_add" +msgstr "Szavazás létrehozása" + +msgid "poll_anonymous_warning" +msgstr "Ez egy névtelen szavazás. A szavazatát rögzítjük, de a neve nem fog megjelenni a szavazás eredményében." + +msgid "poll_answer" +msgstr "Válasz" + +msgid "poll_answer_selected_alt_text" +msgstr "Kiválasztott" + +msgid "poll_answers" +msgstr "Lehetséges válaszok" + +msgid "poll_barchart" +msgstr "Oszlopdiagram" + +msgid "poll_block_message_already_voted" +msgstr "Már szavazott ebben a szavazásban." + +msgid "poll_block_message_no_answers" +msgstr "Ez a szavazás még nem készült el teljesen." + +msgid "poll_block_results_available_on" +msgstr "Az eredmény elérhető ettől: %s." + +msgid "poll_cannot_set_online_no_answers" +msgstr "Az állapot nem módosítható "online"-ra, mert egy kérdés sincs még a szavazásban!" + +msgid "poll_chart_votes" +msgstr "Szavazatok" + +msgid "poll_comments" +msgstr "Nyilvános megjegyzések" + +msgid "poll_copy" +msgstr "Szavazás másolása" + +msgid "poll_delete_votes" +msgstr "Összes leadott szavazat törlése" + +msgid "poll_delete_votes_sure" +msgstr "Biztos, hogy törli az összes szavazatot?" + +msgid "poll_edit" +msgstr "Szavazás módosítása" + +msgid "poll_edit_question" +msgstr "Kérdés módosítása" + +msgid "poll_image" +msgstr "Kép" + +msgid "poll_limit_not_below_answer_count" +msgstr "A válaszok megengedett maximális száma kevesebb kell, hogy legyen, mint a válaszlehetősége száma." + +msgid "poll_limit_number_of_answers" +msgstr "Résztvevőkénti válaszok számának korlátozása" + +msgid "poll_max_number_of_answers" +msgstr "Részvevőnkénti válaszok maximális száma" + +msgid "poll_max_number_of_answers_info" +msgstr "Maximálisan %s választ adhat." + +msgid "poll_mode" +msgstr "Mód" + +msgid "poll_mode_anonymous" +msgstr "Névtelen" + +msgid "poll_mode_anonymous_info" +msgstr "A szavazás résztvevőinek neve nem jelenik meg az ‘Eredmények’ lapon. Kérjük, vegye figyelembe: a résztvevők felhasználói azonosítóit azonban rögzítjük az adatbázisban." + +msgid "poll_mode_personal" +msgstr "Nevesített" + +msgid "poll_mode_personal_info" +msgstr "A szavazás résztvevőinek nevei és válaszai az ‘Eredmények’ lapon szerepelnek. Az ehhez a laphoz hozzáféréssel rendelkező felhasználók ellenőrizhetik, hogy ki melyik választ adta." + +msgid "poll_new" +msgstr "Új szavazás létrehozása" + +msgid "poll_non_anonymous_warning" +msgstr "A szavazás szerkesztői megtekintheti a szavazók neveit." + +msgid "poll_notification_activated" +msgstr "Értesítések bekapcsolva" + +msgid "poll_notification_deactivated" +msgstr "Értesítések kikapcsolva" + +msgid "poll_notification_subscribe" +msgstr "Értesítés bekapcsolása" + +msgid "poll_notification_unsubscribe" +msgstr "Értesítés kikapcsolása" + +msgid "poll_percentage" +msgstr "Szavazatok aránya" + +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "1 leadott szavazat" +msgstr[1] "%s leadott szavazat" + +msgid "poll_population_singular" +msgstr "1 leadott szavazat" + +msgid "poll_question" +msgstr "Kérdés" + +msgid "poll_result" +msgstr "Végeredmények" + +msgid "poll_result_answers" +msgstr "Szavazatok" + +msgid "poll_result_sorting" +msgstr "Rendezés" + +msgid "poll_result_sorting_answers" +msgstr "Lehetséges válaszok sorrendjében" + +msgid "poll_result_sorting_votes" +msgstr "Szavazatok száma szerint ↓" + +msgid "poll_result_users" +msgstr "Résztvevők" + +msgid "poll_show_results_as" +msgstr "Eredmények formátuma" + +msgid "poll_sortorder" +msgstr "Sorrend" + +msgid "poll_stacked_chart" +msgstr "Halmozott diagram" + +msgid "poll_view_results" +msgstr "Eredmények megjelenítése" + +msgid "poll_view_results_after_period" +msgstr "Szavazási időszak után" + +msgid "poll_view_results_after_period_impossible" +msgstr "Ahhoz, hogy az eredmények csak a szavazási időszak végén jelenjenek meg, a fenti dátumok megadásával a szavazási időszakot korlátoznia kell." + +msgid "poll_view_results_after_vote" +msgstr "Szavazás után" + +msgid "poll_view_results_always" +msgstr "Mindig" + +msgid "poll_view_results_never" +msgstr "Soha" + +msgid "poll_vote" +msgstr "Szavazás" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Ne válasszon többet, mint %s." +msgstr[1] "Ne válasszon többet, mint %s." + +msgid "poll_vote_error_multi_no_answer" +msgstr "Legalább 1 választ jelöljön meg." + +msgid "poll_vote_error_single" +msgstr "Egyet válassz." + +msgid "poll_vote_notification_body" +msgstr "ezúton tájékoztatjuk, hogy új szavazat érkezett." + +msgid "poll_vote_notification_link" +msgstr "Link a Szavazásra" + +msgid "poll_vote_notification_reason" +msgstr "Ezt a levelet azért kapta, mert fent említett szavazásnál beállította, hogy kér értesítést." + +msgid "poll_vote_notification_subject" +msgstr "‘%s’ szavazás: új szavazat" + +msgid "poll_votes_no_edit" +msgstr "Ez a szavazás már tartalmaz szavazatokat. A szavazás nem módosítható, míg nem törli a szavazatokat." + +msgid "poll_voting_period_and_results" +msgstr "Szavazási időszak és eredmények" + +msgid "poll_voting_period_ended_info" +msgstr "A szavazási időszak véget ért %s." + +msgid "poll_voting_period_full_info" +msgstr "Szavazási időszak: %s → %s" + +msgid "poll_voting_period_info" +msgstr "Szavazás határideje: %s" + +msgid "poll_voting_period_limited" +msgstr "Korlátozott" diff --git a/components/ILIAS/Poll/lang/poll_it.po b/components/ILIAS/Poll/lang/poll_it.po new file mode 100644 index 000000000000..6f3f894730f1 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_it.po @@ -0,0 +1,234 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: it\n" +"Language-Team: Italian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Voti correnti" + +msgid "poll_activation_online_info" +msgstr "Se selezionato rendera' possibile agli utenti al sondaggio" + +msgid "poll_add" +msgstr "Aggiungi sondaggio" + +msgid "poll_anonymous_warning" +msgstr "Il tuo nome non è visibile nel gruppo di risultati." + +msgid "poll_answer" +msgstr "Risposta" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Risposte possibili" + +msgid "poll_barchart" +msgstr "Diagramma a barre" + +msgid "poll_block_message_already_voted" +msgstr "Hai già votato in questo sondaggio" + +msgid "poll_block_message_no_answers" +msgstr "Questo sondaggio non è completo" + +msgid "poll_block_results_available_on" +msgstr "I risultati saranno disponibili il %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Commenti" + +msgid "poll_copy" +msgstr "Copia sondaggio" + +msgid "poll_delete_votes" +msgstr "Cancella tutte le votazioni" + +msgid "poll_delete_votes_sure" +msgstr "Sei sicureo di voler cancellare tutte le votazioni?" + +msgid "poll_edit" +msgstr "Modifica sondaggio" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Immagine" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Numero massimo di risposte per partecipante" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Modalità" + +msgid "poll_mode_anonymous" +msgstr "Anonimo" + +msgid "poll_mode_anonymous_info" +msgstr "I voti non possono essere ricondotti ai partecipanti." + +msgid "poll_mode_personal" +msgstr "Personale" + +msgid "poll_mode_personal_info" +msgstr "Un elenco dei partecipanti con i rispettivi voti è disponibile quando si dispone di un permesso sufficiente." + +msgid "poll_new" +msgstr "Aggiungi nuovo sondaggio" + +msgid "poll_non_anonymous_warning" +msgstr "Il tuo nome e il tuo voto sono visibili agli amministratori nei risultati." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Attiva notifica" + +msgid "poll_notification_unsubscribe" +msgstr "Disattiva notifica" + +msgid "poll_percentage" +msgstr "Percentuale corrente" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s partecipanti" +msgstr[1] "%s partecipanti" +msgstr[2] "%s partecipanti" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Domanda" + +msgid "poll_result" +msgstr "Risultati della votazione" + +msgid "poll_result_answers" +msgstr "Voti" + +msgid "poll_result_sorting" +msgstr "Ordinamento" + +msgid "poll_result_sorting_answers" +msgstr "Nell’ordine delle risposte" + +msgid "poll_result_sorting_votes" +msgstr "Per numero di voti (decrescente)" + +msgid "poll_result_users" +msgstr "Partecipanti" + +msgid "poll_show_results_as" +msgstr "Mostra risultati come" + +msgid "poll_sortorder" +msgstr "Ordinamento" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Mostra i rislutati" + +msgid "poll_view_results_after_period" +msgstr "Periodo post voto" + +msgid "poll_view_results_after_period_impossible" +msgstr "Il periodo di votazione non è limitato." + +msgid "poll_view_results_after_vote" +msgstr "Dopo voto" + +msgid "poll_view_results_always" +msgstr "Sempre" + +msgid "poll_view_results_never" +msgstr "Mai" + +msgid "poll_vote" +msgstr "Vota" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Si preda di non selezionare più di %s risposte." +msgstr[1] "Si preda di non selezionare più di %s risposte." +msgstr[2] "Si preda di non selezionare più di %s risposte." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Si prega di selezionare 1 risposta." + +msgid "poll_vote_notification_body" +msgstr "il seguente sondaggio ha ricevuto un voto." + +msgid "poll_vote_notification_link" +msgstr "Collega al sondaggio" + +msgid "poll_vote_notification_reason" +msgstr "Stai ricevendo questa e-mail perché hai attivato le notifiche per il sondaggio sopra menzionato." + +msgid "poll_vote_notification_subject" +msgstr "Sondaggio \"%s\": nuovo voto" + +msgid "poll_votes_no_edit" +msgstr "Questo sondaggio contiene già delle voci. Non potra' essere modificato finche' le voci non saranno cancellate." + +msgid "poll_voting_period_and_results" +msgstr "Periodo di voto e risultati" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Periodo di votazione da %s a %s" + +msgid "poll_voting_period_info" +msgstr "Scadenza per la votazione: %s" + +msgid "poll_voting_period_limited" +msgstr "Limitato" diff --git a/components/ILIAS/Poll/lang/poll_ja.po b/components/ILIAS/Poll/lang/poll_ja.po new file mode 100644 index 000000000000..2d799a6f1111 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_ja.po @@ -0,0 +1,215 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ja\n" +"Language-Team: Japanese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "現在の投票" + +msgid "poll_activation_online_info" +msgstr "オフラインの場合は管理者のみしか投票の閲覧と編集はできません。" + +msgid "poll_add" +msgstr "投票を追加" + +msgid "poll_anonymous_warning" +msgstr "投票結果の中にあなたの名前はありません。" + +msgid "poll_answer" +msgstr "回答" + +msgid "poll_answer_selected_alt_text" +msgstr "選択済" + +msgid "poll_answers" +msgstr "予想回答" + +msgid "poll_barchart" +msgstr "棒グラフ" + +msgid "poll_block_message_already_voted" +msgstr "この投票にすでに投票済です。" + +msgid "poll_block_message_no_answers" +msgstr "投票は完了しました。" + +msgid "poll_block_results_available_on" +msgstr "結果は%sに判明します。" + +msgid "poll_cannot_set_online_no_answers" +msgstr "この問題集には問題がないため、ステータスを "online" へ変更できません。" + +msgid "poll_chart_votes" +msgstr "投票" + +msgid "poll_comments" +msgstr "コメント" + +msgid "poll_copy" +msgstr "投票をコピー" + +msgid "poll_delete_votes" +msgstr "全投票を削除" + +msgid "poll_delete_votes_sure" +msgstr "本当に全ての投票を削除しますか?" + +msgid "poll_edit" +msgstr "投票を編集" + +msgid "poll_edit_question" +msgstr "問題編集を編集" + +msgid "poll_image" +msgstr "イメージ" + +msgid "poll_limit_not_below_answer_count" +msgstr "回答期限は以下の回答可能な数字である必要があります。" + +msgid "poll_limit_number_of_answers" +msgstr "受験者当たりの回答制限回数" + +msgid "poll_max_number_of_answers" +msgstr "最大回答数" + +msgid "poll_max_number_of_answers_info" +msgstr "回答数は %s まで選択できます。" + +msgid "poll_mode" +msgstr "モード" + +msgid "poll_mode_anonymous" +msgstr "匿名" + +msgid "poll_mode_anonymous_info" +msgstr "投票は参加者を追跡調査できません。" + +msgid "poll_mode_personal" +msgstr "パーソナル" + +msgid "poll_mode_personal_info" +msgstr "適切なアクセス権があればそれぞれの投票付き参加者リストは利用可能です。" + +msgid "poll_new" +msgstr "新しい投票を追加" + +msgid "poll_non_anonymous_warning" +msgstr "結果の中にあるあなたの名前と投票は管理者へ表示されます。" + +msgid "poll_notification_activated" +msgstr "通知を有効にしました。" + +msgid "poll_notification_deactivated" +msgstr "通知を無効にしました。" + +msgid "poll_notification_subscribe" +msgstr "通知を有効化" + +msgid "poll_notification_unsubscribe" +msgstr "通知を無効化" + +msgid "poll_percentage" +msgstr "現在のパーセント" + +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s 投票者" + +msgid "poll_population_singular" +msgstr "1 受験者" + +msgid "poll_question" +msgstr "質問" + +msgid "poll_result" +msgstr "結果" + +msgid "poll_result_answers" +msgstr "投票" + +msgid "poll_result_sorting" +msgstr "ソート" + +msgid "poll_result_sorting_answers" +msgstr "回答順" + +msgid "poll_result_sorting_votes" +msgstr "投票数順(降順)" + +msgid "poll_result_users" +msgstr "参加者" + +msgid "poll_show_results_as" +msgstr "結果表示" + +msgid "poll_sortorder" +msgstr "順" + +msgid "poll_stacked_chart" +msgstr "積み上げグラフ" + +msgid "poll_view_results" +msgstr "結果表示" + +msgid "poll_view_results_after_period" +msgstr "投票期間後" + +msgid "poll_view_results_after_period_impossible" +msgstr "投票期間の期限はありません。" + +msgid "poll_view_results_after_vote" +msgstr "投票後" + +msgid "poll_view_results_always" +msgstr "常時" + +msgid "poll_view_results_never" +msgstr "無" + +msgid "poll_vote" +msgstr "投票" + +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "回答数%s以上を選択してください。" + +msgid "poll_vote_error_multi_no_answer" +msgstr "回答を一つは選択してください。" + +msgid "poll_vote_error_single" +msgstr "回答を一つ選択してください。" + +msgid "poll_vote_notification_body" +msgstr "次の投票結果が投票から得られました。" + +msgid "poll_vote_notification_link" +msgstr "投票結果へリンク" + +msgid "poll_vote_notification_reason" +msgstr "上記の投票結果説明の通知機能を有効にしているためこのメールを受信します。" + +msgid "poll_vote_notification_subject" +msgstr "投票\"%s\": 新規投票" + +msgid "poll_votes_no_edit" +msgstr "この投票は投票中のため編集できません。" + +msgid "poll_voting_period_and_results" +msgstr "投票期間と結果" + +msgid "poll_voting_period_ended_info" +msgstr "投票期間は %s に終了しました。" + +msgid "poll_voting_period_full_info" +msgstr "%sから%sの投票期間" + +msgid "poll_voting_period_info" +msgstr "投票期限: %s" + +msgid "poll_voting_period_limited" +msgstr "期限限定" diff --git a/components/ILIAS/Poll/lang/poll_ka.po b/components/ILIAS/Poll/lang/poll_ka.po new file mode 100644 index 000000000000..85e78af37917 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_ka.po @@ -0,0 +1,258 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ka\n" +"Language-Team: Georgian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Current Votes" + +msgid "poll_activation_online_info" +msgstr "Activate this setting to make the poll accessible to users." + +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "Copy Poll" + +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Question" + +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Display Results" + +msgid "poll_view_results_after_period" +msgstr "After Voting Period" + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +msgid "poll_view_results_always" +msgstr "Always" + +msgid "poll_view_results_never" +msgstr "Never" + +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +msgid "poll_votes_no_edit" +msgstr "This poll already contains votes. You cannot edit the poll until you delete these votes." + +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_lt.po b/components/ILIAS/Poll/lang/poll_lt.po new file mode 100644 index 000000000000..899b08244df1 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_lt.po @@ -0,0 +1,287 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: lt\n" +"Language-Team: Lithuanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && (n % 100 < 11 || n % 100 > 19)) ? 0 : ((n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? 1 : 2);\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" +msgstr[2] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." +msgstr[2] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_nl.po b/components/ILIAS/Poll/lang/poll_nl.po new file mode 100644 index 000000000000..9c7dcb648351 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_nl.po @@ -0,0 +1,234 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: nl\n" +"Language-Team: Dutch\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Huidige stemmen" + +msgid "poll_activation_online_info" +msgstr "Indien deze instelling actief is, is de poll beschikbaar voor alle gebruikers." + +msgid "poll_add" +msgstr "Toevoegen poll" + +msgid "poll_anonymous_warning" +msgstr "Je naam is niet zichtbaar is de poll resultaten." + +msgid "poll_answer" +msgstr "Stem" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Mogelijke stemmen" + +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "Je hebt al gestemd in deze poll." + +msgid "poll_block_message_no_answers" +msgstr "De poll is niet volledig." + +msgid "poll_block_results_available_on" +msgstr "De resultaten zullen beschikbaar zijn op %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Commentaar" + +msgid "poll_copy" +msgstr "kopiëren Poll" + +msgid "poll_delete_votes" +msgstr "Verwijder alle stemmen" + +msgid "poll_delete_votes_sure" +msgstr "Weet je zeker dat je alle stemmen wilt verwijderen?" + +msgid "poll_edit" +msgstr "Wijzigen Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Afbeelding" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Maximaal aantal antwoorden per deelnemer" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Modus" + +msgid "poll_mode_anonymous" +msgstr "Anoniem" + +msgid "poll_mode_anonymous_info" +msgstr "Stemmen kunnen niet worden herleid naar deelnemers" + +msgid "poll_mode_personal" +msgstr "Persoonlijk" + +msgid "poll_mode_personal_info" +msgstr "Een lijst met deelnemers en hun stemmen is beschikbaar als je voldoende rechten hebt" + +msgid "poll_new" +msgstr "Toevoegen nieuwe poll" + +msgid "poll_non_anonymous_warning" +msgstr "Je naam en stem zijn zichtbaar voor beheerders" + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Inschakelen berichtgeving" + +msgid "poll_notification_unsubscribe" +msgstr "Uitschakelen berichtgeving" + +msgid "poll_percentage" +msgstr "Huidig Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Stemmen" +msgstr[1] "%s Stemmen" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Vraag" + +msgid "poll_result" +msgstr "Stemresultaten" + +msgid "poll_result_answers" +msgstr "Stemmen" + +msgid "poll_result_sorting" +msgstr "Volgorde" + +msgid "poll_result_sorting_answers" +msgstr "In de volgorde van de antwoorden" + +msgid "poll_result_sorting_votes" +msgstr "Per aantal stemmen (aflopend)" + +msgid "poll_result_users" +msgstr "Deelnemers" + +msgid "poll_show_results_as" +msgstr "Toon resultaten als" + +msgid "poll_sortorder" +msgstr "Volgorde" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Tonen resultaten" + +msgid "poll_view_results_after_period" +msgstr "Na stemperiode" + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "Na het stemmen" + +msgid "poll_view_results_always" +msgstr "Altijd" + +msgid "poll_view_results_never" +msgstr "Nooit" + +msgid "poll_vote" +msgstr "Stemmen" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Niet meer dan %s antwoorden selecteren" +msgstr[1] "Niet meer dan %s antwoorden selecteren" + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Selecteer één antwoord" + +msgid "poll_vote_notification_body" +msgstr "Er is een stem uitgebracht voor de volgende pool." + +msgid "poll_vote_notification_link" +msgstr "Koppeling naar poll" + +msgid "poll_vote_notification_reason" +msgstr "Je ontvangt deze e-mail omdat je berichtgeving voor de poll hierboven hebt ingeschakeld." + +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": nieuwe stem" + +msgid "poll_votes_no_edit" +msgstr "Deze poll kan niet worden verwijderd vanwege bestaande stemmen." + +msgid "poll_voting_period_and_results" +msgstr "Stemperiode and resultaten" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +msgid "poll_voting_period_info" +msgstr "Deadline om te stemmen: %s" + +msgid "poll_voting_period_limited" +msgstr "Beperkt" diff --git a/components/ILIAS/Poll/lang/poll_pl.po b/components/ILIAS/Poll/lang/poll_pl.po new file mode 100644 index 000000000000..5ce62b2b6538 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_pl.po @@ -0,0 +1,234 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pl\n" +"Language-Team: Polish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Aktualne głosy" + +msgid "poll_activation_online_info" +msgstr "Wybierz to ustawienie, aby udostępnić głosowanie użytkownikom." + +msgid "poll_add" +msgstr "Utwórz głosowanie" + +msgid "poll_anonymous_warning" +msgstr "Twoja nazwa nie jest wyświetlana w wynikach głosowania." + +msgid "poll_answer" +msgstr "Odpowiedź" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Możliwe odpowiedzi" + +msgid "poll_barchart" +msgstr "Wykres słupkowy" + +msgid "poll_block_message_already_voted" +msgstr "Już zagłosowałeś." + +msgid "poll_block_message_no_answers" +msgstr "To głosowanie nie jest kompletne." + +msgid "poll_block_results_available_on" +msgstr "Wyniki opublikowano w dniu %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Komentarze" + +msgid "poll_copy" +msgstr "Kopiuj głosowanie" + +msgid "poll_delete_votes" +msgstr "Usuń wszystkie głosy" + +msgid "poll_delete_votes_sure" +msgstr "Jesteś pewien, że chcesz usunąć wszystkie głosy?" + +msgid "poll_edit" +msgstr "Edytuj głosowanie" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Obraz" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Maksymalna liczba odpowiedzi na każdego uczestnika" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Tryb" + +msgid "poll_mode_anonymous" +msgstr "Anonim" + +msgid "poll_mode_anonymous_info" +msgstr "Oddanych głosów nie można przydzielić do uczestników." + +msgid "poll_mode_personal" +msgstr "Spersonalizowane" + +msgid "poll_mode_personal_info" +msgstr "Lista uczestników, którzy oddali głosy, dostępna jest w wystarczającymi uprawnieniami." + +msgid "poll_new" +msgstr "Utwórz nowe głosowanie" + +msgid "poll_non_anonymous_warning" +msgstr "Twoja nazwa i oddany głos widoczne są w wynikach głosowania dla administratorów." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Aktywowanie powiadamiania" + +msgid "poll_notification_unsubscribe" +msgstr "Deaktywowanie powiadamiania" + +msgid "poll_percentage" +msgstr "Aktualna zawartość" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s uczestników" +msgstr[1] "%s uczestników" +msgstr[2] "%s uczestników" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Pytanie" + +msgid "poll_result" +msgstr "Wyniki głosowania" + +msgid "poll_result_answers" +msgstr "Głosy" + +msgid "poll_result_sorting" +msgstr "Sortowanie" + +msgid "poll_result_sorting_answers" +msgstr "W kolejności odpowiedzi" + +msgid "poll_result_sorting_votes" +msgstr "Wg liczby głosów (malejąco)" + +msgid "poll_result_users" +msgstr "Uczestnicy" + +msgid "poll_show_results_as" +msgstr "Pokaż wyniki jako" + +msgid "poll_sortorder" +msgstr "Sortowanie" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Pokaż wyniki" + +msgid "poll_view_results_after_period" +msgstr "Po okresie głosowania" + +msgid "poll_view_results_after_period_impossible" +msgstr "Okres głosowania nie jest ograniczony czasowo." + +msgid "poll_view_results_after_vote" +msgstr "Po oddaniu głosu" + +msgid "poll_view_results_always" +msgstr "Zawsze" + +msgid "poll_view_results_never" +msgstr "Nigdy" + +msgid "poll_vote" +msgstr "Głosuj" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Wybierz maksymalnie %s odpowiedzi." +msgstr[1] "Wybierz maksymalnie %s odpowiedzi." +msgstr[2] "Wybierz maksymalnie %s odpowiedzi." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Wybierz odpowiedź." + +msgid "poll_vote_notification_body" +msgstr "w poniższym głosowaniu oddano jeden głos" + +msgid "poll_vote_notification_link" +msgstr "Link do głosowania" + +msgid "poll_vote_notification_reason" +msgstr "Otrzymałeś tę wiadomość, ponieważ aktywowałeś funkcję powiadamiania dla powyżej wymienionego głosowania." + +msgid "poll_vote_notification_subject" +msgstr "Głosowanie \"%s\": nowy głos" + +msgid "poll_votes_no_edit" +msgstr "To głosowanie zawiera już głosy. Nie można go edytować do momentu usunięcia głosów." + +msgid "poll_voting_period_and_results" +msgstr "Okres głosowania i wyniki" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Głosowanie od %s do %s" + +msgid "poll_voting_period_info" +msgstr "Koniec głosowania: %s" + +msgid "poll_voting_period_limited" +msgstr "Czasowo ograniczony okres głosowania" diff --git a/components/ILIAS/Poll/lang/poll_pt.po b/components/ILIAS/Poll/lang/poll_pt.po new file mode 100644 index 000000000000..fd82b6293529 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_pt.po @@ -0,0 +1,234 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pt\n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Votos atuais" + +msgid "poll_activation_online_info" +msgstr "A transferir dados de rastreio para ILIAS. Aguarde um momento." + +msgid "poll_add" +msgstr "Adicionar sondagem" + +msgid "poll_anonymous_warning" +msgstr "O seu nome não está visível nos resultados da sondagem." + +msgid "poll_answer" +msgstr "Resposta" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Possíveis respostas" + +msgid "poll_barchart" +msgstr "Gráfico de barras" + +msgid "poll_block_message_already_voted" +msgstr "Já votou nesta sondagem." + +msgid "poll_block_message_no_answers" +msgstr "Esta sondagem está incompleta." + +msgid "poll_block_results_available_on" +msgstr "Os resultados estão disponíveis em %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Comentários" + +msgid "poll_copy" +msgstr "Copiar sondagem" + +msgid "poll_delete_votes" +msgstr "Apagar todos os votos" + +msgid "poll_delete_votes_sure" +msgstr "Tem a certeza que quer apagar todos os votos?" + +msgid "poll_edit" +msgstr "Editar sondagem" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Imagem" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Número máximo de respostas por participante" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Modo" + +msgid "poll_mode_anonymous" +msgstr "Anónimo" + +msgid "poll_mode_anonymous_info" +msgstr "Os votos não podem ser associados aos participantes." + +msgid "poll_mode_personal" +msgstr "Pessoal" + +msgid "poll_mode_personal_info" +msgstr "Uma lista de participantes com os respetivos votos está disponível se tiver autorização suficiente." + +msgid "poll_new" +msgstr "Adicionar nova sondagem" + +msgid "poll_non_anonymous_warning" +msgstr "O seu nome e voto estão visíveis aos administradores nos resultados." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Ativar notificação" + +msgid "poll_notification_unsubscribe" +msgstr "Desativar notificação" + +msgid "poll_percentage" +msgstr "Percentagem atual" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "A transferência dados de rastreio foi bem-sucedida." +msgstr[1] "A transferência dados de rastreio foi bem-sucedida." +msgstr[2] "A transferência dados de rastreio foi bem-sucedida." + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Pergunta" + +msgid "poll_result" +msgstr "Importar dados de rastreio" + +msgid "poll_result_answers" +msgstr "Votos" + +msgid "poll_result_sorting" +msgstr "Ordenar" + +msgid "poll_result_sorting_answers" +msgstr "Pela ordem das respostas" + +msgid "poll_result_sorting_votes" +msgstr "Por número de votos (descendente)" + +msgid "poll_result_users" +msgstr "Participantes" + +msgid "poll_show_results_as" +msgstr "Mostrar resultados como" + +msgid "poll_sortorder" +msgstr "Ordem" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Exibir resultados" + +msgid "poll_view_results_after_period" +msgstr "Exportar módulos de aprendizagem SCORM para uso offline" + +msgid "poll_view_results_after_period_impossible" +msgstr "O período de votação não está limitado." + +msgid "poll_view_results_after_vote" +msgstr "Após o voto" + +msgid "poll_view_results_always" +msgstr "Sempre" + +msgid "poll_view_results_never" +msgstr "Nunca" + +msgid "poll_vote" +msgstr "Votar" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Não selecione mais do que %s respostas." +msgstr[1] "Não selecione mais do que %s respostas." +msgstr[2] "Não selecione mais do que %s respostas." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Selecione 1 resposta." + +msgid "poll_vote_notification_body" +msgstr "a seguinte sondagem recebeu um voto." + +msgid "poll_vote_notification_link" +msgstr "Link para sondagem" + +msgid "poll_vote_notification_reason" +msgstr "As variáveis less foram atualizadas. Importante: Note que o caching pode impedir que as suas alterações sejam exibidas. Pode precisar de limpar o cache do seu navegador." + +msgid "poll_vote_notification_subject" +msgstr "Sondagem \"%s\": novo voto" + +msgid "poll_votes_no_edit" +msgstr "Iniciar gestor offline SCORM" + +msgid "poll_voting_period_and_results" +msgstr "Período de votação e resultados" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Período de votação de %s até %s" + +msgid "poll_voting_period_info" +msgstr "Prazo para votar: %s" + +msgid "poll_voting_period_limited" +msgstr "Todos os estilos selecionados são obrigatórios e não podem ser apagados." diff --git a/components/ILIAS/Poll/lang/poll_ro.po b/components/ILIAS/Poll/lang/poll_ro.po new file mode 100644 index 000000000000..77889849d8ac --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_ro.po @@ -0,0 +1,287 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ro\n" +"Language-Team: Romanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2);\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" +msgstr[2] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." +msgstr[2] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_ru.po b/components/ILIAS/Poll/lang/poll_ru.po new file mode 100644 index 000000000000..8095c38b4bc9 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_ru.po @@ -0,0 +1,259 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ru\n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Количество голосов" + +msgid "poll_activation_online_info" +msgstr "Сделать голосование доступным для пользователей" + +msgid "poll_add" +msgstr "Добавить голосование" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +msgid "poll_answer" +msgstr "Ответ" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Возможные ответы" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "Вы уже проголосовали." + +msgid "poll_block_message_no_answers" +msgstr "Голосование определено не полностью." + +msgid "poll_block_results_available_on" +msgstr "Результаты буду доступны %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "Копировать голосование" + +msgid "poll_delete_votes" +msgstr "Удалить все голоса" + +msgid "poll_delete_votes_sure" +msgstr "Вы уверены что хотите удалить все голоса?" + +msgid "poll_edit" +msgstr "Редактировать голосование" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Изображение" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +msgid "poll_new" +msgstr "Добавить новое голосование" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +msgid "poll_percentage" +msgstr "Текущий процент" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s голосов" +msgstr[1] "%s голосов" +msgstr[2] "%s голосов" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Вопросы" + +msgid "poll_result" +msgstr "Результаты голосования" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +msgid "poll_sortorder" +msgstr "Сортировка" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Показать результаты" + +msgid "poll_view_results_after_period" +msgstr "После периода голосования" + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "После голосования" + +msgid "poll_view_results_always" +msgstr "Всегда" + +msgid "poll_view_results_never" +msgstr "Никогда" + +msgid "poll_vote" +msgstr "Голосовать" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." +msgstr[2] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +msgid "poll_votes_no_edit" +msgstr "Голосование уже имеет проголосовавших. Вы не можете редактировать голосование пока не удалите эти голоса." + +msgid "poll_voting_period_and_results" +msgstr "Период голосования и результаты" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +msgid "poll_voting_period_limited" +msgstr "Ограничен" diff --git a/components/ILIAS/Poll/lang/poll_sk.po b/components/ILIAS/Poll/lang/poll_sk.po new file mode 100644 index 000000000000..db3617a50168 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_sk.po @@ -0,0 +1,287 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sk\n" +"Language-Team: Slovak\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" +msgstr[2] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." +msgstr[2] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_sl.po b/components/ILIAS/Poll/lang/poll_sl.po new file mode 100644 index 000000000000..3350bdd1030e --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_sl.po @@ -0,0 +1,236 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sl\n" +"Language-Team: Slovenian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=4; plural=(n % 100 == 1) ? 0 : ((n % 100 == 2) ? 1 : ((n % 100 == 3 || n % 100 == 4) ? 2 : 3));\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Aktualni glasovi" + +msgid "poll_activation_online_info" +msgstr "Izberite to nastavitev, da bo glasovanje na voljo uporabnikom." + +msgid "poll_add" +msgstr "Ustvari glasovanje" + +msgid "poll_anonymous_warning" +msgstr "Vaše ime ne bo prikazano v rezultatih glasovanja." + +msgid "poll_answer" +msgstr "Odgovor" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Možni odgovori" + +msgid "poll_barchart" +msgstr "Palični diagram" + +msgid "poll_block_message_already_voted" +msgstr "Svoj glas ste že oddali." + +msgid "poll_block_message_no_answers" +msgstr "To glasovanje ni popolno." + +msgid "poll_block_results_available_on" +msgstr "Rezultati bodo objavljeni dne %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Komentarji" + +msgid "poll_copy" +msgstr "Kopiraj glasovanje" + +msgid "poll_delete_votes" +msgstr "Izbriši vse glasove" + +msgid "poll_delete_votes_sure" +msgstr "Ali ste prepričani, da želite izbrisati vse glasove?" + +msgid "poll_edit" +msgstr "Uredi glasovanje" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Slika" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Maksimalno število odgovorov na udeleženca" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Način" + +msgid "poll_mode_anonymous" +msgstr "Anonimno" + +msgid "poll_mode_anonymous_info" +msgstr "Oddanih glasov ni mogoče dodeliti udeležencem." + +msgid "poll_mode_personal" +msgstr "Personalizirano" + +msgid "poll_mode_personal_info" +msgstr "Seznam udeležencev z oddanimi glasovi je na voljo z ustreznimi pravicami." + +msgid "poll_new" +msgstr "Ustvari novo glasovanje" + +msgid "poll_non_anonymous_warning" +msgstr "Vaše ime in glas sta v rezultatih glasovanja vidna administratorjem." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr "Aktiviraj obveščanje" + +msgid "poll_notification_unsubscribe" +msgstr "Onemogoči obveščanje" + +msgid "poll_percentage" +msgstr "Aktualni delež" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s udeleženec" +msgstr[1] "%s udeleženec" +msgstr[2] "%s udeleženec" +msgstr[3] "%s udeleženec" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Vprašanje" + +msgid "poll_result" +msgstr "Rezultati glasovanja" + +msgid "poll_result_answers" +msgstr "Glasovi" + +msgid "poll_result_sorting" +msgstr "Razvrstitev" + +msgid "poll_result_sorting_answers" +msgstr "Po vrstnem redu odgovorov" + +msgid "poll_result_sorting_votes" +msgstr "Po številu glasov (padajoče)" + +msgid "poll_result_users" +msgstr "Udeleženec" + +msgid "poll_show_results_as" +msgstr "Prikaži rezultate kot" + +msgid "poll_sortorder" +msgstr "Razvrstitev" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Prikaži rezultate" + +msgid "poll_view_results_after_period" +msgstr "Po obdobju glasovanja" + +msgid "poll_view_results_after_period_impossible" +msgstr "Obdobje glasovanja ni časovno omejeno." + +msgid "poll_view_results_after_vote" +msgstr "Po oddanem glasu" + +msgid "poll_view_results_always" +msgstr "Vedno" + +msgid "poll_view_results_never" +msgstr "Nikoli" + +msgid "poll_vote" +msgstr "Glasuj" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Izberite maksimalno %s odgovorov." +msgstr[1] "Izberite maksimalno %s odgovorov." +msgstr[2] "Izberite maksimalno %s odgovorov." +msgstr[3] "Izberite maksimalno %s odgovorov." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Izberite en odgovor." + +msgid "poll_vote_notification_body" +msgstr "naslednje glasovanje je prejelo en glas" + +msgid "poll_vote_notification_link" +msgstr "Povezava do glasovanja" + +msgid "poll_vote_notification_reason" +msgstr "To sporočilo ste prejeli, ker ste aktivirali obveščanje za zgoraj navedeno glasovanje." + +msgid "poll_vote_notification_subject" +msgstr "Glasovanje \"%s\": nov glas" + +msgid "poll_votes_no_edit" +msgstr "To glasovanje že vsebuje glasove. Glasovanja ne morete urejati, dokler ne izbrišete teh glasov." + +msgid "poll_voting_period_and_results" +msgstr "Obdobje glasovanja in rezultati" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Glasovanje od %s do %s" + +msgid "poll_voting_period_info" +msgstr "Konec glasovanja: %s" + +msgid "poll_voting_period_limited" +msgstr "Časovno omejeno obdobje glasovanja" diff --git a/components/ILIAS/Poll/lang/poll_sq.po b/components/ILIAS/Poll/lang/poll_sq.po new file mode 100644 index 000000000000..e685df921724 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_sq.po @@ -0,0 +1,285 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sq\n" +"Language-Team: Albanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_sr.po b/components/ILIAS/Poll/lang/poll_sr.po new file mode 100644 index 000000000000..659846d35a2d --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_sr.po @@ -0,0 +1,287 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sr\n" +"Language-Team: Serbian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" +msgstr[2] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." +msgstr[2] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_sv.po b/components/ILIAS/Poll/lang/poll_sv.po new file mode 100644 index 000000000000..49ecb8a71523 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_sv.po @@ -0,0 +1,232 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sv\n" +"Language-Team: Swedish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Aktuella röster" + +msgid "poll_activation_online_info" +msgstr "Välj denna inställning för att göra röstning tillgänglig för användare." + +msgid "poll_add" +msgstr "Skapa omröstning" + +msgid "poll_anonymous_warning" +msgstr "Ditt namn visas inte i röstningsresultaten." + +msgid "poll_answer" +msgstr "Svar" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Möjliga svar" + +msgid "poll_barchart" +msgstr "Balkdiagram" + +msgid "poll_block_message_already_voted" +msgstr "Du har redan röstat." + +msgid "poll_block_message_no_answers" +msgstr "Denna omröstning är inte slutförd." + +msgid "poll_block_results_available_on" +msgstr "Resultaten kommer att publiceras på %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +msgid "poll_comments" +msgstr "Kommentarer" + +msgid "poll_copy" +msgstr "Kopiera omröstning" + +msgid "poll_delete_votes" +msgstr "Radera alla röster" + +msgid "poll_delete_votes_sure" +msgstr "Är du säker på att du vill radera alla röster?" + +msgid "poll_edit" +msgstr "Redigera omröstning" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +msgid "poll_max_number_of_answers" +msgstr "Maximalt antal svar per deltagare" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +msgid "poll_mode" +msgstr "Mode" + +msgid "poll_mode_anonymous" +msgstr "Anonym" + +msgid "poll_mode_anonymous_info" +msgstr "Avgivna Votes kan inte tilldelas deltagare." + +msgid "poll_mode_personal" +msgstr "Personligt anpassad" + +msgid "poll_mode_personal_info" +msgstr "En lista över deltagare med avgivna röster är tillgänglig med tillräckliga rättigheter." + +msgid "poll_new" +msgstr "Skapa ny omröstning" + +msgid "poll_non_anonymous_warning" +msgstr "Ditt namn och din röst syns i omröstningsresultaten för administratörer." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +msgid "poll_notification_subscribe" +msgstr " Aktivera avisering" + +msgid "poll_notification_unsubscribe" +msgstr "Avaktivera avisering" + +msgid "poll_percentage" +msgstr "Aktuell andel" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s deltagare" +msgstr[1] "%s deltagare" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Fråga" + +msgid "poll_result" +msgstr "Röstningsresultat" + +msgid "poll_result_answers" +msgstr "Voices" + +msgid "poll_result_sorting" +msgstr "Sortering" + +msgid "poll_result_sorting_answers" +msgstr "I den ordning som svaren kommer" + +msgid "poll_result_sorting_votes" +msgstr "Efter antal röster (fallande)" + +msgid "poll_result_users" +msgstr "Deltagare" + +msgid "poll_show_results_as" +msgstr "Visa resultat som" + +msgid "poll_sortorder" +msgstr "Sortering" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Visa resultat" + +msgid "poll_view_results_after_period" +msgstr "Efter omröstningsperiod" + +msgid "poll_view_results_after_period_impossible" +msgstr "Omröstningsperioden är inte tidsbegränsad." + +msgid "poll_view_results_after_vote" +msgstr "Efter omröstning" + +msgid "poll_view_results_always" +msgstr "Alltid" + +msgid "poll_view_results_never" +msgstr "Aldrig" + +msgid "poll_vote" +msgstr "Rösta" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] " Välj högst %s svar." +msgstr[1] " Välj högst %s svar." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +msgid "poll_vote_error_single" +msgstr "Välj ett svar." + +msgid "poll_vote_notification_body" +msgstr "Följande omröstning har fått en röst" + +msgid "poll_vote_notification_link" +msgstr "Länk till omröstningen" + +msgid "poll_vote_notification_reason" +msgstr "Du får detta meddelande eftersom du har aktiverat meddelandefunktionen för ovanstående omröstning." + +msgid "poll_vote_notification_subject" +msgstr "Röstning \"%s\"" + +msgid "poll_votes_no_edit" +msgstr "Denna omröstning innehåller redan röster. Den kan inte redigeras så länge du inte tar bort rösterna." + +msgid "poll_voting_period_and_results" +msgstr "Röstningsperiod och resultat" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +msgid "poll_voting_period_full_info" +msgstr "Röstning från %s till %s" + +msgid "poll_voting_period_info" +msgstr "Slut på omröstningen" + +msgid "poll_voting_period_limited" +msgstr "Tidsbegränsad omröstningsperiod" diff --git a/components/ILIAS/Poll/lang/poll_tr.po b/components/ILIAS/Poll/lang/poll_tr.po new file mode 100644 index 000000000000..662a37da1a5a --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_tr.po @@ -0,0 +1,258 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: tr\n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "Geçerli Oy" + +msgid "poll_activation_online_info" +msgstr "kullanıcıların anket erişilebilir yapmak için bu ayarı etkinleştirin." + +msgid "poll_add" +msgstr "Anket Ekle" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +msgid "poll_answer" +msgstr "Yanıt" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "Olası Yanıtlar" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "Zaten bu Ankette oy kullandı." + +msgid "poll_block_message_no_answers" +msgstr "Bu anket eksiktir." + +msgid "poll_block_results_available_on" +msgstr "sonuçları% s mevcut olacaktır." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "Kopya Anket" + +msgid "poll_delete_votes" +msgstr "Tüm Oyları sil" + +msgid "poll_delete_votes_sure" +msgstr "tüm oy silmek istediğinizden emin misiniz?" + +msgid "poll_edit" +msgstr "Düzenleme Anket" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "Görüntü" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +msgid "poll_new" +msgstr "Ekle Yeni Anket" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +msgid "poll_percentage" +msgstr "Geçerli Yüzde" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "% s Oy" +msgstr[1] "% s Oy" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "Soru" + +msgid "poll_result" +msgstr "Oylama Sonucu" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +msgid "poll_sortorder" +msgstr "Sıra" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "Ekran Sonuçlar" + +msgid "poll_view_results_after_period" +msgstr "Oy Dönemi Sonrası" + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "Oy sonra" + +msgid "poll_view_results_always" +msgstr "Her Zaman" + +msgid "poll_view_results_never" +msgstr "Asla" + +msgid "poll_vote" +msgstr "Oy" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +msgid "poll_votes_no_edit" +msgstr "Bu anket oy içerir. Bu oy silmek kadar anketi düzenleyemezsiniz." + +msgid "poll_voting_period_and_results" +msgstr "Oy Dönemi ve Sonuçlar" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +msgid "poll_voting_period_limited" +msgstr "Sınırlı" diff --git a/components/ILIAS/Poll/lang/poll_uk.po b/components/ILIAS/Poll/lang/poll_uk.po new file mode 100644 index 000000000000..edb37cb86787 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_uk.po @@ -0,0 +1,287 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: uk\n" +"Language-Team: Ukrainian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" +msgstr[1] "%s Votes" +msgstr[2] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." +msgstr[1] "Please select no more than %s answers." +msgstr[2] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_vi.po b/components/ILIAS/Poll/lang/poll_vi.po new file mode 100644 index 000000000000..7660a940233a --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_vi.po @@ -0,0 +1,283 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: vi\n" +"Language-Team: Vietnamese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: poll\n" + +#, fuzzy +msgid "poll_absolute" +msgstr "Current Votes" + +#, fuzzy +msgid "poll_activation_online_info" +msgstr "If offline, only administrators can see and edit the poll." + +#, fuzzy +msgid "poll_add" +msgstr "Add Poll" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +#, fuzzy +msgid "poll_answer" +msgstr "Answer" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +#, fuzzy +msgid "poll_answers" +msgstr "Possible Answers" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +#, fuzzy +msgid "poll_block_message_already_voted" +msgstr "You already voted in this poll." + +#, fuzzy +msgid "poll_block_message_no_answers" +msgstr "This poll is incomplete." + +#, fuzzy +msgid "poll_block_results_available_on" +msgstr "The results will be available on %s." + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +#, fuzzy +msgid "poll_copy" +msgstr "Copy Poll" + +#, fuzzy +msgid "poll_delete_votes" +msgstr "Delete All Votes" + +#, fuzzy +msgid "poll_delete_votes_sure" +msgstr "Are you sure you want to delete all votes?" + +#, fuzzy +msgid "poll_edit" +msgstr "Edit Poll" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +#, fuzzy +msgid "poll_image" +msgstr "Image" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +#, fuzzy +msgid "poll_new" +msgstr "Add New Poll" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +#, fuzzy +msgid "poll_percentage" +msgstr "Current Percentage" + +#, fuzzy +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s Votes" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +#, fuzzy +msgid "poll_question" +msgstr "Question" + +#, fuzzy +msgid "poll_result" +msgstr "Voting Result" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +#, fuzzy +msgid "poll_sortorder" +msgstr "Order" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +#, fuzzy +msgid "poll_view_results" +msgstr "Display Results" + +#, fuzzy +msgid "poll_view_results_after_period" +msgstr "After Voting Period " + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +#, fuzzy +msgid "poll_view_results_after_vote" +msgstr "After Vote" + +#, fuzzy +msgid "poll_view_results_always" +msgstr "Always" + +#, fuzzy +msgid "poll_view_results_never" +msgstr "Never" + +#, fuzzy +msgid "poll_vote" +msgstr "Vote" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +#, fuzzy +msgid "poll_votes_no_edit" +msgstr "This poll cannot be edited because of existing votes." + +#, fuzzy +msgid "poll_voting_period_and_results" +msgstr "Voting Period and Results" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +#, fuzzy +msgid "poll_voting_period_limited" +msgstr "Limited" diff --git a/components/ILIAS/Poll/lang/poll_zh.po b/components/ILIAS/Poll/lang/poll_zh.po new file mode 100644 index 000000000000..4760c86b38d3 --- /dev/null +++ b/components/ILIAS/Poll/lang/poll_zh.po @@ -0,0 +1,255 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: zh\n" +"Language-Team: Chinese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: poll\n" + +msgid "poll_absolute" +msgstr "当前投票" + +msgid "poll_activation_online_info" +msgstr "如果离线,仅管理员能看和编辑投票。" + +msgid "poll_add" +msgstr "添加投票" + +#, fuzzy +msgid "poll_anonymous_warning" +msgstr "Your name is not visible in the poll results." + +msgid "poll_answer" +msgstr "答案" + +#, fuzzy +msgid "poll_answer_selected_alt_text" +msgstr "Selected" + +msgid "poll_answers" +msgstr "可能的答案" + +#, fuzzy +msgid "poll_barchart" +msgstr "Bar Chart" + +msgid "poll_block_message_already_voted" +msgstr "您已经在这个调查中投票。" + +msgid "poll_block_message_no_answers" +msgstr "这个投票是不完整的。" + +msgid "poll_block_results_available_on" +msgstr "这个结果将在 %s 可用。" + +#, fuzzy +msgid "poll_cannot_set_online_no_answers" +msgstr "The status cannot be changed to "online" because this poll has no question!" + +#, fuzzy +msgid "poll_chart_votes" +msgstr "Votes" + +#, fuzzy +msgid "poll_comments" +msgstr "Comments" + +msgid "poll_copy" +msgstr "复制投票" + +msgid "poll_delete_votes" +msgstr "删除所有投票" + +msgid "poll_delete_votes_sure" +msgstr "您确定想删除所有投票吗?" + +msgid "poll_edit" +msgstr "编辑投票" + +#, fuzzy +msgid "poll_edit_question" +msgstr "Edit Question" + +msgid "poll_image" +msgstr "图像" + +#, fuzzy +msgid "poll_limit_not_below_answer_count" +msgstr "The answer limit must be below the number of possible answers." + +#, fuzzy +msgid "poll_limit_number_of_answers" +msgstr "Limit Number of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers" +msgstr "Maximum Number Of Answers per Participant" + +#, fuzzy +msgid "poll_max_number_of_answers_info" +msgstr "You may choose up to %s answers." + +#, fuzzy +msgid "poll_mode" +msgstr "Mode" + +#, fuzzy +msgid "poll_mode_anonymous" +msgstr "Anonymous" + +#, fuzzy +msgid "poll_mode_anonymous_info" +msgstr "Votes cannot be traced back to participants." + +#, fuzzy +msgid "poll_mode_personal" +msgstr "Personal" + +#, fuzzy +msgid "poll_mode_personal_info" +msgstr "A list of participants with their respective votes is available when having sufficient permission." + +msgid "poll_new" +msgstr "添加新投票" + +#, fuzzy +msgid "poll_non_anonymous_warning" +msgstr "Your name and vote are visible for administrators in the results." + +#, fuzzy +msgid "poll_notification_activated" +msgstr "Notification Activated" + +#, fuzzy +msgid "poll_notification_deactivated" +msgstr "Notification Deactivated" + +#, fuzzy +msgid "poll_notification_subscribe" +msgstr "Activate Notification" + +#, fuzzy +msgid "poll_notification_unsubscribe" +msgstr "Deactivate Notification" + +msgid "poll_percentage" +msgstr "当前百分比" + +msgid "poll_population" +msgid_plural "poll_population_plural" +msgstr[0] "%s 投票" + +#, fuzzy +msgid "poll_population_singular" +msgstr "1 Participant" + +msgid "poll_question" +msgstr "题目" + +msgid "poll_result" +msgstr "投票结果" + +#, fuzzy +msgid "poll_result_answers" +msgstr "Votes" + +#, fuzzy +msgid "poll_result_sorting" +msgstr "Sorting" + +#, fuzzy +msgid "poll_result_sorting_answers" +msgstr "In the order of the answers" + +#, fuzzy +msgid "poll_result_sorting_votes" +msgstr "By number of votes (descending)" + +#, fuzzy +msgid "poll_result_users" +msgstr "Participants" + +#, fuzzy +msgid "poll_show_results_as" +msgstr "Show Results as" + +msgid "poll_sortorder" +msgstr "排序" + +#, fuzzy +msgid "poll_stacked_chart" +msgstr "Stacked Chart" + +msgid "poll_view_results" +msgstr "显示结果" + +msgid "poll_view_results_after_period" +msgstr "投票时间段之后" + +#, fuzzy +msgid "poll_view_results_after_period_impossible" +msgstr "The voting period is not limited." + +msgid "poll_view_results_after_vote" +msgstr "投票后" + +msgid "poll_view_results_always" +msgstr "总是" + +msgid "poll_view_results_never" +msgstr "从不" + +msgid "poll_vote" +msgstr "投票" + +#, fuzzy +msgid "poll_vote_error_multi" +msgid_plural "poll_vote_error_multi_plural" +msgstr[0] "Please select no more than %s answers." + +#, fuzzy +msgid "poll_vote_error_multi_no_answer" +msgstr "Please select at least 1 answer." + +#, fuzzy +msgid "poll_vote_error_single" +msgstr "Please select 1 answer." + +#, fuzzy +msgid "poll_vote_notification_body" +msgstr "the following poll has received a vote." + +#, fuzzy +msgid "poll_vote_notification_link" +msgstr "Link to Poll" + +#, fuzzy +msgid "poll_vote_notification_reason" +msgstr "You are receiving this email because you activated notifications for the poll mentioned above." + +#, fuzzy +msgid "poll_vote_notification_subject" +msgstr "Poll \"%s\": new vote" + +msgid "poll_votes_no_edit" +msgstr "这个调查投票不能被编辑,因为已经有投票了。" + +msgid "poll_voting_period_and_results" +msgstr "投票期和结果" + +#, fuzzy +msgid "poll_voting_period_ended_info" +msgstr "The voting period ended %s." + +#, fuzzy +msgid "poll_voting_period_full_info" +msgstr "Voting period from %s to %s" + +#, fuzzy +msgid "poll_voting_period_info" +msgstr "Deadline for Voting: %s" + +msgid "poll_voting_period_limited" +msgstr "已限制的" From dee2e3804394e9c8a6873edc9cfe9690ab3383c1 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:25:35 +0200 Subject: [PATCH 036/103] Language: Add translate() with generated LanguageIdentifier enums translate() reads an identifier given as LanguageIdentifier enum case from exactly its module, without loadLanguageModule(). A tool generates the enum of a module from its .pot; ntxt() reads its fallback module the same way. --- .../classes/Setup/class.ilSetupLanguage.php | 9 + .../Language/classes/class.ilLanguage.php | 221 +++++- .../ILIAS/Language/src/LanguageIdentifier.php | 31 + .../LanguageLegacyInitialisationAdapter.php | 18 + .../tests/LanguageIdentifierEnumToolTest.php | 520 +++++++++++++ components/ILIAS/Language/tests/NtxtTest.php | 12 + .../tests/TranslateSetupAndAdapterTest.php | 123 +++ .../ILIAS/Language/tests/TranslateTest.php | 698 ++++++++++++++++++ .../generate_language_identifier_enum.php | 412 +++++++++++ 9 files changed, 2009 insertions(+), 35 deletions(-) create mode 100644 components/ILIAS/Language/src/LanguageIdentifier.php create mode 100644 components/ILIAS/Language/tests/LanguageIdentifierEnumToolTest.php create mode 100644 components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php create mode 100644 components/ILIAS/Language/tests/TranslateTest.php create mode 100644 components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index 3bcc252b5986..b1e8bf014d02 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -23,6 +23,7 @@ use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync; +use ILIAS\Language\LanguageIdentifier; use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\LanguageInstallationManager; @@ -191,6 +192,14 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''): return $translation; } + /** + * Same as txt(); $n, $lang and the module of a LanguageIdentifier have no effect in Setup. + */ + public function translate(string|LanguageIdentifier $key, ?int $n = null, ?string $lang = null): string + { + return $this->txt($key instanceof LanguageIdentifier ? (string) $key->value : $key); + } + /** * install languages * diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index 18274d1b563d..39087e61cfc3 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -22,6 +22,7 @@ use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; use ILIAS\Language\ComponentTranslation\MigratedTranslations; +use ILIAS\Language\LanguageIdentifier; /** * language handling @@ -33,13 +34,10 @@ * e.g. $lng->txt("user_updated"); * you can translate a lang-topic into the actual language * - * Two coexisting backends per module: the legacy DB tables lng_data/lng_modules (for every module - * not migrated yet), and - for a module migrated for the requested language - its build served - * through native gettext with the overlay of the local changes on top (see MigratedTranslations), - * never the DB. The values of a migrated module do not go into $text: loadLanguageModule() records - * its identifiers in $migrated_key_modules, and txt() looks them up when asked. The DB path exists - * to be fully replaced and eventually removed as more modules migrate onto the file-based one, not - * to be maintained forever alongside it. + * Two backends: the legacy DB tables lng_data/lng_modules, and for a module migrated for the language its + * build served through native gettext plus the overlay of local changes (see MigratedTranslations), never the DB. + * Values of a migrated module do not go into $text: loadLanguageModule() records its identifiers in + * $migrated_key_modules and txt() looks them up on demand. * * @author Peter Gabriel * @version $Id$ @@ -80,6 +78,14 @@ class ilLanguage implements \ILIAS\Language\Language * @var array */ private static array $shipped_without_build = []; + + /** + * Identifier => value per module that is not migrated, so translate() serves the module a LanguageIdentifier + * names, not the one loaded last ($text). Shares the arrays of cached_modules/$text (copy-on-write). + * + * @var array> + */ + private array $module_texts = []; protected array $cached_modules = array(); protected array $map_modules_txt = array(); protected bool $usage_log_enabled = false; @@ -258,10 +264,16 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): /** * Plural form selected by the "Plural-Forms" rule (native gettext) for a plural message of a migrated * module, otherwise exactly txt(). The quantity is not inserted; callers do that, e.g. with sprintf(). + * + * A fallback module is read like translate() reads a LanguageIdentifier, language of this instance first, + * then the default language (#13467). */ public function ntxt(string $a_topic, int $a_n, string $a_default_lang_fallback_mod = ""): string { $module = $a_topic === '' ? null : ($this->migrated_key_modules[$a_topic] ?? null); + if ($module === null && $a_default_lang_fallback_mod !== '' && ($this->text[$a_topic] ?? '') === '') { + return $this->translateInModule($a_default_lang_fallback_mod, $a_topic, $a_n, $this->migratedLangKey()); + } $value = $module === null ? null : MigratedTranslations::pluralText($module, $this->migratedLangKey(), $a_topic, $a_n, self::clientDataDir()); @@ -290,13 +302,154 @@ public function exists(string $a_topic): bool } /** - * Load language module + * Successor of txt(), ntxt() and txtlng(). + * + * - A LanguageIdentifier is read from exactly its module, without loadLanguageModule() and without side + * effect on txt() & Co. (nothing goes into $text or the loaded modules). Falls back to the default + * language like txt() with a fallback module (#13467), otherwise "-identifier-". + * - A string without $lang behaves like txt($key) or ntxt($key, $n); the module loaded last wins. With another + * $lang exactly one lookup in the module responsible for $key, without default-language fallback (as txtlng()). + * + * A $lang that is no language key gives no text. An empty identifier (as for txt(): also "0") gives "". + * $n only selects a plural form of a migrated module. The quantity is not inserted; callers do that. */ - public function loadLanguageModule(string $a_module): void + public function translate(string|LanguageIdentifier $key, ?int $n = null, ?string $lang = null): string + { + if ($key instanceof LanguageIdentifier) { + return $this->translateInModule($key->module(), (string) $key->value, $n, $lang ?? $this->migratedLangKey()); + } + if ($lang === null || $lang === $this->migratedLangKey()) { + return $n === null ? $this->txt($key) : $this->ntxt($key, $n); + } + + return $this->translateInLanguage($key, $n, $lang); + } + + private function translateInModule(string $module, string $a_topic, ?int $n, string $lang_key): string + { + // like txt(""): never the header of a catalog + if (empty($a_topic)) { + return ''; + } + self::$used_topics[$a_topic] = $a_topic; + + $translation = $this->moduleText($module, $a_topic, $n, $lang_key); + $default_lang_key = $this->getDefaultLanguage(); + if ($translation === null && $lang_key !== $default_lang_key) { + $translation = $this->moduleText($module, $a_topic, $n, $default_lang_key); + } + + return $translation ?? $this->missingText($a_topic, $lang_key); + } + + private function translateInLanguage(string $a_topic, ?int $n, string $lang_key): string + { + if (empty($a_topic)) { + return ''; + } + self::$used_topics[$a_topic] = $a_topic; + + $module = $this->responsibleModule($a_topic); + + return ($module === null ? null : $this->moduleText($module, $a_topic, $n, $lang_key)) + ?? $this->missingText($a_topic, $lang_key); + } + + private function responsibleModule(string $a_topic): ?string + { + $module = $this->migrated_key_modules[$a_topic] ?? null; + if ($module !== null) { + return $module; + } + foreach (array_reverse($this->loaded_modules) as $loaded_module) { + if (array_key_exists($a_topic, $this->module_texts[$loaded_module] ?? [])) { + return (string) $loaded_module; + } + } + + return null; + } + + private function moduleText(string $module, string $a_topic, ?int $n, string $lang_key): ?string + { + if (!MigratedLanguageFilePaths::isValidLanguageKey($lang_key)) { + return null; + } + if (self::migratedKeysOf($module, $lang_key) !== null) { + $translation = $n === null + ? null + : MigratedTranslations::pluralText($module, $lang_key, $a_topic, $n, self::clientDataDir()); + if ($translation === null || $translation === '') { + $translation = MigratedTranslations::text($module, $lang_key, $a_topic, self::clientDataDir()); + } + } elseif ($lang_key === $this->migratedLangKey()) { + $translation = $this->moduleTexts($module, $lang_key)[$a_topic] ?? null; + } else { + $translation = self::lookupDatabaseEntry($lang_key, $module, $a_topic); + } + if (!is_string($translation) || $translation === '') { + return null; + } + + self::$used_modules[$module] = $module; + if ($this->usage_log_enabled) { + self::logUsage($module, $a_topic); + } + + return $translation; + } + + /** + * Without loading the module (see translate()). + * + * @return array + */ + private function moduleTexts(string $module, string $lang_key): array + { + if (!isset($this->module_texts[$module])) { + $cached = $this->cached_modules[$module] ?? null; + $this->module_texts[$module] = is_array($cached) ? $cached : (self::readModuleTexts($module, $lang_key) ?? []); + } + + return $this->module_texts[$module]; + } + + /** + * @return array|null + */ + private static function readModuleTexts(string $module, string $lang_key): ?array { global $DIC; $ilDB = $DIC->database(); + $q = "SELECT lang_array FROM lng_modules " . + "WHERE lang_key = " . $ilDB->quote($lang_key, "text") . " AND module = " . + $ilDB->quote($module, "text"); + $r = $ilDB->query($q); + $row = $r->fetchRow(ilDBConstants::FETCHMODE_ASSOC); + if ($row === false) { + return null; + } + $texts = unserialize($row["lang_array"], ["allowed_classes" => false]); + + return is_array($texts) ? $texts : null; + } + + private function missingText(string $a_topic, string $lang_key): string + { + if (ILIAS_LOG_ENABLED && is_object($this->log)) { + $lang_key = MigratedLanguageFilePaths::isValidLanguageKey($lang_key) ? $lang_key : 'invalid language key'; + $this->log->debug("Language (" . $lang_key . "): topic -" . $a_topic . "- not present"); + } + + return "-" . $a_topic . "-"; + } + + /** + * Load language module + */ + public function loadLanguageModule(string $a_module): void + { if (in_array($a_module, $this->loaded_modules, true)) { return; } @@ -322,6 +475,7 @@ public function loadLanguageModule(string $a_module): void } if (isset($this->cached_modules[$a_module]) && is_array($this->cached_modules[$a_module])) { + $this->module_texts[$a_module] = $this->cached_modules[$a_module]; $this->text = array_merge($this->text, $this->cached_modules[$a_module]); $this->forgetMigratedKeys($this->cached_modules[$a_module]); @@ -334,18 +488,9 @@ public function loadLanguageModule(string $a_module): void return; } - $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); - $row = $r->fetchRow(ilDBConstants::FETCHMODE_ASSOC); - - if ($row === false) { - return; - } - - $new_text = unserialize($row["lang_array"], ["allowed_classes" => false]); - if (is_array($new_text)) { + $new_text = self::readModuleTexts($a_module, $lang_key); + if ($new_text !== null) { + $this->module_texts[$a_module] = $new_text; $this->text = array_merge($this->text, $new_text); $this->forgetMigratedKeys($new_text); @@ -532,19 +677,8 @@ public static function _lookupEntry(string $a_lang_key, string $a_mod, string $a return $value; } - global $DIC; - $ilDB = $DIC->database(); - - $set = $ilDB->query($q = sprintf( - "SELECT value FROM lng_data WHERE module = %s " . - "AND lang_key = %s AND identifier = %s", - $ilDB->quote($a_mod, "text"), - $ilDB->quote($a_lang_key, "text"), - $ilDB->quote($a_id, "text") - )); - $rec = $ilDB->fetchAssoc($set); - - if (isset($rec["value"]) && $rec["value"] != "") { + $value = self::lookupDatabaseEntry($a_lang_key, $a_mod, $a_id); + if ($value !== null) { // remember the used topics self::$used_topics[$a_id] = $a_id; self::$used_modules[$a_mod] = $a_mod; @@ -553,12 +687,29 @@ public static function _lookupEntry(string $a_lang_key, string $a_mod, string $a self::logUsage($a_mod, $a_id); } - return $rec["value"]; + return $value; } return "-" . $a_id . "-"; } + private static function lookupDatabaseEntry(string $a_lang_key, string $a_mod, string $a_id): ?string + { + global $DIC; + $ilDB = $DIC->database(); + + $set = $ilDB->query(sprintf( + "SELECT value FROM lng_data WHERE module = %s " . + "AND lang_key = %s AND identifier = %s", + $ilDB->quote($a_mod, "text"), + $ilDB->quote($a_lang_key, "text"), + $ilDB->quote($a_id, "text") + )); + $rec = $ilDB->fetchAssoc($set); + + return isset($rec["value"]) && $rec["value"] != "" ? (string) $rec["value"] : null; + } + /** * Lookup obj_id of language */ diff --git a/components/ILIAS/Language/src/LanguageIdentifier.php b/components/ILIAS/Language/src/LanguageIdentifier.php new file mode 100644 index 000000000000..d4bb0e600ae2 --- /dev/null +++ b/components/ILIAS/Language/src/LanguageIdentifier.php @@ -0,0 +1,31 @@ +getLegacyLanguageInstance()->txt($a_topic, $a_default_lang_fallback_mod); } + /** + * Not part of the interface Language, so other implementations do not break; without translate() + * the identifier is looked up via txt() and $n/$lang have no effect. + */ + public function translate(string|LanguageIdentifier $key, ?int $n = null, ?string $lang = null): string + { + $language = $this->getLegacyLanguageInstance(); + if ($language instanceof \ilLanguage) { + return $language->translate($key, $n, $lang); + } + if ($key instanceof LanguageIdentifier) { + $language->loadLanguageModule($key->module()); + return $language->txt((string) $key->value); + } + + return $language->txt($key); + } + public function loadLanguageModule(string $a_module): void { $this->getLegacyLanguageInstance()->loadLanguageModule($a_module); diff --git a/components/ILIAS/Language/tests/LanguageIdentifierEnumToolTest.php b/components/ILIAS/Language/tests/LanguageIdentifierEnumToolTest.php new file mode 100644 index 000000000000..24a6210b0573 --- /dev/null +++ b/components/ILIAS/Language/tests/LanguageIdentifierEnumToolTest.php @@ -0,0 +1,520 @@ +directory = sys_get_temp_dir() . '/ilias_key_enum_tool_' . bin2hex(random_bytes(4)); + $this->root = $this->directory . '/repo'; + $tool_dir = $this->root . '/components/ILIAS/Language/tools/language-identifier-enum'; + mkdir($tool_dir, 0775, true); + mkdir($this->root . '/components/ILIAS/Demo/lang', 0775, true); + mkdir($this->root . '/components/ILIAS/Demo/src', 0775, true); + mkdir($this->directory . '/outside', 0775, true); + copy(self::TOOL, $tool_dir . '/generate_language_identifier_enum.php'); + mkdir($this->root . '/vendor/composer/vendor', 0775, true); + $real_autoload = realpath(__DIR__ . '/../../../../vendor/composer/vendor/autoload.php'); + $this->assertIsString($real_autoload); + symlink($real_autoload, $this->root . '/vendor/composer/vendor/autoload.php'); + } + + protected function tearDown(): void + { + MigratedPoFixture::removeDirectory($this->directory); + } + + /** @return array{int, string, string} exit code, stdout, stderr */ + private function runTool(string ...$arguments): array + { + $result = ChildPhp::run( + [ + '-d', 'error_reporting=-1', '-d', 'display_errors=stderr', + $this->root . '/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php', + ...$arguments, + ], + $this->root + ); + $this->assertNotNull($result, 'could not start the child PHP process'); + + return $result; + } + + /** @param list $messages msgids; "plural:" a plural message, "ctx::" one with a msgctxt */ + private function writePot(array $messages, string $header = "\"X-Domain: demo\\n\"\n", string $path = self::POT): void + { + $pot = "msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n" . $header . "\n"; + foreach ($messages as $message) { + if (str_starts_with($message, 'plural:')) { + $id = substr($message, 7); + $pot .= "msgid \"$id\"\nmsgid_plural \"{$id}s\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\n"; + } elseif (str_starts_with($message, 'ctx:')) { + [, $context, $id] = explode(':', $message, 3); + $pot .= "msgctxt \"$context\"\nmsgid \"$id\"\nmsgstr \"\"\n\n"; + } else { + $pot .= "msgid \"$message\"\nmsgstr \"\"\n\n"; + } + } + file_put_contents($this->root . '/' . $path, $pot); + } + + /** @return array{int, string, string} */ + private function generate(string ...$options): array + { + return $this->runTool(...$options, ...['--namespace=' . self::NAMESPACE, self::POT, self::ENUM]); + } + + /** @return array{module: string, cases: array} */ + private function loadEnum(string $file = self::ENUM, string $class = self::NAMESPACE . '\DemoIdentifier'): array + { + $code = sprintf( + 'require_once %s; require %s; $m = []; foreach (%s::cases() as $c) { $m[$c->name] = $c->value; } ' + . 'echo json_encode(["module" => %s::cases() === [] ? "" : %s::cases()[0]->module(), "cases" => $m, "is_key" => is_a(%s::class, "ILIAS\\\\Language\\\\LanguageIdentifier", true)]);', + var_export((string) realpath(__DIR__ . '/../../../../vendor/composer/vendor/autoload.php'), true), + var_export($this->root . '/' . $file, true), + $class, + $class, + $class, + $class + ); + file_put_contents($this->directory . '/load.php', 'directory . '/load.php'] + ); + $this->assertNotNull($result, 'could not start the child PHP process'); + [$exit, $stdout, $stderr] = $result; + $this->assertSame(0, $exit, $stderr); + $this->assertSame('', $stderr, 'no warning or deprecation'); + $decoded = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + $this->assertTrue($decoded['is_key']); + + return ['module' => $decoded['module'], 'cases' => $decoded['cases']]; + } + + public function testGeneratesCaseNamesAndResolvesCollisionsDeterministically(): void + { + $this->writePot([ + 'poll_answers', 'a-b.c', '1st', 'class', 'Class', 'style', 'Style', 'STYLE', 'a b', 'a-b', + 'plural:item', 'ctx:demo:with_context', 'Ünï', 'x_2', 'x', 'X', + ]); + + [$exit, $stdout, $stderr] = $this->generate(); + + $this->assertSame(0, $exit, $stderr); + $this->assertStringContainsString('Written:', $stdout); + $this->assertStringContainsString('NOTE: Case name STYLE: "style" -> STYLE, "STYLE" -> STYLE_2, "Style" -> STYLE_3', $stderr); + $this->assertStringContainsString('NOTE: Case name KEY_CLASS: "class" -> KEY_CLASS, "Class" -> KEY_CLASS_2', $stderr); + $loaded = $this->loadEnum(); + $this->assertSame('demo', $loaded['module']); + $this->assertEquals([ + 'POLL_ANSWERS' => 'poll_answers', + 'A_B_C' => 'a-b.c', + 'KEY_1ST' => '1st', + 'KEY_CLASS' => 'class', + 'KEY_CLASS_2' => 'Class', + 'STYLE' => 'style', + 'STYLE_2' => 'STYLE', + 'STYLE_3' => 'Style', + 'A_B' => 'a b', + 'A_B_2' => 'a-b', + 'ITEM' => 'item', + 'WITH_CONTEXT' => 'with_context', + '_N_' => 'Ünï', + 'X_2' => 'x_2', + 'X' => 'x', + 'X_3' => 'X', + ], $loaded['cases']); + } + + public function testEveryMsgidIsExactlyOneCaseWithItsValue(): void + { + $identifiers = ['one', 'Two', 'three_3', '4four', 'class', 'CLASS', 'a-a', 'a_a', 'A.A', 'plural_message']; + $this->writePot(array_map(static fn(string $i): string => $i === 'plural_message' ? 'plural:plural_message' : $i, $identifiers)); + + [$exit, , $stderr] = $this->generate(); + + $this->assertSame(0, $exit, $stderr); + $cases = $this->loadEnum()['cases']; + $this->assertCount(count($identifiers), $cases); + $values = array_values($cases); + sort($values); + $expected = $identifiers; + sort($expected); + $this->assertSame($expected, $values, 'no identifier lost, none doubled, the msgid_plural is no case'); + foreach (array_keys($cases) as $name) { + $this->assertMatchesRegularExpression('/^[A-Z_][A-Z0-9_]*$/', $name); + } + } + + public function testSecondRunIsUnchangedAndStdoutPrintsWhatWouldBeWritten(): void + { + $this->writePot(['one', 'two']); + [, $stdout] = $this->generate('--stdout'); + $this->assertFileDoesNotExist($this->root . '/' . self::ENUM, '--stdout writes nothing'); + + $this->assertSame(0, $this->generate()[0]); + $this->assertSame($stdout, (string) file_get_contents($this->root . '/' . self::ENUM)); + $before = fileperms($this->root . '/' . self::ENUM); + [$exit, $again] = $this->generate(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Unchanged:', $again); + $this->assertSame($before, fileperms($this->root . '/' . self::ENUM)); + $this->assertSame(['.', '..', 'DemoIdentifier.php'], scandir($this->root . '/components/ILIAS/Demo/src'), 'no temporary file left'); + } + + /** @return iterable */ + public static function modules(): iterable + { + yield 'option wins' => ['--module=from_option', "\"X-Domain: from_header\\n\"\n", 'from_option']; + yield 'X-Domain header' => [null, "\"X-Domain: from_header\\n\"\n", 'from_header']; + yield 'file name' => [null, '', 'demo']; + } + + #[DataProvider('modules')] + public function testModuleComesFromTheOptionTheHeaderOrTheFileName(?string $option, string $header, string $expected): void + { + $this->writePot(['one'], $header); + + [$exit, , $stderr] = $this->runTool(...array_filter([$option, '--namespace=' . self::NAMESPACE, self::POT, self::ENUM])); + + $this->assertSame(0, $exit, $stderr); + $this->assertSame($expected, $this->loadEnum()['module']); + } + + public function testAnEmptyPotGivesAnEnumWithoutCasesThatStillLoads(): void + { + $this->writePot([]); + + [$exit, , $stderr] = $this->generate(); + + $this->assertSame(0, $exit, $stderr); + $this->assertSame([], $this->loadEnum()['cases']); + $this->assertSame(0, $this->generate('--check')[0]); + } + + public function testTheGeneratedFileIsInTheNamespaceOfTheOptionWithoutBackslashOnlyInTheGlobalOne(): void + { + $this->writePot(['one']); + + [$exit, $stdout] = $this->runTool('--stdout', '--namespace=ILIAS\Language', self::POT, 'components/ILIAS/Demo/src/DemoIdentifier.php'); + + $this->assertSame(0, $exit); + $this->assertStringContainsString("namespace ILIAS\\Language;\n", $stdout); + $this->assertStringNotContainsString('use ILIAS\\Language\\LanguageIdentifier;', $stdout, 'no import of its own namespace'); + $this->assertStringContainsString('enum DemoIdentifier: string implements LanguageIdentifier', $stdout); + } + + public function testCheckPassesOnWhatWasGenerated(): void + { + $this->writePot(['one', 'plural:two', 'Style', 'style']); + $this->generate(); + + [$exit, $stdout, $stderr] = $this->generate('--check'); + + $this->assertSame(0, $exit, $stderr); + $this->assertStringContainsString('OK:', $stdout); + } + + /** @return iterable */ + public static function deviations(): iterable + { + yield 'surplus case' => [static fn(string $code): string => str_replace(" public function module", " case EXTRA = 'extra';\n\n public function module", $code), 'Surplus case EXTRA']; + yield 'missing case' => [static fn(string $code): string => str_replace(" case TWO = 'two';\n", '', $code), 'Missing case TWO']; + yield 'wrong value' => [static fn(string $code): string => str_replace("case TWO = 'two'", "case TWO = 'zwei'", $code), 'Case TWO has the value']; + yield 'wrong module' => [static fn(string $code): string => str_replace("return 'demo';", "return 'other';", $code), 'module() returns']; + yield 'not a LanguageIdentifier' => [static fn(string $code): string => str_replace(' implements LanguageIdentifier', '', $code), 'does not implement']; + yield 'not string backed' => [static fn(string $code): string => (string) preg_replace("/case (\w+) = '[^']*';/", 'case $1 = 1;', str_replace('enum DemoIdentifier: string', 'enum DemoIdentifier: int', $code)), 'not backed by string']; + } + + /** @param callable(string): string $break */ + #[DataProvider('deviations')] + public function testCheckReportsADeviationWithExitCodeOne(callable $break, string $message): void + { + $this->writePot(['one', 'two']); + $this->generate(); + $file = $this->root . '/' . self::ENUM; + $broken = $break((string) file_get_contents($file)); + $this->assertNotSame(file_get_contents($file), $broken, 'the fixture really breaks the enum'); + file_put_contents($file, $broken); + + [$exit, , $stderr] = $this->generate('--check'); + + $this->assertSame(1, $exit, $stderr); + $this->assertStringContainsString('DEVIATION: ', $stderr); + $this->assertStringContainsString($message, $stderr); + } + + public function testCheckNoticesAChangedPot(): void + { + $this->writePot(['one', 'two']); + $this->generate(); + $this->writePot(['one', 'two', 'three']); + + [$exit, , $stderr] = $this->generate('--check'); + + $this->assertSame(1, $exit); + $this->assertStringContainsString('Missing case THREE', $stderr); + } + + public function testCheckNeverWritesAndNeedsAnExistingEnum(): void + { + $this->writePot(['one']); + + [$exit, , $stderr] = $this->generate('--check'); + + $this->assertSame(2, $exit); + $this->assertStringContainsString('does not exist', $stderr); + $this->assertFileDoesNotExist($this->root . '/' . self::ENUM); + } + + public function testCheckOfAFileThatDeclaresAnotherEnumIsAnInputError(): void + { + $this->writePot(['one']); + file_put_contents($this->root . '/' . self::ENUM, "generate('--check'); + + $this->assertSame(2, $exit); + $this->assertStringContainsString('does not declare the enum', $stderr); + } + + /** @return iterable, string}> arguments ("POT", "ENUM", "NS" are the defaults), stderr */ + public static function invalidInput(): iterable + { + yield 'no namespace' => [['POT', 'ENUM'], 'Usage:']; + yield 'one path missing' => [['NS', 'POT'], 'Usage:']; + yield 'three paths' => [['NS', 'POT', 'ENUM', 'ENUM'], 'Usage:']; + yield 'unknown option' => [['NS', '--force', 'POT', 'ENUM'], 'Unknown option']; + yield 'check and stdout' => [['NS', '--check', '--stdout', 'POT', 'ENUM'], 'exclude each other']; + yield 'leading backslash' => [['--namespace=\ILIAS\Demo', 'POT', 'ENUM'], 'Invalid namespace']; + yield 'empty namespace' => [['--namespace=', 'POT', 'ENUM'], 'Invalid namespace']; + yield 'digit namespace' => [['--namespace=ILIAS\1Demo', 'POT', 'ENUM'], 'Invalid namespace']; + yield 'namespace with space' => [['--namespace=ILIAS Demo', 'POT', 'ENUM'], 'Invalid namespace']; + yield 'namespace with code' => [['--namespace=A;eval($x)', 'POT', 'ENUM'], 'Invalid namespace']; + yield 'double backslash' => [['--namespace=ILIAS\\\\Demo', 'POT', 'ENUM'], 'Invalid namespace']; + yield 'module with path' => [['NS', '--module=../x', 'POT', 'ENUM'], 'Invalid module']; + yield 'module with space' => [['NS', '--module=a b', 'POT', 'ENUM'], 'Invalid module']; + yield 'empty module' => [['NS', '--module=', 'POT', 'ENUM'], 'Invalid module']; + yield 'enum name with dash' => [['NS', 'POT', 'components/ILIAS/Demo/src/a-b.php'], 'no valid enum name']; + yield 'enum name with digit first' => [['NS', 'POT', 'components/ILIAS/Demo/src/1Key.php'], 'no valid enum name']; + yield 'enum no php' => [['NS', 'POT', 'components/ILIAS/Demo/src/DemoIdentifier.txt'], 'no .php file']; + yield 'enum directory missing' => [['NS', 'POT', 'components/ILIAS/Demo/nowhere/DemoIdentifier.php'], 'does not exist']; + yield 'pot missing' => [['NS', 'components/ILIAS/Demo/lang/none.pot', 'ENUM'], 'no .pot file']; + yield 'pot is no pot' => [['NS', 'components/ILIAS/Demo/lang/demo.txt', 'ENUM'], 'no .pot file']; + yield 'pot outside by ..' => [['NS', 'components/../../outside/x.pot', 'ENUM'], 'no .pot file']; + yield 'enum outside by ..' => [['NS', 'POT', 'components/../../outside/DemoIdentifier.php'], 'no .php file path']; + yield 'enum with .. inside missing part' => [['NS', 'POT', 'components/ILIAS/Demo/new/../src/DemoIdentifier.php'], 'no .php file path']; + yield 'enum absolute outside' => [['NS', 'POT', '/tmp/DemoIdentifier.php'], 'no .php file path']; + yield 'enum is the root' => [['NS', 'POT', '.php'], 'is no valid enum name']; + yield 'pot through a symlink out' => [['NS', 'linked/outside.pot', 'ENUM'], 'no .pot file']; + yield 'enum through a symlink out' => [['NS', 'POT', 'linked/DemoIdentifier.php'], 'no .php file path']; + yield 'enum is a dangling symlink out' => [['NS', 'POT', 'components/ILIAS/Demo/src/Dangling.php'], 'Refusing|no .php file path']; + } + + /** @param list $arguments */ + #[DataProvider('invalidInput')] + public function testInvalidInputIsRefusedWithExitCodeTwoAndNothingIsWritten(array $arguments, string $stderr_pattern): void + { + $this->writePot(['one']); + file_put_contents($this->directory . '/outside/outside.pot', "msgid \"\"\nmsgstr \"\"\n\nmsgid \"x\"\nmsgstr \"\"\n"); + symlink($this->directory . '/outside', $this->root . '/linked'); + symlink($this->directory . '/outside/target.php', $this->root . '/components/ILIAS/Demo/src/Dangling.php'); + $arguments = array_map(static fn(string $a): string => match ($a) { + 'POT' => self::POT, + 'ENUM' => self::ENUM, + 'NS' => '--namespace=' . self::NAMESPACE, + default => $a, + }, $arguments); + + [$exit, $stdout, $stderr] = $this->runTool(...$arguments); + + $this->assertSame(2, $exit, $stdout . $stderr); + $this->assertMatchesRegularExpression('/ERROR: .*(' . $stderr_pattern . ')/', $stderr); + $this->assertSame([], glob($this->directory . '/outside/*.php') ?: []); + $this->assertSame( + [$this->root . '/components/ILIAS/Demo/src/Dangling.php'], + glob($this->root . '/components/ILIAS/Demo/src/*') ?: [], + 'no enum written' + ); + } + + public function testAnUnsupportedMsgctxtIsRefused(): void + { + $this->writePot(['one', 'ctx:other:two']); + + [$exit, , $stderr] = $this->generate(); + + $this->assertSame(2, $exit); + $this->assertStringContainsString('msgctxt "other"', $stderr); + $this->assertFileDoesNotExist($this->root . '/' . self::ENUM); + } + + public function testAnInvalidModuleInTheHeaderIsRefused(): void + { + $this->writePot(['one'], "\"X-Domain: ../../x\\n\"\n"); + + [$exit, , $stderr] = $this->generate(); + + $this->assertSame(2, $exit); + $this->assertStringContainsString('Invalid module', $stderr); + } + + public function testControlCharactersFromTheInputAreNotPrintedRaw(): void + { + $this->writePot(['one']); + + [$exit, , $stderr] = $this->runTool("--namespace=A\x1b[31mB", self::POT, self::ENUM); + + $this->assertSame(2, $exit); + $this->assertStringNotContainsString("\x1b", $stderr); + $this->assertStringContainsString('\x1B', $stderr); + } + + /** @return iterable a path of the .pot below components/ILIAS/Demo/lang */ + public static function unsafePotPaths(): iterable + { + yield 'asterisk' => ['a*b/demo.pot']; + yield 'space' => ['a b/demo.pot']; + yield 'comment end' => ['x*/y/demo.pot']; + yield 'code in directory names' => ["x*/system('echo PWNED');/*/demo.pot"]; + yield 'quote' => ["it's/demo.pot"]; + } + + #[DataProvider('unsafePotPaths')] + public function testAPotPathWithOtherThanPlainPathCharactersIsRefused(string $relative): void + { + $path = 'components/ILIAS/Demo/lang/' . $relative; + mkdir(dirname($this->root . '/' . $path), 0775, true); + $this->writePot(['one'], "\"X-Domain: demo\\n\"\n", $path); + + [$exit, $stdout, $stderr] = $this->runTool('--namespace=' . self::NAMESPACE, $path, self::ENUM); + + $this->assertSame(2, $exit, $stdout . $stderr); + $this->assertStringContainsString('may only contain', $stderr); + $this->assertStringNotContainsString("PWNED\n", $stdout, 'nothing is executed'); + $this->assertFileDoesNotExist($this->root . '/' . self::ENUM); + [$exit_stdout, $out] = $this->runTool('--stdout', '--namespace=' . self::NAMESPACE, $path, self::ENUM); + $this->assertSame(2, $exit_stdout); + $this->assertSame('', $out, '--stdout prints nothing either'); + } + + public function testAMsgidWithACommentEndStaysCodeAndIsChecked(): void + { + $this->writePot(['a*/b', '*/', "/* x", 'it\'s', 'ok']); + + [$exit, , $stderr] = $this->generate(); + + $this->assertSame(0, $exit, $stderr); + $file = $this->root . '/' . self::ENUM; + $lint = ChildPhp::run(['-l', $file]); + $this->assertNotNull($lint, 'could not start the child PHP process'); + $this->assertSame(0, $lint[0], $lint[1] . $lint[2]); + $this->assertEqualsCanonicalizing(['a*/b', '*/', '/* x', "it's", 'ok'], array_values($this->loadEnum()['cases'])); + [$check_exit, , $check_stderr] = $this->generate('--check'); + $this->assertSame(0, $check_exit, $check_stderr); + } + + public function testReservedCaseNamesGetThePrefix(): void + { + $this->writePot(['__halt_compiler', 'class', 'CLASS', 'Class']); + + [$exit, , $stderr] = $this->generate(); + + $this->assertSame(0, $exit, $stderr); + $cases = $this->loadEnum()['cases']; + $this->assertSame('__halt_compiler', $cases['KEY___HALT_COMPILER'] ?? null); + $this->assertSame('class', $cases['KEY_CLASS'] ?? null); + $this->assertEqualsCanonicalizing(['KEY___HALT_COMPILER', 'KEY_CLASS', 'KEY_CLASS_2', 'KEY_CLASS_3'], array_keys($cases)); + } + + /** + * @return iterable the module() method of an enum without + * cases, the exit code --check ends with, a message + */ + public static function modulesOfAnEnumWithoutCases(): iterable + { + yield 'the module' => ["return 'demo';", 0, 'OK:']; + yield 'a comment in between' => ["/* c */ return 'demo'; // x", 0, 'OK:']; + yield 'another module' => ["return 'other';", 1, 'module() returns']; + yield 'concatenation' => ["return 'de' . 'mo';", 1, 'does not just return']; + yield 'call' => ["return strtolower('DEMO');", 1, 'does not just return']; + yield 'double quoted' => ['return "demo";', 1, 'does not just return']; + yield 'constant' => ['return self::class;', 1, 'does not just return']; + yield 'more than the return' => ["echo 'x'; return 'demo';", 1, 'does not just return']; + yield 'second statement' => ["return 'demo'; echo 'x';", 1, 'does not just return']; + } + + #[DataProvider('modulesOfAnEnumWithoutCases')] + public function testCheckReadsTheModuleOfAnEnumWithoutCasesFromItsBody(string $body, int $expected_exit, string $message): void + { + $this->writePot([]); + $this->generate(); + $file = $this->root . '/' . self::ENUM; + $code = (string) file_get_contents($file); + $changed = str_replace("return 'demo';", $body, $code); + $this->assertNotSame('', $changed); + file_put_contents($file, $changed); + + [$exit, $stdout, $stderr] = $this->generate('--check'); + + $this->assertSame($expected_exit, $exit, $stdout . $stderr); + $this->assertStringContainsString($message, $stdout . $stderr); + } + + public function testCheckOfAnEnumWithoutCasesDoesNotExecuteItsModuleMethod(): void + { + $this->writePot([]); + $this->generate(); + $file = $this->root . '/' . self::ENUM; + file_put_contents($file, str_replace("return 'demo';", "echo 'PWNED'; return 'demo';", (string) file_get_contents($file))); + + [$exit, $stdout] = $this->generate('--check'); + + $this->assertSame(1, $exit); + $this->assertStringNotContainsString('PWNED', $stdout); + } + + public function testControlCharactersOfAMsgidAreMaskedInTheMessage(): void + { + $this->writePot(["a\x1b[31mb"]); + + [$exit, $stdout, $stderr] = $this->generate(); + + $this->assertSame(2, $exit); + $this->assertStringNotContainsString("\x1b", $stdout . $stderr); + $this->assertFileDoesNotExist($this->root . '/' . self::ENUM); + } +} diff --git a/components/ILIAS/Language/tests/NtxtTest.php b/components/ILIAS/Language/tests/NtxtTest.php index 7c62e0f28e4a..1f1cb6ded727 100644 --- a/components/ILIAS/Language/tests/NtxtTest.php +++ b/components/ILIAS/Language/tests/NtxtTest.php @@ -168,6 +168,18 @@ public function testFallsBackToTxtForATopicThatWasNeverLoadedAtAll(): void $this->assertSame($language->txt('unknown_topic'), $language->ntxt('unknown_topic', 5)); } + public function testServesThePluralFormFromTheFallbackModuleWithoutLoadingIt(): void + { + $catalog = self::pluralCatalog('ntxt', 'nplurals=2; plural=(n != 1);', 'item', ['Eintrag', 'Einträge']); + $this->stubUsageLogDependencies(); + $this->registerDirectoryManager($this->contributeModule('ntxt', $catalog)); + + $language = $this->buildLanguage(); + + $this->assertSame('Eintrag', $language->ntxt('item', 1, 'ntxt')); + $this->assertSame('Einträge', $language->ntxt('item', 5, 'ntxt')); + } + /** Without a "Plural-Forms" header the Germanic rule (n==1 -> form 0, else 1) applies instead of failing. */ public function testUsesTheGermanicRuleWhenThePluralFormsHeaderIsMissing(): void { diff --git a/components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php b/components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php new file mode 100644 index 000000000000..aa4d1761e881 --- /dev/null +++ b/components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php @@ -0,0 +1,123 @@ + */ + public static function setupLookups(): iterable + { + yield 'string' => ['known', null, null, 'Bekannt']; + yield 'string, n and language have no effect' => ['known', 3, 'fr', 'Bekannt']; + yield 'enum' => [TranslateSetupTestIdentifier::KNOWN, null, null, 'Bekannt']; + yield 'enum, n and language have no effect' => [TranslateSetupTestIdentifier::KNOWN, 0, 'fr', 'Bekannt']; + yield 'unknown string' => ['unknown', null, null, '-unknown-']; + yield 'unknown enum' => [TranslateSetupTestIdentifier::UNKNOWN, 1, null, '-unknown-']; + yield 'empty string' => ['', null, null, '']; + yield 'empty enum' => [TranslateSetupTestIdentifier::EMPTY, null, null, '']; + } + + #[DataProvider('setupLookups')] + public function testSetupLanguageTranslateIsTxt(string|LanguageIdentifier $key, ?int $n, ?string $lang, string $expected): void + { + $GLOBALS['log'] = $this->createStub(ilLogger::class); + $language = new ilSetupLanguage('de'); + $language->text = ['known' => 'Bekannt']; + + $this->assertSame($expected, $language->translate($key, $n, $lang)); + if (is_string($key)) { + $this->assertSame($language->txt($key), $language->translate($key)); + } + } + + /** $DIC->language() only returns an ilLanguage, so another Language implementation is handed in by overriding the lookup. */ + private static function adapterOf(Language $language): LanguageLegacyInitialisationAdapter + { + return new class ($language) extends LanguageLegacyInitialisationAdapter { + public function __construct(private readonly Language $language) + { + } + + protected function getLegacyLanguageInstance(): Language + { + return $this->language; + } + }; + } + + public function testAdapterPassesEverythingOnToAnIlLanguage(): void + { + $key = TranslateSetupTestIdentifier::KNOWN; + $inner = $this->createMock(ilLanguage::class); + $inner->expects($this->exactly(2))->method('translate') + ->willReturnCallback(static fn(string|LanguageIdentifier $k, ?int $n, ?string $l): string => 'got ' . (is_string($k) ? $k : $k->value) . ' ' . var_export($n, true) . ' ' . var_export($l, true)); + $inner->expects($this->never())->method('loadLanguageModule'); + $this->setGlobalVariable('lng', $inner); + + $adapter = new LanguageLegacyInitialisationAdapter(); + + $this->assertSame("got known 2 'fr'", $adapter->translate($key, 2, 'fr')); + $this->assertSame('got plain NULL NULL', $adapter->translate('plain')); + } + + public function testAdapterWithoutIlLanguageLoadsTheModuleOfAnEnumAndUsesTxt(): void + { + $inner = $this->createMock(Language::class); + $calls = []; + $inner->expects($this->once())->method('loadLanguageModule') + ->willReturnCallback(function (string $module) use (&$calls): void { + $calls[] = 'load ' . $module; + }); + $inner->expects($this->once())->method('txt')->with('known') + ->willReturnCallback(function () use (&$calls): string { + $calls[] = 'txt'; + + return 'Bekannt'; + }); + + $this->assertSame('Bekannt', self::adapterOf($inner)->translate(TranslateSetupTestIdentifier::KNOWN, 4, 'fr')); + $this->assertSame(['load setup_module', 'txt'], $calls, 'the module is loaded before the lookup'); + } + + public function testAdapterWithoutIlLanguageLooksUpAStringWithoutLoadingAModule(): void + { + $inner = $this->createMock(Language::class); + $inner->expects($this->never())->method('loadLanguageModule'); + $inner->expects($this->once())->method('txt')->with('known')->willReturn('Bekannt'); + + $this->assertSame('Bekannt', self::adapterOf($inner)->translate('known')); + } +} diff --git a/components/ILIAS/Language/tests/TranslateTest.php b/components/ILIAS/Language/tests/TranslateTest.php new file mode 100644 index 000000000000..052798a5610c --- /dev/null +++ b/components/ILIAS/Language/tests/TranslateTest.php @@ -0,0 +1,698 @@ + the SQL every query of the ilDB stub received */ + private array $queries = []; + + /** @var list|null kind (modules|data), lang_key, module[, identifier] of the last query */ + private ?array $last_query = null; + + protected function setUp(): void + { + parent::setUp(); + + if (!defined('ILIAS_ABSOLUTE_PATH')) { + define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../')); + } + if (!defined('ILIAS_LOG_ENABLED')) { + define('ILIAS_LOG_ENABLED', true); // translate() logs a missing text + } + MigratedPoFixture::resetRuntime(); + MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + $this->fixture_directory = __DIR__ . '/tmp-translate-' . bin2hex(random_bytes(4)); + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'de', self::catalog('mig_a', ['shared' => 'Mig A', 'only_a' => 'A'], ['Eintrag', 'Einträge'])); + MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'en', self::catalog('mig_a', ['shared' => 'Mig A en', 'only_en' => 'Only en'], ['Item', 'Items'])); + MigratedPoFixture::writeShippedPo($relative_path, 'mig_b', 'de', self::catalog('mig_b', ['shared' => 'Mig B'])); + + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('mig_a', $relative_path), + MigratedPoFixture::directory('mig_b', $relative_path) + ) + ); + $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + $factory = $this->createStub(ilLoggerFactory::class); + $factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); + $this->setGlobalVariable('ilLoggerFactory', $factory); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . basename($this->fixture_directory)); + } + if (defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** + * @param array $values + * @param list|null $item_forms the two forms of the plural message "item" (none: without) + */ + private static function catalog(string $module, array $values, ?array $item_forms = null): TranslationCatalog + { + $catalog = MigratedPoFixture::catalog($module, $values); + if ($item_forms !== null) { + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $entry = new TranslationEntry($module, 'item'); + $entry->setPlural('items', $item_forms); + $catalog->add($entry); + } + + return $catalog; + } + + /** @param array> $cached lng_modules of the language by module (the cache the constructor reads: cached_modules) */ + private function language(array $cached = [], string $lang_key = 'de', string $default = 'en'): ilLanguage + { + global $DIC; + MigratedPoFixture::build($DIC[LanguageFileDirectoryManager::class], (string) constant('ILIAS_ABSOLUTE_PATH')); + $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $reflected = new ReflectionObject($language); + foreach (['lang_key', 'lang_user'] as $property) { + $reflected->getProperty($property)->setValue($language, $lang_key); + } + $reflected->getProperty('lang_default')->setValue($language, $default); + $reflected->getProperty('log')->setValue($language, $this->createStub(ilLogger::class)); + $reflected->getProperty('cached_modules')->setValue($language, $cached); + + return $language; + } + + /** + * lng_modules ("modules": "|" => values, a missing one has no row) and lng_data + * ("data": "||" => value) of the database stub; $this->queries records every query. + * + * @param array> $modules + * @param array $data + */ + private function stubDatabase(array $modules = [], array $data = []): void + { + $statement = $this->createStub(ilDBStatement::class); + $statement->method('fetchRow')->willReturnCallback(function () use ($modules) { + $key = $this->last_query[1] . '|' . $this->last_query[2]; + + return isset($modules[$key]) ? ['lang_array' => serialize($modules[$key])] : false; + }); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . (string) $value . "'"); + $db->method('query')->willReturnCallback(function (string $sql) use ($statement) { + $this->queries[] = $sql; + if (preg_match("/FROM lng_modules WHERE lang_key = '([^']*)' AND module = '([^']*)'/", $sql, $m) === 1) { + $this->last_query = ['modules', $m[1], $m[2]]; + } elseif (preg_match("/FROM lng_data WHERE module = '([^']*)' AND lang_key = '([^']*)' AND identifier = '([^']*)'/", $sql, $m) === 1) { + $this->last_query = ['data', $m[2], $m[1], $m[3]]; + } else { + $this->fail('unexpected query: ' . $sql); + } + + return $statement; + }); + $db->method('fetchAssoc')->willReturnCallback(function () use ($data) { + $value = $data[$this->last_query[1] . '|' . $this->last_query[2] . '|' . $this->last_query[3]] ?? null; + + return $value === null ? null : ['value' => $value]; + }); + $this->setGlobalVariable('ilDB', $db); + } + + /** @return array{loaded_modules: list, text: array, migrated_key_modules: mixed} */ + private static function state(ilLanguage $language): array + { + return [ + 'loaded_modules' => $language->loaded_modules, + 'text' => $language->text, + 'migrated_key_modules' => (new ReflectionProperty(ilLanguage::class, 'migrated_key_modules'))->getValue($language), + ]; + } + + /** @return array */ + private static function usageLog(): array + { + return (new ReflectionProperty(ilLanguage::class, 'lng_log'))->getValue(); + } + + private static function enableUsageLog(ilLanguage $language, bool $enabled = true): void + { + (new ReflectionProperty(ilLanguage::class, 'usage_log_enabled'))->setValue($language, $enabled); + } + + /** @return iterable */ + public static function migratedTexts(): iterable + { + yield 'own module, not mig_b' => [TranslateTestMigAIdentifier::SHARED, null, null, 'Mig A']; + yield 'same identifier, other module' => [TranslateTestMigBIdentifier::SHARED, null, null, 'Mig B']; + yield 'identifier only in mig_a' => [TranslateTestMigAIdentifier::ONLY_A, null, null, 'A']; + yield 'plural n=1' => [TranslateTestMigAIdentifier::ITEM, 1, null, 'Eintrag']; + yield 'plural n=0' => [TranslateTestMigAIdentifier::ITEM, 0, null, 'Einträge']; + yield 'plural n=5' => [TranslateTestMigAIdentifier::ITEM, 5, null, 'Einträge']; + yield 'plain identifier with n' => [TranslateTestMigAIdentifier::SHARED, 5, null, 'Mig A']; + yield 'other language (build, not lng_data)' => [TranslateTestMigAIdentifier::SHARED, null, 'en', 'Mig A en']; + yield 'other language, plural n=1' => [TranslateTestMigAIdentifier::ITEM, 1, 'en', 'Item']; + yield 'other language, plural n=2' => [TranslateTestMigAIdentifier::ITEM, 2, 'en', 'Items']; + yield 'current language given explicitly' => [TranslateTestMigAIdentifier::SHARED, null, 'de', 'Mig A']; + yield 'default language fallback' => [TranslateTestMigAIdentifier::ONLY_EN, null, null, 'Only en']; + yield 'missing everywhere' => [TranslateTestMigAIdentifier::MISSING, null, null, '-missing-']; + yield 'missing, other language' => [TranslateTestMigAIdentifier::MISSING, 3, 'en', '-missing-']; + } + + #[DataProvider('migratedTexts')] + public function testMigratedModuleIsReadDirectlyWithoutLoadingAnything(LanguageIdentifier $key, ?int $n, ?string $lang, string $expected): void + { + $this->forbidDatabaseQueries(); + $language = $this->language(); + $before = self::state($language); + + $this->assertSame($expected, $language->translate($key, $n, $lang)); + + $this->assertSame($before, self::state($language), 'no module is loaded, nothing is assigned to a module'); + } + + private function forbidDatabaseQueries(): void + { + $db = $this->createMock(ilDBInterface::class); + $db->expects($this->never())->method('query'); + $this->setGlobalVariable('ilDB', $db); + } + + public function testTheOverlayWinsOverTheBuildForTextAndPluralForm(): void + { + $overlay = self::catalog('mig_a', ['shared' => 'Overlay A', 'only_a' => 'A'], ['Overlay-Eintrag', 'Overlay-Einträge']); + MigratedPoFixture::writePair(rtrim(CLIENT_DATA_DIR, '/') . '/lang/mig_a/de/mig_a_de', $overlay); + $language = $this->language(); + + $this->assertSame('Overlay A', $language->translate(TranslateTestMigAIdentifier::SHARED)); + $this->assertSame('Overlay-Eintrag', $language->translate(TranslateTestMigAIdentifier::ITEM, 1)); + $this->assertSame('Overlay-Einträge', $language->translate(TranslateTestMigAIdentifier::ITEM, 2)); + $this->assertSame('Mig B', $language->translate(TranslateTestMigBIdentifier::SHARED), 'another module is not affected'); + } + + public function testLegacyModuleServesItsOwnValueWhateverModuleWasLoadedLast(): void + { + $language = $this->language([ + 'legacy' => ['shared' => 'Legacy', 'zero' => '0'], + 'legacy2' => ['shared' => 'Legacy 2'], + ]); + $language->loadLanguageModule('mig_a'); + $language->loadLanguageModule('legacy'); + $language->loadLanguageModule('legacy2'); + $language->loadLanguageModule('mig_b'); + + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + $this->assertSame('Legacy 2', $language->translate(TranslateTestLegacy2Identifier::SHARED)); + $this->assertSame('0', $language->translate(TranslateTestLegacyIdentifier::ZERO), '"0" is a value'); + $this->assertSame('Mig B', $language->txt('shared'), 'txt() still serves the module loaded last'); + $this->assertSame('Mig A', $language->translate(TranslateTestMigAIdentifier::SHARED)); + } + + public function testLegacyModuleWithoutAnyLoadedModule(): void + { + $language = $this->language(['legacy' => ['shared' => 'Legacy', 'blank' => '']]); + + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING)); + $this->assertSame('-blank-', $language->translate(TranslateTestLegacyIdentifier::BLANK), 'an empty value is no text'); + } + + public function testLegacyModuleHasNoSideEffectOnTxtAndTheModuleAssignment(): void + { + $language = $this->language(['legacy' => ['shared' => 'Legacy']]); + $language->loadLanguageModule('mig_a'); + $before = self::state($language); + $this->assertSame('mig_a', $before['migrated_key_modules']['shared'] ?? null); + + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + + $this->assertSame($before, self::state($language), 'loaded_modules, text and the module assignment are unchanged'); + $this->assertSame('Mig A', $language->txt('shared'), 'loadLanguageModule("legacy") would have taken the identifier over'); + $this->assertSame(['mig_a'], $language->loaded_modules); + } + + public function testLegacyModuleReadsLngModulesOncePerModuleAndRequest(): void + { + $this->stubDatabase([ + 'de|legacy' => ['shared' => 'Legacy', 'zero' => '0'], + 'de|legacy2' => ['shared' => 'Legacy 2'], + ]); + $language = $this->language([], 'de', 'de'); + + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + $this->assertSame('0', $language->translate(TranslateTestLegacyIdentifier::ZERO)); + $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING)); + $this->assertCount(1, $this->queries, 'one query for the module, whatever the identifier'); + $this->assertSame('Legacy 2', $language->translate(TranslateTestLegacy2Identifier::SHARED)); + $this->assertCount(2, $this->queries); + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + $this->assertCount(2, $this->queries); + } + + public function testAModuleWithoutRowIsNotQueriedAgain(): void + { + $this->stubDatabase(); + $language = $this->language(); + + $this->assertSame('-shared-', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING)); + + $modules_queries = array_filter($this->queries, static fn(string $sql): bool => str_contains($sql, 'lng_modules')); + $this->assertCount(1, $modules_queries); + } + + public function testLoadLanguageModuleAfterTranslateStillLoadsTheModule(): void + { + $this->stubDatabase(['de|legacy' => ['shared' => 'Legacy']]); + $language = $this->language(); + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + + $language->loadLanguageModule('legacy'); + + $this->assertSame(['legacy'], $language->loaded_modules); + $this->assertSame('Legacy', $language->txt('shared')); + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + } + + public function testLoadLanguageModuleReadsTheDatabaseOnlyForAModuleThatIsNotMigrated(): void + { + $this->stubDatabase(['de|legacy' => ['only_legacy' => 'L']]); + $language = $this->language(); + + $language->loadLanguageModule('mig_a'); + $this->assertSame([], $this->queries, 'a migrated module is never read from lng_modules'); + $language->loadLanguageModule('legacy'); + + $this->assertCount(1, $this->queries); + $this->assertSame('L', $language->txt('only_legacy')); + } + + public function testLegacyModuleInAnotherLanguageReadsLngDataAndFallsBackToTheDefaultLanguage(): void + { + $this->stubDatabase(['de|legacy' => ['shared' => 'Legacy de']], [ + 'fr|legacy|shared' => 'Legacy fr', + 'en|legacy|zero' => '0', + 'en|legacy|blank' => 'Blank en', + ]); + $language = $this->language(); + + $this->assertSame('Legacy fr', $language->translate(TranslateTestLegacyIdentifier::SHARED, null, 'fr')); + $this->assertSame('0', $language->translate(TranslateTestLegacyIdentifier::ZERO, null, 'fr'), 'default language, value "0"'); + $this->assertSame('Blank en', $language->translate(TranslateTestLegacyIdentifier::BLANK, null, 'fr')); + $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING, null, 'fr')); + $this->assertSame('Legacy de', $language->translate(TranslateTestLegacyIdentifier::SHARED), 'the current language still comes from lng_modules'); + } + + public function testAMigratedModuleWithoutThatLanguageIsReadFromLngData(): void + { + $this->stubDatabase([], ['en|mig_b|shared' => 'Mig B en']); + $language = $this->language(); + + $this->assertSame('Mig B en', $language->translate(TranslateTestMigBIdentifier::SHARED, null, 'en')); + $this->assertSame('Mig B', $language->translate(TranslateTestMigBIdentifier::SHARED), 'de is migrated: the build'); + } + + public function testPluralWithoutNIsTheFormTxtServes(): void + { + $language = $this->language(); + $language->loadLanguageModule('mig_a'); + + $this->assertSame($language->txt('item'), $language->translate(TranslateTestMigAIdentifier::ITEM)); + $this->assertSame($language->txt('item'), $language->translate(TranslateTestMigAIdentifier::ITEM, null, 'de')); + } + + public function testLegacyModuleOfTheCurrentLanguageFallsBackToTheDefaultLanguageFromLngData(): void + { + $this->stubDatabase(['de|legacy' => ['shared' => '']], ['en|legacy|shared' => 'Legacy en']); + $language = $this->language(); + + $this->assertSame('Legacy en', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + } + + public function testNoFallbackQueryWhenTheLanguageIsTheDefaultLanguage(): void + { + $this->stubDatabase(); + $language = $this->language([], 'en', 'en'); + + $this->assertSame('-shared-', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + $this->assertSame('-shared-', $language->translate(TranslateTestLegacyIdentifier::SHARED, null, 'en')); + $this->assertCount(1, $this->queries, 'lng_modules once, no lng_data'); + } + + /** @return iterable, string, ?int}> */ + public static function stringLookups(): iterable + { + foreach ([ + 'migrated then legacy' => ['mig_a', 'legacy'], + 'legacy then migrated' => ['legacy', 'mig_a'], + 'two migrated' => ['mig_a', 'mig_b'], + 'nothing loaded' => [], + ] as $label => $modules) { + foreach (['shared', 'only_a', 'item', 'zero', 'missing', 'direct'] as $identifier) { + foreach ([null, 1, 5] as $n) { + yield $label . ' / ' . $identifier . ' / ' . var_export($n, true) => [$modules, $identifier, $n]; + } + } + } + } + + /** @param list $modules */ + #[DataProvider('stringLookups')] + public function testAStringBehavesLikeTxtAndNtxt(array $modules, string $identifier, ?int $n): void + { + $language = $this->language(['legacy' => ['shared' => 'Legacy', 'zero' => '0']]); + $language->text['direct'] = 'Direct'; + foreach ($modules as $module) { + $language->loadLanguageModule($module); + } + $expected = $n === null ? $language->txt($identifier) : $language->ntxt($identifier, $n); + + $this->assertSame($expected, $language->translate($identifier, $n)); + $this->assertSame($expected, $language->translate($identifier, $n, 'de'), 'the current language given explicitly'); + } + + public function testAStringInAnotherLanguageUsesTheModuleResponsibleAndNeverAnEarlierOne(): void + { + $language = $this->language(); + $language->loadLanguageModule('mig_a'); + $language->loadLanguageModule('mig_b'); + + $this->assertSame('-shared-', $language->translate('shared', null, 'en')); + + $reverse = $this->language(); + $reverse->loadLanguageModule('mig_b'); + $reverse->loadLanguageModule('mig_a'); + $this->assertSame('Mig A en', $reverse->translate('shared', null, 'en')); + $this->assertSame('Items', $reverse->translate('item', 2, 'en')); + $this->assertSame('Item', $reverse->translate('item', 1, 'en')); + } + + /** Exactly one lookup, in the module txt() serves the identifier from (the one loaded last containing it), never the default language. */ + public function testAStringInAnotherLanguageReadsOnlyTheLegacyModuleTxtServesItFrom(): void + { + $this->stubDatabase([ + 'de|legacy' => ['shared' => 'x', 'only_first' => 'x', 'both' => 'x'], + 'de|legacy2' => ['shared' => 'x', 'both' => 'x'], + ], [ + 'fr|legacy|shared' => 'Legacy fr', + 'fr|legacy2|shared' => 'Legacy 2 fr', + 'fr|legacy|only_first' => 'First only fr', + 'fr|legacy|both' => 'Earlier module fr', + 'en|legacy|nowhere' => 'Default language', + ]); + $language = $this->language(); + $language->loadLanguageModule('legacy'); + $language->loadLanguageModule('legacy2'); + $lng_data_queries = function (): array { + return array_values(array_filter($this->queries, static fn(string $sql): bool => str_contains($sql, 'lng_data'))); + }; + + $this->queries = []; + $this->assertSame('Legacy 2 fr', $language->translate('shared', null, 'fr'), 'the module loaded last wins, as in txt()'); + $this->assertCount(1, $lng_data_queries(), 'one lookup'); + + $this->queries = []; + $this->assertSame('First only fr', $language->translate('only_first', null, 'fr'), 'the only module that contains it'); + $this->assertCount(1, $lng_data_queries()); + + $this->queries = []; + $this->assertSame('-both-', $language->translate('both', null, 'fr'), 'legacy2 is responsible and has no "fr" - never the earlier module'); + $this->assertCount(1, $lng_data_queries()); + + $this->queries = []; + $this->assertSame('-nowhere-', $language->translate('nowhere', null, 'fr'), 'no module responsible, no fallback to the default language'); + $this->assertSame([], $lng_data_queries(), 'no lookup without a responsible module'); + } + + public function testAStringInAnotherLanguageIgnoresTheLegacyModulesOfAMigratedIdentifier(): void + { + $this->stubDatabase([], ['fr|legacy|shared' => 'Legacy fr']); + $language = $this->language(['legacy' => ['shared' => 'Legacy']]); + $language->loadLanguageModule('legacy'); + $language->loadLanguageModule('mig_a'); + + $this->assertSame('Mig A', $language->txt('shared')); + $this->assertSame('-shared-', $language->translate('shared', null, 'fr'), 'mig_a is responsible, it has no "fr"'); + } + + public function testTheResponsibleModuleOfAStringFollowsTheLoadingOrderOfMigratedAndLegacyModules(): void + { + $this->stubDatabase([], ['en|legacy|shared' => 'Legacy en']); + + $migrated_last = $this->language(['legacy' => ['shared' => 'Legacy']]); + $migrated_last->loadLanguageModule('legacy'); + $migrated_last->loadLanguageModule('mig_a'); + $this->queries = []; + $this->assertSame('Mig A en', $migrated_last->translate('shared', null, 'en')); + $this->assertSame([], $this->queries, 'a migrated module is read from the build, not from lng_data'); + + $legacy_last = $this->language(['legacy' => ['shared' => 'Legacy']]); + $legacy_last->loadLanguageModule('mig_a'); + $legacy_last->loadLanguageModule('legacy'); + $this->assertSame('Legacy', $legacy_last->txt('shared')); + $this->assertSame('Legacy en', $legacy_last->translate('shared', null, 'en')); + $this->assertCount(1, $this->queries, 'exactly one lookup, in the module loaded last'); + $this->assertStringContainsString("module = 'legacy'", $this->queries[0]); + } + + public function testAModuleOnlyReadThroughAnIdentifierIsNotResponsibleForAString(): void + { + $this->stubDatabase([], ['fr|legacy|shared' => 'Legacy fr']); + $language = $this->language(['legacy' => ['shared' => 'Legacy']]); + + $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); + $this->queries = []; + + $this->assertSame('-shared-', $language->translate('shared', null, 'fr')); + $this->assertSame([], $this->queries, 'no module loaded, so none responsible: no lookup at all'); + } + + /** @return iterable */ + public static function invalidLanguageKeys(): iterable + { + foreach (['../x', 'DE', '', 'deu', 'd', 'de/', "fr' OR '1", 'x.'] as $lang) { + yield var_export($lang, true) => [$lang]; + } + } + + #[DataProvider('invalidLanguageKeys')] + public function testAnInvalidLanguageIsNeverLookedUp(string $lang): void + { + $this->stubDatabase(['de|legacy' => ['shared' => 'Legacy']], ['en|legacy|shared' => 'Legacy en']); + $language = $this->language(); + $language->loadLanguageModule('mig_a'); + $language->loadLanguageModule('legacy'); + $language->loadLanguageModule('mig_b'); + $this->queries = []; + + $this->assertSame('-shared-', $language->translate('shared', null, $lang), 'string: no text'); + $this->assertSame('Mig A en', $language->translate(TranslateTestMigAIdentifier::SHARED, null, $lang), 'migrated enum: default language'); + $this->assertSame('Mig A en', $language->translate(TranslateTestMigAIdentifier::SHARED, 1, $lang)); + $this->assertSame('Legacy en', $language->translate(TranslateTestLegacyIdentifier::SHARED, null, $lang), 'legacy enum: default language'); + $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING, null, $lang)); + + foreach ($this->queries as $sql) { + $this->assertStringNotContainsString("lang_key = '" . $lang . "'", $sql); + $this->assertStringContainsString("lang_key = 'en'", $sql, 'only the default language is queried'); + } + } + + public function testAnInvalidLanguageIsLoggedWithoutItsValue(): void + { + $logger = $this->createStub(ilLogger::class); + $messages = []; + $logger->method('debug')->willReturnCallback(static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + $language = $this->language(); + (new ReflectionProperty(ilLanguage::class, 'log'))->setValue($language, $logger); + + $language->translate('nothing', null, "../x\nINJECTED"); + + $this->assertCount(1, $messages); + $this->assertStringNotContainsString('INJECTED', $messages[0]); + } + + public function testAnEmptyIdentifierIsEmptyAndNotRecorded(): void + { + $this->forbidDatabaseQueries(); + $language = $this->language(); + self::enableUsageLog($language); + + $this->assertSame('', $language->translate('')); + $this->assertSame('', $language->translate('', 3, 'fr')); + $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::EMPTY)); + $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::EMPTY, 2, 'en')); + $this->assertSame('', $language->translate('0'), 'empty() as in txt()'); + $this->assertSame('', $language->translate('0', 2, 'fr')); + $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::NUL)); + $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::NUL, 1, 'en')); + + $this->assertSame([], $language->getUsedTopics()); + $this->assertSame([], $language->getUsedModules()); + $this->assertSame([], self::usageLog()); + self::enableUsageLog($language, false); + } + + public function testUsageIsRecordedForTopicsAlwaysAndForModulesAndLogOnlyOnAHit(): void + { + $language = $this->language(['legacy' => ['shared' => 'Legacy']]); + self::enableUsageLog($language); + + $language->translate(TranslateTestMigAIdentifier::MISSING); + $this->assertSame(['missing' => 'missing'], $language->getUsedTopics()); + $this->assertSame([], $language->getUsedModules(), 'no hit, no module'); + $this->assertSame([], self::usageLog()); + + $language->translate(TranslateTestMigAIdentifier::SHARED); + $language->translate(TranslateTestLegacyIdentifier::SHARED); + + $this->assertEqualsCanonicalizing(['mig_a' => 'mig_a', 'legacy' => 'legacy'], $language->getUsedModules()); + $this->assertSame(['shared' => 'legacy'], self::usageLog(), 'the identifier is logged for the module read last'); + $this->assertSame(['missing' => 'missing', 'shared' => 'shared'], $language->getUsedTopics()); + self::enableUsageLog($language, false); + } + + public function testUsageLogIsOffWhenDisabledAndRecordsTheModuleOfAFallbackHit(): void + { + $language = $this->language(); + + $language->translate(TranslateTestMigAIdentifier::ONLY_EN); + $this->assertSame([], self::usageLog(), 'usage log disabled'); + $this->assertSame(['mig_a' => 'mig_a'], $language->getUsedModules()); + + self::enableUsageLog($language); + $language->translate('only_a', null, 'en'); + $this->assertSame([], self::usageLog(), 'a miss is not logged'); + $language->loadLanguageModule('mig_a'); + $language->translate('only_a'); + $this->assertSame(['only_a' => 'mig_a'], self::usageLog()); + self::enableUsageLog($language, false); + } + + /** @return iterable */ + public static function lookupEntryValues(): iterable + { + yield 'value' => ['Wert', 'Wert']; + yield 'zero' => ['0', '0']; + yield 'empty' => ['', '-id-']; + yield 'no row' => [null, '-id-']; + } + + #[DataProvider('lookupEntryValues')] + public function testLookupEntryReadsLngData(?string $stored, string $expected): void + { + $this->stubDatabase([], $stored === null ? [] : ['fr|legacy|id' => $stored]); + $this->language(); + + $this->assertSame($expected, ilLanguage::_lookupEntry('fr', 'legacy', 'id')); + $this->assertCount(1, $this->queries); + } + + public function testLookupEntryRecordsTheUsageOnlyForAHit(): void + { + $this->stubDatabase([], ['fr|legacy|id' => 'Wert']); + $language = $this->language(); + + ilLanguage::_lookupEntry('fr', 'legacy', 'nothing'); + $this->assertSame([], $language->getUsedModules()); + ilLanguage::_lookupEntry('fr', 'legacy', 'id'); + $this->assertSame(['legacy' => 'legacy'], $language->getUsedModules()); + $this->assertSame(['id' => 'id'], $language->getUsedTopics()); + } +} diff --git a/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php b/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php new file mode 100644 index 000000000000..1ead62b46a62 --- /dev/null +++ b/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php @@ -0,0 +1,412 @@ +] + * --namespace= + * the `.pot` of the module, inside this ILIAS directory + * the enum, ".php", inside this ILIAS directory - written atomically (its + * directory must exist), with --stdout only printed, with --check only read + * --module the module (default: the "X-Domain" header of the `.pot`, else its file name) + * --namespace the namespace of the enum, e.g. "ILIAS\Poll" + * --check compares the enum with the `.pot`: missing or surplus cases, wrong values, wrong + * module - exit code 1 if it deviates. For an enum without cases, module() must + * return a single-quoted string literal (it cannot be called without a case). + * Exit codes: 0 success (no deviation), 1 deviation (--check), 2 invalid arguments or input. + * + * Case names: the identifier in upper case, every run of characters other than A-Z, 0-9 and "_" + * replaced by one "_", a leading digit and the names PHP does not allow for a case (CLASS, + * __HALT_COMPILER) prefixed with "KEY_". Identifiers + * that end up with the same name (e.g. "style" and "Style") are numbered deterministically: the + * identifier in lower case keeps the name, the others (in byte order) get "_2", "_3", ... (skipping + * every name taken otherwise) - reported on stderr. One case per msgid, plural messages included. + * + * Deliberately declares no named classes or functions: this file lives inside the classmap-scanned + * components/ tree (see convert_module_to_po.php). + */ + +// a development tool: never reachable through the web server +if (PHP_SAPI !== 'cli') { + exit(1); +} + +$repo_root = dirname(__DIR__, 5); +require $repo_root . '/vendor/composer/vendor/autoload.php'; + +use ILIAS\Language\ComponentTranslation\AtomicFileWriter; +use ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; +use ILIAS\Language\LanguageIdentifier; + +const LANGUAGE_IDENTIFIER_ENUM_TOOL = 'components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php'; + +$fail = static function (string $message): never { + fwrite(STDERR, 'ERROR: ' . $message . PHP_EOL); + exit(2); +}; + +/** + * Makes control characters visible in everything printed that comes from a file or the command line. + */ +$printable = static fn(string $text): string => (string) preg_replace_callback( + '/[\x00-\x1F\x7F]/', + static fn(array $match): string => sprintf('\x%02X', ord($match[0])), + $text +); + +$identifier_format = '/^[A-Za-z_][A-Za-z0-9_]*$/'; + +/** + * Case names PHP rejects (all other keywords are allowed as names of class constants and cases). + */ +$reserved_case_names = ['CLASS', '__HALT_COMPILER']; + +$comment_safe = static fn(string $text): string => str_replace('*/', '*\\/', $text); + +/** + * The absolute path of $path if it lies inside $root, `null` otherwise. Symbolic links of the deepest existing + * ancestor are resolved; the non-existing rest must not contain "." or "..". + */ +$inside_repository = static function (string $path, string $root): ?string { + if ($path === '' || str_contains($path, "\0")) { + return null; + } + if (!str_starts_with($path, '/')) { + $path = getcwd() . '/' . $path; + } + $missing = []; + $existing = rtrim($path, '/'); + while ($existing !== '' && !file_exists($existing)) { + $missing[] = basename($existing); + $existing = dirname($existing); + } + $resolved = realpath($existing === '' ? '/' : $existing); + $resolved_root = realpath($root); + if ($resolved === false || $resolved_root === false) { + return null; + } + foreach ($missing as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + return null; + } + } + $absolute = $resolved . ($missing === [] ? '' : '/' . implode('/', array_reverse($missing))); + + return str_starts_with($absolute . '/', rtrim($resolved_root, '/') . '/') && $absolute !== $resolved_root + ? $absolute + : null; +}; + +/** + * @param list $identifiers + * @return array{names: array, collisions: list} + */ +$case_names = static function (array $identifiers) use ($reserved_case_names): array { + $bases = []; + foreach ($identifiers as $identifier) { + $base = (string) preg_replace('/[^A-Z0-9_]+/', '_', strtoupper($identifier)); + if (preg_match('/^[0-9]/', $base) === 1 || in_array($base, $reserved_case_names, true)) { + $base = 'KEY_' . $base; + } + $bases[$identifier] = $base; + } + $groups = []; + foreach ($bases as $identifier => $base) { + $groups[$base][] = (string) $identifier; + } + $taken = array_fill_keys(array_keys($groups), true); + $names = []; + $collisions = []; + ksort($groups, SORT_STRING); + foreach ($groups as $base => $group) { + usort($group, static fn(string $a, string $b): int => [$a !== strtolower($a), $a] <=> [$b !== strtolower($b), $b]); + $names[$group[0]] = (string) $base; + $described = [sprintf('"%s" -> %s', $group[0], $base)]; + $suffix = 2; + foreach (array_slice($group, 1) as $identifier) { + while (isset($taken[$base . '_' . $suffix])) { + $suffix++; + } + $name = $base . '_' . $suffix; + $taken[$name] = true; + $names[$identifier] = $name; + $described[] = sprintf('"%s" -> %s', $identifier, $name); + } + if (count($group) > 1) { + $collisions[] = sprintf('Case name %s: %s', $base, implode(', ', $described)); + } + } + + $ordered = []; + foreach ($identifiers as $identifier) { + $ordered[$identifier] = $names[$identifier]; + } + + return ['names' => $ordered, 'collisions' => $collisions]; +}; + +/** + * @param array $names identifier => case name + */ +$render = static function ( + string $namespace, + string $enum_name, + string $module, + string $pot_path, + array $names +) use ($comment_safe): string { + $code = <<<'PHP' + $name) { + $code .= ' case ' . $name . ' = ' . var_export((string) $identifier, true) . ";\n"; + } + if ($names !== []) { + $code .= "\n"; + } + $code .= " public function module(): string\n {\n return " . var_export($module, true) . ";\n }\n}\n"; + + return $code; +}; + +/** + * module() cannot be called on an enum without cases, so its body is read; it must be `return '';` only. + */ +$literal_module_of = static function (ReflectionEnum $reflection): ?string { + if (!$reflection->hasMethod('module')) { + return null; + } + $method = $reflection->getMethod('module'); + $file = $method->getFileName(); + $lines = $file === false ? false : file($file); + if ($lines === false || $method->getStartLine() === false || $method->getEndLine() === false) { + return null; + } + $source = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1)); + $tokens = array_values(array_filter( + token_get_all(' !is_array($token) || !in_array($token[0], [T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true) + )); + $start = array_search('{', $tokens, true); + $body = $start === false ? [] : array_slice($tokens, $start + 1); + if ( + count($body) !== 4 + || !is_array($body[0]) || $body[0][0] !== T_RETURN + || !is_array($body[1]) || $body[1][0] !== T_CONSTANT_ENCAPSED_STRING || !str_starts_with($body[1][1], "'") + || $body[2] !== ';' || $body[3] !== '}' + ) { + return null; + } + + return strtr(substr($body[1][1], 1, -1), ['\\\\' => '\\', "\\'" => "'"]); +}; + +// --- arguments --- + +$options = ['check' => false, 'stdout' => false, 'module' => null, 'namespace' => null]; +$arguments = []; +foreach (array_slice($argv, 1) as $argument) { + if ($argument === '--check' || $argument === '--stdout') { + $options[substr($argument, 2)] = true; + } elseif (str_starts_with($argument, '--module=')) { + $options['module'] = substr($argument, strlen('--module=')); + } elseif (str_starts_with($argument, '--namespace=')) { + $options['namespace'] = substr($argument, strlen('--namespace=')); + } elseif (str_starts_with($argument, '-')) { + $fail(sprintf('Unknown option "%s".', $printable($argument))); + } else { + $arguments[] = $argument; + } +} +if (count($arguments) !== 2 || $options['namespace'] === null) { + $fail('Usage: php generate_language_identifier_enum.php [--check | --stdout] [--module=] --namespace= '); +} +if ($options['check'] && $options['stdout']) { + $fail('--check and --stdout exclude each other.'); +} +[$pot_argument, $enum_argument] = $arguments; + +$namespace = (string) $options['namespace']; +if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace) !== 1) { + $fail(sprintf('Invalid namespace "%s" (e.g. "ILIAS\\Poll", without leading backslash).', $printable($namespace))); +} + +$pot_file = $inside_repository($pot_argument, $repo_root); +if ($pot_file === null || !is_file($pot_file) || !str_ends_with($pot_file, '.pot')) { + $fail(sprintf('"%s" is no .pot file inside %s.', $printable($pot_argument), $printable($repo_root))); +} +$enum_file = $inside_repository($enum_argument, $repo_root); +if ($enum_file === null || !str_ends_with($enum_file, '.php')) { + $fail(sprintf('"%s" is no .php file path inside %s.', $printable($enum_argument), $printable($repo_root))); +} +$enum_name = basename($enum_file, '.php'); +if (preg_match($identifier_format, $enum_name) !== 1) { + $fail(sprintf('"%s" is no valid enum name (the file name without ".php").', $printable($enum_name))); +} +if ($options['check'] && !is_file($enum_file)) { + $fail(sprintf('The enum "%s" does not exist.', $printable($enum_argument))); +} +if (!$options['check'] && !$options['stdout'] && !is_dir(dirname($enum_file))) { + $fail(sprintf('The directory of "%s" does not exist.', $printable($enum_argument))); +} + +try { + $catalog = TranslationCatalog::fromPoFile($pot_file); +} catch (RuntimeException $e) { + $fail($printable($e->getMessage())); +} +$module = $options['module'] ?? $catalog->getHeader('X-Domain') ?? basename($pot_file, '.pot'); +if (!MigratedLanguageFilePaths::isValidModule($module)) { + $fail(sprintf('Invalid module "%s".', $printable($module))); +} + +$identifiers = []; +foreach ($catalog->getEntries() as $entry) { + $context = $entry->getContext(); + if ($context !== null && $context !== $module) { + $fail(sprintf('msgid "%s" has the msgctxt "%s" - only none or the module is supported.', $printable($entry->getId()), $printable($context))); + } + $identifier = $entry->getId(); + if ($identifier === '' || preg_match('/[\x00-\x1F\x7F]/', $identifier) === 1) { + $fail(sprintf('msgid "%s" cannot be a case (empty or with a control character).', $printable($identifier))); + } + $identifiers[$identifier] = $identifier; +} +['names' => $names, 'collisions' => $collisions] = $case_names(array_values($identifiers)); +foreach ($collisions as $collision) { + fwrite(STDERR, 'NOTE: ' . $printable($collision) . PHP_EOL); +} + +$pot_path = substr($pot_file, strlen(rtrim((string) realpath($repo_root), '/')) + 1); +// the path goes into a doc comment of the generated code: nothing but plain path characters +if (preg_match('/\A[A-Za-z0-9_.\/-]+\z/', $pot_path) !== 1) { + $fail(sprintf('The path "%s" of the .pot may only contain A-Z, a-z, 0-9, "_", ".", "/" and "-".', $printable($pot_path))); +} +$fqcn = $namespace . '\\' . $enum_name; + +// --- check --- + +if ($options['check']) { + if (!interface_exists(LanguageIdentifier::class)) { + require_once dirname(__DIR__, 2) . '/src/LanguageIdentifier.php'; + } + $declared_before = get_declared_classes(); + (static function (string $file): void { + require $file; + })($enum_file); + $declared = array_map('strtolower', array_diff(get_declared_classes(), $declared_before)); + if (!in_array(strtolower($fqcn), $declared, true) || !enum_exists($fqcn, false)) { + $fail(sprintf('"%s" does not declare the enum %s.', $printable($enum_argument), $printable($fqcn))); + } + $reflection = new ReflectionEnum($fqcn); + $deviations = []; + if (!$reflection->implementsInterface(LanguageIdentifier::class)) { + $deviations[] = sprintf('%s does not implement %s.', $fqcn, LanguageIdentifier::class); + } + if ((string) $reflection->getBackingType() !== 'string') { + $deviations[] = sprintf('%s is not backed by string.', $fqcn); + } + $actual = []; + foreach ($reflection->getCases() as $case) { + $actual[$case->getName()] = $case instanceof ReflectionEnumBackedCase ? $case->getBackingValue() : null; + } + $expected = array_flip($names); + foreach ($expected as $name => $identifier) { + if (!array_key_exists($name, $actual)) { + $deviations[] = sprintf('Missing case %s = %s.', $name, var_export((string) $identifier, true)); + } elseif ($actual[$name] !== (string) $identifier) { + $deviations[] = sprintf('Case %s has the value %s instead of %s.', $name, var_export($actual[$name], true), var_export((string) $identifier, true)); + } + } + foreach (array_diff_key($actual, $expected) as $name => $value) { + $deviations[] = sprintf('Surplus case %s = %s (not in the .pot).', $name, var_export($value, true)); + } + $first_case = $fqcn::cases()[0] ?? null; + $actual_module = $first_case instanceof LanguageIdentifier + ? $first_case->module() + : $literal_module_of($reflection); + if ($actual_module === null) { + $deviations[] = sprintf('module() of %s (without cases) does not just return a single-quoted string literal - cannot check it.', $fqcn); + } elseif ($actual_module !== $module) { + $deviations[] = sprintf('module() returns %s instead of %s.', var_export($actual_module, true), var_export($module, true)); + } + if ($deviations === []) { + fwrite(STDOUT, $printable(sprintf('OK: %s matches %s (%d cases, module "%s").', $fqcn, $pot_path, count($names), $module)) . PHP_EOL); + exit(0); + } + foreach ($deviations as $deviation) { + fwrite(STDERR, 'DEVIATION: ' . $printable($deviation) . PHP_EOL); + } + fwrite(STDERR, $printable(sprintf('%s deviates from %s - generate it again with %s.', $fqcn, $pot_path, LANGUAGE_IDENTIFIER_ENUM_TOOL)) . PHP_EOL); + exit(1); +} + +// --- generate --- + +$code = $render($namespace, $enum_name, $module, $pot_path, $names); +if ($options['stdout']) { + fwrite(STDOUT, $code); + exit(0); +} +if (is_file($enum_file) && file_get_contents($enum_file) === $code) { + fwrite(STDOUT, $printable(sprintf('Unchanged: %s (%d cases).', $enum_file, count($names))) . PHP_EOL); + exit(0); +} +try { + AtomicFileWriter::write($enum_file, $code, $repo_root); +} catch (RuntimeException $e) { + $fail($printable($e->getMessage())); +} +fwrite(STDOUT, $printable(sprintf('Written: %s (%d cases, module "%s").', $enum_file, count($names), $module)) . PHP_EOL); From dc529054e4213350176d3975cfc88822263953e6 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 12 Oct 2026 11:26:37 +0200 Subject: [PATCH 037/103] Language: Adapt the Activities to modules maintained in PO files The Activities write local changes and remarks of such modules into the overlay, check markup and plural forms and report unwritten overlays. UpdateLanguage re-applies the plugin language files instead of the GUI, except for its Setup instance. --- components/ILIAS/Language/Language.php | 27 +- .../classes/class.ilObjLanguageFolderGUI.php | 10 - .../src/Activities/AddLanguageEntry.php | 33 +- .../src/Activities/InstallLanguage.php | 16 +- .../src/Activities/LanguageActivity.php | 56 ++ .../src/Activities/ParsesLanguageKeyList.php | 2 +- .../Activities/RemoveLocalLanguageChanges.php | 34 +- .../src/Activities/RepositoryProvider.php | 230 ++++- .../src/Activities/UninstallLanguage.php | 15 +- .../src/Activities/UpdateLanguage.php | 22 +- .../Activities/ActivityContractTestCase.php | 32 - .../tests/Activities/AddLanguageEntryTest.php | 796 ++---------------- .../AddLanguageEntryTestFixture.php | 126 +++ .../AddLanguageEntryValidationTest.php | 348 ++++++++ .../Activities/AddLanguageEntryWritesTest.php | 424 ++++++++++ .../FakeInstalledLanguageRepository.php | 87 ++ .../tests/Activities/FakeLanguageObject.php | 83 ++ .../FakeRemoveLocalLanguageChangesObject.php | 69 ++ .../tests/Activities/GrindsFormInputTest.php | 2 +- .../Activities/InstallLanguageModesTest.php | 467 ++++++++++ .../tests/Activities/InstallLanguageTest.php | 368 +------- .../Activities/InstallLanguageTestFixture.php | 90 ++ .../LanguageActivitySetupVariantTest.php | 7 +- .../tests/Activities/LanguageActivityTest.php | 73 +- .../LanguageObjectRepositoryProvider.php | 78 ++ .../Activities/ParsesLanguageKeyListTest.php | 126 +++ .../tests/Activities/RealFieldsUiFactory.php | 10 +- ...emoveLocalLanguageChangesAmbiguityTest.php | 203 +++++ .../RemoveLocalLanguageChangesTest.php | 306 ++----- .../RemoveLocalLanguageChangesTestFixture.php | 44 + .../Activities/RepositoryProviderTest.php | 221 ++++- .../SetLanguageDetectionEnabledTest.php | 18 +- .../SetLanguageTranslationEnabledTest.php | 20 +- .../UninstallLanguageAmbiguityTest.php | 203 +++++ .../Activities/UninstallLanguageTest.php | 265 +----- .../UninstallLanguageTestFixture.php | 43 + .../tests/Activities/UpdateLanguageTest.php | 128 ++- .../tests/LanguageComponentGraphTest.php | 29 +- 38 files changed, 3420 insertions(+), 1691 deletions(-) create mode 100644 components/ILIAS/Language/tests/Activities/AddLanguageEntryTestFixture.php create mode 100644 components/ILIAS/Language/tests/Activities/AddLanguageEntryValidationTest.php create mode 100644 components/ILIAS/Language/tests/Activities/AddLanguageEntryWritesTest.php create mode 100644 components/ILIAS/Language/tests/Activities/FakeInstalledLanguageRepository.php create mode 100644 components/ILIAS/Language/tests/Activities/FakeLanguageObject.php create mode 100644 components/ILIAS/Language/tests/Activities/FakeRemoveLocalLanguageChangesObject.php create mode 100644 components/ILIAS/Language/tests/Activities/InstallLanguageModesTest.php create mode 100644 components/ILIAS/Language/tests/Activities/InstallLanguageTestFixture.php create mode 100644 components/ILIAS/Language/tests/Activities/LanguageObjectRepositoryProvider.php create mode 100644 components/ILIAS/Language/tests/Activities/ParsesLanguageKeyListTest.php create mode 100644 components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesAmbiguityTest.php create mode 100644 components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTestFixture.php create mode 100644 components/ILIAS/Language/tests/Activities/UninstallLanguageAmbiguityTest.php create mode 100644 components/ILIAS/Language/tests/Activities/UninstallLanguageTestFixture.php diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 896c37f532d0..01647e455b50 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -84,6 +84,18 @@ public function init( $internal[Language\Setup\TranslationCache::class] ); + $internal[Language\Activities\LanguageSettings::class] = static fn() => + new Language\Activities\LanguageSettingsLegacyProxy(); + + // the runtime Activities route all legacy access through this provider + $internal[RepositoryProvider::class] = static fn() => + new RepositoryProvider( + $internal[InstalledLanguageDatabaseRepository::class], + $pull[\ILIAS\Database\Connection::class], + $internal[Language\Activities\LanguageSettings::class], + $internal[LanguageFileDirectoryManager::class], + ); + $internal[InstallLanguage::class] = static fn() => new InstallLanguage( $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], @@ -95,18 +107,8 @@ public function init( 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[Language\LanguageFolder::class], + $internal[RepositoryProvider::class], ); $internal[UninstallLanguage::class] = static fn() => @@ -169,6 +171,7 @@ public function init( $internal[\ilSetupLanguage::class], // Setup checks no permissions, hence no RBAC. InstallLanguage::forSetup($internal[\ilSetupLanguage::class]), + // no plugin language files re-applied: the plugin objectives do that in the Setup UpdateLanguage::forSetup($internal[\ilSetupLanguage::class]) ); diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index 6cc4277d846c..c627f3898d12 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -555,11 +555,6 @@ public function uninstallChangesObject(array $ids): void } $value = $result->value(); - // 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']); - if (($lang_changed = $value['removed_local_changes_language_keys']) !== []) { $this->tpl->setOnScreenMessage( 'success', @@ -636,11 +631,6 @@ public function refreshSelectedObject(array $ids): void } $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', diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php index d578807fa0ec..0beed3b507c8 100644 --- a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -21,6 +21,7 @@ namespace ILIAS\Language\Activities; use ILIAS\AccessControl\PublicInterface\RBAC; +use ILIAS\Language\ComponentTranslation\TranslationMarkupPolicy; use ILIAS\Data\Description; use ILIAS\Data\Text; use ILIAS\Language\Language; @@ -121,6 +122,7 @@ public function getOutputDescription(Description\Factory $f): Description\Descri ), $f->string($text->simpleDocument('Language key of a language with no given value.')) ), + 'overlay_write_failed_language_keys' => $this->overlayWriteFailedOutputField($f), ] ); } @@ -172,12 +174,38 @@ public function perform(mixed $parameters): array ); } + // all languages are checked before anything is written; only language keys are named, never values + $values = []; + foreach ($installed_language_keys as $lang_key) { + $value = $translations[$lang_key] ?? ''; + $values[$lang_key] = is_string($value) ? trim($value) : ''; + } + $invalid_values = (new TranslationMarkupPolicy())->findInvalidValues($values); + if ($invalid_values !== []) { + throw new InvalidInputException( + 'The value contains markup that is not allowed for: ' . implode(', ', array_keys($invalid_values)) . '.' + ); + } + + // such a form would be dropped from the module's content: reject instead of reporting success + $languages_without_form = $this->repository->languagesWithoutPluralForm( + $module, + $identifier, + array_keys(array_filter($values)) + ); + if ($languages_without_form !== []) { + throw new InvalidInputException( + 'The plural message has no such form for: ' . implode(', ', $languages_without_form) . '.' + ); + } + // '' (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 = []; + $overlay_write_failed_language_keys = []; // not transactional: a failure partway leaves the languages processed so far written foreach ($installed_language_keys as $lang_key) { @@ -191,7 +219,9 @@ public function perform(mixed $parameters): array } $this->repository->replaceLangEntry($module, $identifier, $lang_key, $value, $local_change, $login); - $this->repository->updateModuleCache($lang_key, $module, $identifier, $value); + if (!$this->repository->updateModuleCache($lang_key, $module, $identifier, $value, $login)) { + $overlay_write_failed_language_keys[] = $lang_key; + } $added_language_keys[] = $lang_key; } @@ -201,6 +231,7 @@ public function perform(mixed $parameters): array 'identifier' => $identifier, 'added_language_keys' => $added_language_keys, 'skipped_empty_language_keys' => $skipped_empty_language_keys, + 'overlay_write_failed_language_keys' => $overlay_write_failed_language_keys, ]; } diff --git a/components/ILIAS/Language/src/Activities/InstallLanguage.php b/components/ILIAS/Language/src/Activities/InstallLanguage.php index f7ae7d7b44a9..b83a71af2cef 100644 --- a/components/ILIAS/Language/src/Activities/InstallLanguage.php +++ b/components/ILIAS/Language/src/Activities/InstallLanguage.php @@ -135,6 +135,8 @@ public function getOutputDescription(Description\Factory $f): Description\Descri $text->simpleDocument('Local language files with an invalid file name.'), $f->string($text->simpleDocument('File name of an invalid local language file.')) ), + 'overlay_write_failed_language_keys' => $this->overlayWriteFailedOutputField($f), + 'invalid_markup_customizing_entries' => $this->invalidMarkupCustomizingOutputField($f), ] ); } @@ -186,6 +188,7 @@ public function perform(mixed $parameters): array $installed_language_keys = []; $installed_with_local_language_keys = []; $invalid_local_language_files = []; + $overlay_write_failed_language_keys = []; $affected_language_keys = array_merge($to_fully_install, $to_apply_local_changes); if ($affected_language_keys !== []) { @@ -197,7 +200,9 @@ public function perform(mixed $parameters): array foreach ($to_fully_install as $language_key) { $this->setup_language->flushLanguageForInstallation($language_key); - $this->setup_language->insertLanguageForInstallation($language_key); + if ($this->setup_language->insertLanguageForInstallation($language_key) !== []) { + $overlay_write_failed_language_keys[] = $language_key; + } $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); if (in_array($language_key, $local_language_keys, true)) { @@ -208,7 +213,9 @@ public function perform(mixed $parameters): array } foreach ($to_apply_local_changes as $language_key) { - $this->setup_language->insertLanguageForApplyingLocalChanges($language_key); + if ($this->setup_language->insertLanguageForApplyingLocalChanges($language_key) !== []) { + $overlay_write_failed_language_keys[] = $language_key; + } $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); if (in_array($language_key, $local_language_keys, true)) { @@ -226,6 +233,11 @@ public function perform(mixed $parameters): array 'already_installed_language_keys' => $already_installed_no_op, 'not_installed_language_keys' => $not_installed_no_op, 'invalid_local_language_files' => $invalid_local_language_files, + 'overlay_write_failed_language_keys' => $overlay_write_failed_language_keys, + 'invalid_markup_customizing_entries' => $this->invalidMarkupCustomizingEntries( + $this->setup_language->getSkippedInvalidMarkupEntries(), + $affected_language_keys + ), ]; } diff --git a/components/ILIAS/Language/src/Activities/LanguageActivity.php b/components/ILIAS/Language/src/Activities/LanguageActivity.php index 64f5c0131f18..6c1cb930bd83 100644 --- a/components/ILIAS/Language/src/Activities/LanguageActivity.php +++ b/components/ILIAS/Language/src/Activities/LanguageActivity.php @@ -23,8 +23,10 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Component\Activities\ActivityImpl; use ILIAS\Component\Activities\ActivityType; +use ILIAS\Data\Description; use ILIAS\Data\Result; use ILIAS\Data\Factory as DataFactory; +use ILIAS\Language\ComponentTranslation\TranslationMarkupPolicy; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Factory as InputFactory; @@ -45,6 +47,60 @@ public function getType(): ActivityType return ActivityType::Command; } + /** The database was written, but the PO/MO overlay of a module was not: callers must not report plain success. */ + protected function overlayWriteFailedOutputField(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->list( + $text->simpleDocument( + 'Languages whose database content was written, but for which the per-installation ' . + 'PO/MO files of at least one module maintained in PO files could not be written ' . + '(typically missing write permissions of the web server user on the client data ' . + 'directory) - the details are logged.' + ), + $f->string($text->simpleDocument('Language key of a language with an unwritten PO/MO overlay.')) + ); + } + + protected function invalidMarkupCustomizingOutputField(Description\Factory $f): Description\Description + { + $text = $this->data->text()->markdown(); + + return $f->list( + $text->simpleDocument( + 'Entries of the customizing/local language files that were not applied because their ' . + 'value contains HTML that is not allowed in language texts - the languages were ' . + 'installed regardless.' + ), + $f->string($text->simpleDocument( + 'Language key and entry, followed by the markup that is not allowed, language-neutral ' . + '(": #:#: , ..."; without violations only ' . + '": #:#").' + )) + ); + } + + /** + * @param array>> $skipped see + * \ILIAS\Language\Setup\LanguageInstallationManager::getSkippedInvalidMarkupEntries() + * @param list $language_keys + * @return list the value of invalidMarkupCustomizingOutputField() + */ + protected function invalidMarkupCustomizingEntries(array $skipped, array $language_keys): array + { + $policy = new TranslationMarkupPolicy(); + $entries = []; + foreach ($language_keys as $language_key) { + foreach ($skipped[$language_key] ?? [] as $entry => $violations) { + $markup = $policy->markupOf(array_map('strval', $violations)); + $entries[] = $language_key . ': ' . $entry . ($markup === [] ? '' : ': ' . implode(', ', $markup)); + } + } + + return $entries; + } + public function isAllowedToPerform(int $usr_id, mixed $parameters): bool { if ($this->rbac === null) { diff --git a/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php index 158f9ba35f68..776d97090005 100644 --- a/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php +++ b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php @@ -23,7 +23,7 @@ trait ParsesLanguageKeyList { // two lowercase ASCII letters, like the `ilias_.lang` file names - private const LANGUAGE_KEY_FORMAT = '/^[a-z]{2}$/'; + private const LANGUAGE_KEY_FORMAT = '/^[a-z]{2}\z/'; /** @return list */ private function toLanguageKeyList(mixed $value): array diff --git a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php index 0bf7fa24a2fa..c02b6b5a7c1a 100644 --- a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php +++ b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php @@ -44,8 +44,10 @@ public function getDescription(): Text\SimpleDocumentMarkdown <<<'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. +override coming from a customizing/local language file, locally added variables +and remarks - and reinstalls each language purely from the global/component +language files (for a module maintained in PO files: from its shipped .po, its +overlay is removed); the language files of the active plugins are applied again. 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 @@ -88,6 +90,7 @@ public function getOutputDescription(Description\Factory $f): Description\Descri ), $f->string($text->simpleDocument('Language key of a not-installed language.')) ), + 'overlay_write_failed_language_keys' => $this->overlayWriteFailedOutputField($f), ] ); } @@ -116,6 +119,7 @@ public function perform(mixed $parameters): array $removed_local_changes_language_keys = []; $invalid_language_file_keys = []; $not_installed_language_keys = []; + $overlay_write_failed_language_keys = []; // not transactional: a failure partway leaves the languages processed so far changed foreach ($language_keys as $language_key) { @@ -131,19 +135,41 @@ public function perform(mixed $parameters): array continue; } - // removeLocalChanges() also validates the language file: false here means invalid + // removeLocalChanges() also validates the language file: null here means invalid // (not installed is excluded above) - if ($this->repository->removeLocalChanges($obj_id)) { + try { + $unwritten_overlay_modules = $this->repository->removeLocalChanges($obj_id); + } catch (\Throwable $t) { + // flush("all") and the rebuild are not one transaction: the language may be left incomplete + $this->repository->logWarning(sprintf( + 'Removing the local changes of language "%s" failed after its data was flushed - ' + . 'the language may be incomplete, run "php cli/setup.php update" (or "Update" in the ' + . 'language list) to rebuild it: %s', + $language_key, + $t->getMessage() + )); + throw $t; + } + if ($unwritten_overlay_modules !== null) { $removed_local_changes_language_keys[] = $language_key; + if ($unwritten_overlay_modules !== []) { + $overlay_write_failed_language_keys[] = $language_key; + } } else { $invalid_language_file_keys[] = $language_key; } } + // plugin language files are flushed with the rest, so re-apply them + if ($removed_local_changes_language_keys !== []) { + $this->repository->refreshPlugins($removed_local_changes_language_keys); + } + 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, + 'overlay_write_failed_language_keys' => $overlay_write_failed_language_keys, ]; } } diff --git a/components/ILIAS/Language/src/Activities/RepositoryProvider.php b/components/ILIAS/Language/src/Activities/RepositoryProvider.php index d664f6735936..7e695e18571b 100644 --- a/components/ILIAS/Language/src/Activities/RepositoryProvider.php +++ b/components/ILIAS/Language/src/Activities/RepositoryProvider.php @@ -20,10 +20,16 @@ namespace ILIAS\Language\Activities; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync; +use ILIAS\Language\ComponentTranslation\PluralFormKey; use ILIAS\Language\Setup\InstalledLanguageRepository; /** - * Every legacy and repository access of the runtime language activities. + * Every legacy and repository access of the runtime language activities. No component offers the + * logger and the component repository yet, so they are resolved from the legacy container on first use. + * Without a LanguageFileDirectoryManager no module is treated as maintained in PO files. */ class RepositoryProvider { @@ -31,6 +37,7 @@ public function __construct( private readonly InstalledLanguageRepository $installed_language_repository, private readonly \ilDBInterface $db, private readonly LanguageSettings $settings, + private readonly ?LanguageFileDirectoryManager $language_file_directory_manager = null, ) { } @@ -67,16 +74,59 @@ 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 + /** + * The overlay state is only known to the object that uninstalled, hence returned here. + * + * @return list|null null if a guard of ilObjLanguage::uninstall() applied and nothing was + * uninstalled, otherwise the modules whose PO/MO overlay could not be written + */ + public function uninstall(int $obj_id): ?array { - return $this->languageObject($obj_id)->uninstall() !== ''; + $language = $this->languageObject($obj_id); + if ($language->uninstall() === '') { + return null; + } + + return array_values($language->getModulesWithUnwrittenOverlay()); } - /** False if the language is not installed or its language file is invalid. */ - public function removeLocalChanges(int $obj_id): bool + /** + * @return list|null null if the language is not installed or its language file is invalid, + * otherwise the modules whose PO/MO overlay could not be written + */ + public function removeLocalChanges(int $obj_id): ?array { - return $this->languageObject($obj_id)->removeLocalChanges(); + $language = $this->languageObject($obj_id); + if (!$language->removeLocalChanges()) { + return null; + } + + return array_values($language->getModulesWithUnwrittenOverlay()); + } + + /** + * Re-applies the language files of the active plugins; a no-op without the component repository (Setup). + * + * @param list $lang_keys + */ + public function refreshPlugins(array $lang_keys): void + { + global $DIC; + + if ($DIC instanceof \ILIAS\DI\Container && $DIC->offsetExists('component.repository')) { + \ilObjLanguage::refreshPlugins($lang_keys); + } + } + + public function logWarning(string $message): void + { + global $DIC; + + try { + $DIC->logger()->forComponent('lang')->warning($message); + } catch (\Throwable) { + error_log($message); + } } public function replaceLangEntry( @@ -90,25 +140,89 @@ public function replaceLangEntry( 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') + /** + * Writes the entry into the module content ilLanguage serves, and $remarks into the overlay. + * + * @return bool false if the entry reached lng_data, but the PO/MO overlay could not be written + */ + public function updateModuleCache( + string $lang_key, + string $module, + string $identifier, + string $value, + string $remarks = '' + ): bool { + $client_data_dir = MigratedLanguageFilePaths::resolveClientDataDir(ILIAS_ABSOLUTE_PATH); + + // read and written under the overlay lock, so a concurrent write is not lost + return MigratedLanguageFileSync::withOverlayLock( + $this->language_file_directory_manager, + $lang_key, + $module, + $client_data_dir, + function () use ($lang_key, $module, $identifier, $value, $remarks, $client_data_dir): bool { + $entries = $this->currentModuleEntries($lang_key, $module); + if ($entries === null) { + // no lng_modules row of a module not maintained in PO files: lng_data holds the entry + return true; + } + $entries[$this->entryKeyOf($entries, $identifier)] = $value; + $written = \ilObjLanguage::replaceLangModule($lang_key, $module, $entries); + if ($remarks === '' || $this->language_file_directory_manager === null) { + return $written; + } + try { + MigratedLanguageFileSync::setRemarks( + $this->language_file_directory_manager, + $lang_key, + $module, + [$identifier => $remarks], + $client_data_dir, + ILIAS_ABSOLUTE_PATH + ); + } catch (\Throwable) { + return false; + } + return $written; + }, + ILIAS_ABSOLUTE_PATH ); - $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; + /** + * The languages whose shipped `.po` has a plural message for $identifier that lacks this form + * (e.g. `poll_population [2]`); a `.po` that cannot be read is skipped. + * + * @param list $lang_keys + * @return list + */ + public function languagesWithoutPluralForm(string $module, string $identifier, array $lang_keys): array + { + if (PluralFormKey::parse($identifier) === null || $this->language_file_directory_manager === null) { + return []; + } + $languages = []; + foreach ($lang_keys as $lang_key) { + $lang_key = (string) $lang_key; + $shipped_po = MigratedLanguageFileSync::findShippedModuleFiles( + $this->language_file_directory_manager, + ILIAS_ABSOLUTE_PATH, + $lang_key + )[$module] ?? null; + if ($shipped_po === null) { + continue; + } + try { + $shipped = MigratedLanguageFileSync::loadShippedModuleEntries($shipped_po, $module); + } catch (\Throwable) { + continue; + } + if (MigratedLanguageFileSync::pluralMessageOf($identifier, $shipped) !== null && !array_key_exists($identifier, $shipped)) { + $languages[] = $lang_key; + } } - $entries[$identifier] = $value; - \ilObjLanguage::replaceLangModule($lang_key, $module, $entries); + return $languages; } public function lookupLogin(int $usr_id): string @@ -131,6 +245,78 @@ public function setPageTranslationEnabled(string $lang_key, bool $enabled): void $this->settings->setPageTranslationEnabled($lang_key, $enabled); } + /** + * The default form's key for a plural message of a module maintained in PO files (the one the + * database row stands for), otherwise $identifier itself. + * + * @param array $entries + */ + private function entryKeyOf(array $entries, string $identifier): string + { + return MigratedLanguageFileSync::defaultFormKeyOf($identifier, $entries) ?? $identifier; + } + + /** + * The content the new entry is added to: for a module maintained in PO files what ilLanguage serves + * (shipped state plus overlay), otherwise or if unreadable the lng_modules row. Without a row only a + * PO module is rebuilt from lng_data, so its entry never hides behind the overlay; any other module + * gives null, since the module name is free input and no row may be created for an arbitrary one. + * + * @return array|null identifier => value + */ + private function currentModuleEntries(string $lang_key, string $module): ?array + { + $is_migrated = $this->language_file_directory_manager !== null + && isset(MigratedLanguageFileSync::findShippedModuleFiles( + $this->language_file_directory_manager, + ILIAS_ABSOLUTE_PATH, + $lang_key + )[$module]); + if ($is_migrated) { + try { + $overlay = MigratedLanguageFileSync::loadModuleTranslations( + $this->language_file_directory_manager, + $lang_key, + $module, + MigratedLanguageFilePaths::resolveClientDataDir(ILIAS_ABSOLUTE_PATH), + ILIAS_ABSOLUTE_PATH + ); + } catch (\Throwable) { + // an unreadable overlay is rebuilt by the sync, the database content is the base then + $overlay = null; + } + if ($overlay !== null) { + return array_map(static fn(array $entry): string => $entry['value'], $overlay); + } + } + + $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); + $entries = is_string($row['lang_array'] ?? null) + ? unserialize($row['lang_array'], ['allowed_classes' => false]) + : null; + if (is_array($entries)) { + return $entries; + } + if (!$is_migrated) { + return null; + } + + $entries = []; + $set = $this->db->query( + 'SELECT identifier, value FROM lng_data WHERE lang_key = ' + . $this->db->quote($lang_key, 'text') . ' AND module = ' . $this->db->quote($module, 'text') + ); + while ($row = $this->db->fetchAssoc($set)) { + $entries[(string) $row['identifier']] = (string) $row['value']; + } + + return $entries; + } + private function languageObject(int $obj_id): \ilObjLanguage { return new \ilObjLanguage($obj_id); diff --git a/components/ILIAS/Language/src/Activities/UninstallLanguage.php b/components/ILIAS/Language/src/Activities/UninstallLanguage.php index c6dfa6942020..d835180df65a 100644 --- a/components/ILIAS/Language/src/Activities/UninstallLanguage.php +++ b/components/ILIAS/Language/src/Activities/UninstallLanguage.php @@ -94,6 +94,7 @@ public function getOutputDescription(Description\Factory $f): Description\Descri ), $f->string($text->simpleDocument('Language key of a not-installed language.')) ), + 'overlay_write_failed_language_keys' => $this->overlayWriteFailedOutputField($f), ] ); } @@ -123,6 +124,7 @@ public function perform(mixed $parameters): array $system_language_keys = []; $user_language_keys = []; $not_installed_language_keys = []; + $overlay_write_failed_language_keys = []; // not transactional: a failure partway leaves the languages processed so far uninstalled foreach ($language_keys as $language_key) { @@ -141,11 +143,17 @@ public function perform(mixed $parameters): array $user_language_keys[] = $language_key; } elseif (!$this->repository->isInstalled($obj_id)) { $not_installed_language_keys[] = $language_key; - } elseif ($this->repository->uninstall($obj_id)) { + } else { // uninstall() re-checks the guards: its result decides + $unwritten_overlay_modules = $this->repository->uninstall($obj_id); + if ($unwritten_overlay_modules === null) { + $not_installed_language_keys[] = $language_key; + continue; + } $uninstalled_language_keys[] = $language_key; - } else { - $not_installed_language_keys[] = $language_key; + if ($unwritten_overlay_modules !== []) { + $overlay_write_failed_language_keys[] = $language_key; + } } } @@ -154,6 +162,7 @@ public function perform(mixed $parameters): array 'system_language_keys' => $system_language_keys, 'user_language_keys' => $user_language_keys, 'not_installed_language_keys' => $not_installed_language_keys, + 'overlay_write_failed_language_keys' => $overlay_write_failed_language_keys, ]; } } diff --git a/components/ILIAS/Language/src/Activities/UpdateLanguage.php b/components/ILIAS/Language/src/Activities/UpdateLanguage.php index 8a66b2fa0ae6..a1c53f2288b0 100644 --- a/components/ILIAS/Language/src/Activities/UpdateLanguage.php +++ b/components/ILIAS/Language/src/Activities/UpdateLanguage.php @@ -30,10 +30,12 @@ class UpdateLanguage extends LanguageActivity { use DeclaresLanguageKeysOnlyInput; + /** @param RepositoryProvider|null $repository null in the Setup, whose plugin objectives apply the plugin language files */ public function __construct( ?RBAC $rbac, private readonly \ilSetupLanguage $setup_language, LanguageFolder $language_folder, + private readonly ?RepositoryProvider $repository = null, ) { parent::__construct($rbac, $language_folder); } @@ -54,8 +56,9 @@ public function getDescription(): Text\SimpleDocumentMarkdown <<<'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. +language files; the language files of the active plugins are applied again. +A language that is not installed is left completely untouched - use +InstallLanguage to install it first. MARKDOWN ); } @@ -87,6 +90,8 @@ public function getOutputDescription(Description\Factory $f): Description\Descri ), $f->string($text->simpleDocument('Language key of a not-yet-installed language.')) ), + 'overlay_write_failed_language_keys' => $this->overlayWriteFailedOutputField($f), + 'invalid_markup_customizing_entries' => $this->invalidMarkupCustomizingOutputField($f), ] ); } @@ -125,20 +130,31 @@ public function perform(mixed $parameters): array ); } + $overlay_write_failed_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); + if ($this->setup_language->insertLanguageForInstallation($language_key) !== []) { + $overlay_write_failed_language_keys[] = $language_key; + } $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); } + + // plugin language files are flushed with the rest, so re-apply them + $this->repository?->refreshPlugins($to_update); } return [ 'updated_language_keys' => $to_update, 'not_installed_language_keys' => $not_installed_no_op, + 'overlay_write_failed_language_keys' => $overlay_write_failed_language_keys, + 'invalid_markup_customizing_entries' => $this->invalidMarkupCustomizingEntries( + $this->setup_language->getSkippedInvalidMarkupEntries(), + $to_update + ), ]; } } diff --git a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php index 91f79a0a1aa8..e8de097665e1 100644 --- a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php +++ b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php @@ -20,9 +20,7 @@ namespace ILIAS\Language\Tests\Activities; -use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Tests\Activities\RealFieldsUiFactory; -use PHPUnit\Framework\MockObject\MockObject; use ILIAS\Component\Activities\Activity; use ILIAS\Component\Activities\ActivityType; use ilLanguageBaseTestCase; @@ -86,33 +84,3 @@ public function testGetInputDescriptionSignatureExactlyMatchesTheActivityInterfa } } } - -/** - * 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/AddLanguageEntryTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php index 5f7613628f7c..58b430426d27 100644 --- a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -6,8 +6,8 @@ * * 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. + * 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: @@ -20,7 +20,6 @@ namespace ILIAS\Language\Tests\Activities; -use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\AddLanguageEntry; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Language; @@ -29,7 +28,6 @@ use ILIAS\UI\Component\Input\Field\Text; use ILIAS\Data\Description\Factory as DescriptionFactory; use ILIAS\Language\Activities\RepositoryProvider; -use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -39,6 +37,8 @@ */ class AddLanguageEntryTest extends ActivityContractTestCase { + use AddLanguageEntryTestFixture; + protected function createDefaultActivity(): AddLanguageEntry { return $this->createActivity([]); @@ -59,7 +59,7 @@ public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfig public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void { - $rbac = $this->createMock(\ilRbacSystem::class); + $rbac = $this->createStub(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(false); $activity = $this->createActivity([], rbac: $rbac); @@ -73,7 +73,7 @@ public function testIsAllowedToPerformNeverTouchesRepositoryOrWriteClosures(): v throw new \LogicException('getInstalledLanguages() must never be called by isAllowedToPerform()'); }); - $rbac = $this->createMock(\ilRbacSystem::class); + $rbac = $this->createStub(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); $replace_lang_entry = static function (): bool { @@ -94,551 +94,6 @@ public function testIsAllowedToPerformNeverTouchesRepositoryOrWriteClosures(): v $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); @@ -648,7 +103,7 @@ public function testPermissionDeniedBeforePerformNeverCallsWriteClosuresAndRetur ->willReturn(false); // getInputDescription() calls txt('meta_l_de') before isAllowedToPerform(), so the mock must allow it too - $language = $this->createMock(Language::class); + $language = $this->createStub(Language::class); $language->method('txt')->willReturnCallback( static fn(string $key): string => $key === 'msg_no_perm_write' ? 'no write permission' : $key ); @@ -676,7 +131,7 @@ public function testPermissionDeniedBeforePerformNeverCallsWriteClosuresAndRetur public function testPermissionGrantedPerformsAndReturnsOkResultWithExpectedStructure(): void { - $rbac = $this->createMock(\ilRbacSystem::class); + $rbac = $this->createStub(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); $activity = $this->createActivity(['de', 'fr'], rbac: $rbac); @@ -725,7 +180,7 @@ public function testUsrIdArgumentIsUsedForBothThePermissionCheckAndTheAuditLogin public function testDefectFromWithinPerformIsMaskedAsA500ResultErrorKeepingTheOriginal(): void { - $rbac = $this->createMock(\ilRbacSystem::class); + $rbac = $this->createStub(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); $replace_lang_entry = static function (): bool { @@ -869,7 +324,7 @@ public function testMaybePerformAsRejectsInvalidRawParametersAsResultError(array public function testNormalizeParametersTrimsModuleAndIdentifierButKeepsTranslationsAsGiven(): void { - $rbac = $this->createMock(\ilRbacSystem::class); + $rbac = $this->createStub(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); $calls = []; @@ -889,26 +344,6 @@ public function testNormalizeParametersTrimsModuleAndIdentifierButKeepsTranslati $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. @@ -917,7 +352,7 @@ public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLangua 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 = $this->createStub(Language::class); $language->method('txt')->willReturnCallback(static fn(string $k): string => $k); $repository = $this->createMock(RepositoryProvider::class); @@ -943,32 +378,62 @@ public function getInputDescription(FieldFactory $f): FormInput 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(); + $module_text = $this->createStub(Text::class); + $module_text->method('withRequired')->willReturnCallback( + function (bool $required) use ($module_text): Text { + $this->assertTrue($required); + return $module_text; + } + ); + $module_text->method('withDedicatedName')->willReturnCallback( + function (string $name) use ($module_text): Text { + $this->assertSame('module', $name); + return $module_text; + } + ); - $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(); + $identifier_text = $this->createStub(Text::class); + $identifier_text->method('withRequired')->willReturnCallback( + function (bool $required) use ($identifier_text): Text { + $this->assertTrue($required); + return $identifier_text; + } + ); + $identifier_text->method('withDedicatedName')->willReturnCallback( + function (string $name) use ($identifier_text): Text { + $this->assertSame('identifier', $name); + return $identifier_text; + } + ); - $de_text = $this->createMock(Text::class); + $de_text = $this->createStub(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(); + $de_text->method('withDedicatedName')->willReturnCallback( + function (string $name) use ($de_text): Text { + $this->assertSame('de', $name); + return $de_text; + } + ); - $fr_text = $this->createMock(Text::class); + $fr_text = $this->createStub(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(); + $fr_text->method('withDedicatedName')->willReturnCallback( + function (string $name) use ($fr_text): Text { + $this->assertSame('fr', $name); + return $fr_text; + } + ); $text_calls = []; - $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field = $this->createStub(\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; @@ -982,8 +447,13 @@ function (string $label, ?string $byline = null) use (&$text_calls, $module_text } ); - $translations_group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::class); - $translations_group->expects($this->once())->method('withDedicatedName')->with('translations')->willReturnSelf(); + $translations_group = $this->createStub(\ILIAS\UI\Component\Input\Field\Group::class); + $translations_group->method('withDedicatedName')->willReturnCallback( + function (string $name) use ($translations_group): \ILIAS\UI\Component\Input\Field\Group { + $this->assertSame('translations', $name); + return $translations_group; + } + ); $outer_section = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class); @@ -993,8 +463,6 @@ 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; @@ -1032,7 +500,7 @@ function (array $fields, string $label) use (&$section_calls, $module_text, $ide $this->assertFalse($fr_required); } - public function testOutputDescriptionDeclaresTheExpectedFourFields(): void + public function testOutputDescriptionDeclaresTheExpectedFiveFields(): void { $activity = $this->createActivity(['de']); @@ -1044,150 +512,8 @@ public function testOutputDescriptionDeclaresTheExpectedFourFields(): void } $this->assertSame( - ['module', 'identifier', 'added_language_keys', 'skipped_empty_language_keys'], + ['module', 'identifier', 'added_language_keys', 'skipped_empty_language_keys', 'overlay_write_failed_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/AddLanguageEntryTestFixture.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTestFixture.php new file mode 100644 index 000000000000..388f0bada199 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTestFixture.php @@ -0,0 +1,126 @@ + $calls + */ + protected 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 + */ + protected function spyUpdateModuleCache(array &$calls): \Closure + { + return static function ( + string $lang_key, + string $module, + string $identifier, + string $value, + string $remarks = '' + ) use (&$calls): void { + $calls['cache'][] = [$lang_key, $module, $identifier, $value]; + }; + } + + /** + * The provider answers from the closures; a closure of update_module_cache() returning null (void) counts as + * written, as does the default. + * + * @param list $installed_languages + * @param \Closure|null $languages_without_plural_form (string $module, string $identifier, array $lang_keys): list + */ + protected 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, + ?\Closure $languages_without_plural_form = null, + ): AddLanguageEntry { + // no-op defaults; tests proving a closure is never called pass a throwing one + $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 fn( + string $lang_key, + string $module, + string $identifier, + string $value, + string $remarks = '' + ): bool => true; + $user_login ??= static fn(int $usr_id): string => 'default-login'; + $languages_without_plural_form ??= static fn(string $module, string $identifier, array $lang_keys): array => []; + $installed_language_repository ??= new FakeInstalledLanguageRepository(static fn(): array => $installed_languages); + + $repository = $this->createStub(RepositoryProvider::class); + $repository->method('getInstalledLanguages')->willReturnCallback( + static fn(): array => $installed_language_repository->getInstalledLanguages() + ); + $repository->method('replaceLangEntry')->willReturnCallback($replace_lang_entry); + $repository->method('updateModuleCache')->willReturnCallback( + static fn( + string $lang_key, + string $module, + string $identifier, + string $value, + string $remarks = '' + ): bool => $update_module_cache($lang_key, $module, $identifier, $value, $remarks) ?? true + ); + $repository->method('lookupLogin')->willReturnCallback($user_login); + $repository->method('languagesWithoutPluralForm')->willReturnCallback($languages_without_plural_form); + + return new AddLanguageEntry( + $language ?? $this->createStub(Language::class), + $this->rbacOf($rbac ?? $this->createStub(\ilRbacSystem::class)), + $this->languageFolder($language_folder_ref_id), + $repository, + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryValidationTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryValidationTest.php new file mode 100644 index 000000000000..580b7c0fc85d --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryValidationTest.php @@ -0,0 +1,348 @@ +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', + ], + 'usr_id' => 6, + ]); + $this->fail('Expected an InvalidInputException to be thrown.'); + } catch (InvalidInputException $e) { + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + } + + /** + * Disallowed markup (TranslationMarkupPolicy) in a single language rejects the whole request before any write. + */ + public function testDisallowedMarkupInOneLanguageRejectsTheWholeRequestAndWritesNothingForAnyLanguage(): void + { + $calls = []; + $activity = $this->createActivity( + ['de', 'en', 'fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => 'Hello', + 'fr' => '', + ], + 'usr_id' => 6, + ]); + $this->fail('Expected an InvalidInputException to be thrown.'); + } catch (InvalidInputException $e) { + $this->assertStringContainsString('fr', $e->getMessage()); + $this->assertStringNotContainsString('alert(1)', $e->getMessage()); + $this->assertStringNotContainsString('script', $e->getMessage()); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + } + + /** + * A form key missing from the shipped message (here "item [2]", reported by the provider) rejects the request + * before any write, naming the language but not the value. + */ + public function testAFormKeyWithNoSuchFormInAShippedMessageRejectsTheRequestBeforeAnyWrite(): void + { + $calls = []; + $asked = []; + $activity = $this->createActivity( + ['de', 'fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls), + languages_without_plural_form: static function (string $module, string $identifier, array $lang_keys) use (&$asked): array { + $asked[] = [$module, $identifier, $lang_keys]; + return ['de']; + } + ); + + try { + $activity->perform([ + 'module' => 'poll', + 'identifier' => 'item [2]', + 'translations' => ['de' => 'Ein geheimer Wert', 'fr' => ''], + 'usr_id' => 6, + ]); + $this->fail('Expected an InvalidInputException to be thrown.'); + } catch (InvalidInputException $e) { + $this->assertStringContainsString('de', $e->getMessage()); + $this->assertStringNotContainsString('Ein geheimer Wert', $e->getMessage()); + // only the languages with a value are asked + $this->assertSame([['poll', 'item [2]', ['de']]], $asked); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + } + + // same de/en rule as the perform() test above, via maybePerformAs() + public function testMaybePerformAsRejectsWholeRequestAndWritesNothingWhenEnIsBlankWhileDeIsValid(): void + { + $rbac = $this->createStub(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + rbac: $rbac, + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => ' ', // whitespace-only counts as blank too + ], + ]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testPerformRejectsWholeRequestAndWritesNothingWhenDeIsBlankWhileEnIsValid(): void + { + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $this->expectException(InvalidInputException::class); + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['en' => 'Hello'], + 'usr_id' => 6, + ]); + } finally { + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + } + + public function testDeAndEnNotInstalledAtAllImposesNoMandatoryRequirement(): void + { + $calls = []; + $activity = $this->createActivity( + ['fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + } + + public static function invalidPerformParametersProvider(): array + { + return [ + 'missing module' => [['identifier' => 'foo', 'translations' => [], 'usr_id' => 6]], + 'empty string module' => [['module' => '', 'identifier' => 'foo', 'translations' => [], 'usr_id' => 6]], + 'non-string module' => [['module' => 42, 'identifier' => 'foo', 'translations' => [], 'usr_id' => 6]], + 'missing identifier' => [['module' => 'common', 'translations' => [], 'usr_id' => 6]], + 'empty string identifier' => [['module' => 'common', 'identifier' => '', 'translations' => [], 'usr_id' => 6]], + 'non-string identifier' => [['module' => 'common', 'identifier' => [], 'translations' => [], 'usr_id' => 6]], + 'missing translations' => [['module' => 'common', 'identifier' => 'foo', 'usr_id' => 6]], + 'non-array translations' => [['module' => 'common', 'identifier' => 'foo', 'translations' => 'nope', 'usr_id' => 6]], + // a missing/null usr_id is allowed; only a non-null usr_id of the wrong type is invalid + 'non-int usr_id' => [['module' => 'common', 'identifier' => 'foo', 'translations' => [], 'usr_id' => '6']], + ]; + } + + #[DataProvider('invalidPerformParametersProvider')] + public function testPerformRejectsInvalidOrIncompleteParameters(array $parameters): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(['de'])->perform($parameters); + } + + public function testPerformRejectsNonArrayParameters(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(['de'])->perform('not-an-array'); + } + + /** + * The fake repository returns different installed-language lists on the 1st (getInputDescription()) and + * 2nd (perform()) call. + */ + public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerformIsSkippedNotRejected(): void + { + $rbac = $this->createStub(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = 0; + $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { + $calls++; + // 1st call: 'fr' not installed yet; 2nd call: installed + return $calls === 1 ? ['de', 'en'] : ['de', 'en', 'fr']; + }); + + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + ]); + + $this->assertFalse($result->isError()); + $value = $result->value(); + $this->assertSame(['de', 'en'], $value['added_language_keys']); + $this->assertSame(['fr'], $value['skipped_empty_language_keys']); + // two independent snapshots, not a cached one + $this->assertSame(2, $calls); + } + + // fail-closed: "en" becomes mandatory after the form was built without it; the request must be rejected + public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWholeRequestRatherThanSkippingEn(): void + { + $rbac = $this->createStub(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = 0; + $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { + $calls++; + // 1st call: only "de" installed; 2nd call: "en" installed + return $calls === 1 ? ['de'] : ['de', 'en']; + }); + + $calls_to_write_closures = []; + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository, + replace_lang_entry: $this->spyReplaceLangEntry($calls_to_write_closures), + update_module_cache: $this->spyUpdateModuleCache($calls_to_write_closures) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertSame('A value is required for: en.', $error->getMessage()); + $this->assertArrayNotHasKey('replace', $calls_to_write_closures); + $this->assertArrayNotHasKey('cache', $calls_to_write_closures); + // two independent snapshots, not a cached one + $this->assertSame(2, $calls); + } + + /** + * An optional language uninstalled between the two calls has its submitted value silently dropped. + */ + public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerformIsSilentlyDroppedNotWritten(): void + { + $rbac = $this->createStub(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = 0; + $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { + $calls++; + // 1st call: "fr" installed; 2nd call: uninstalled + return $calls === 1 ? ['de', 'en', 'fr'] : ['de', 'en']; + }); + + $calls_to_write_closures = []; + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository, + replace_lang_entry: $this->spyReplaceLangEntry($calls_to_write_closures), + update_module_cache: $this->spyUpdateModuleCache($calls_to_write_closures) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => 'Bonjour'], + ]); + + $this->assertFalse($result->isError()); + $value = $result->value(); + $this->assertSame(['de', 'en'], $value['added_language_keys']); + $this->assertSame([], $value['skipped_empty_language_keys']); + $this->assertNotContains('fr', $value['added_language_keys']); + $this->assertNotContains('fr', $value['skipped_empty_language_keys']); + $this->assertArrayHasKey('replace', $calls_to_write_closures); + $written_lang_keys = array_column($calls_to_write_closures['replace'], 2); + $this->assertSame(['de', 'en'], $written_lang_keys); + $this->assertNotContains('fr', $written_lang_keys); + // two independent snapshots, not a cached one + $this->assertSame(2, $calls); + } +} diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryWritesTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryWritesTest.php new file mode 100644 index 000000000000..b050e74f9845 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryWritesTest.php @@ -0,0 +1,424 @@ + 'login-' . $usr_id; + + $activity = $this->createActivity( + ['de', 'en', 'fr'], + replace_lang_entry: $replace_lang_entry, + update_module_cache: $update_module_cache, + user_login: $user_login + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => 'Hello', + ], + 'usr_id' => 6, + ]); + + $this->assertSame('common', $result['module']); + $this->assertSame('new_topic', $result['identifier']); + $this->assertSame(['de', 'en'], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + + $this->assertCount(2, $calls['replace']); + $this->assertSame(['common', 'new_topic', 'de', 'Hallo'], array_slice($calls['replace'][0], 0, 4)); + $this->assertSame('login-6', $calls['replace'][0][5]); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $calls['replace'][0][4]); + $this->assertSame(['common', 'new_topic', 'en', 'Hello'], array_slice($calls['replace'][1], 0, 4)); + + $this->assertCount(2, $calls['cache']); + $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]); + $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]); + + foreach ($calls['replace'] as $call) { + $this->assertNotSame('fr', $call[2]); + } + foreach ($calls['cache'] as $call) { + $this->assertNotSame('fr', $call[0]); + } + } + + /** + * update_module_cache() returns `false` when the PO/MO overlay of a PO-maintained module could not be written: + * the language is listed in "overlay_write_failed_language_keys" but still counts as added. + */ + public function testALanguageWhoseModuleCacheUpdateReportsAnUnwrittenOverlayIsListed(): void + { + $results = ['de' => false, 'en' => true, 'fr' => null]; + $update_module_cache = static fn( + string $lang_key, + string $module, + string $identifier, + string $value + ): ?bool => $results[$lang_key]; + + $result = $this->createActivity(['de', 'en', 'fr'], update_module_cache: $update_module_cache)->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => 'Bonjour'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de', 'en', 'fr'], $result['added_language_keys']); + $this->assertSame(['de'], $result['overlay_write_failed_language_keys']); + } + + /** + * A skipped language never reaches update_module_cache(), so it is never listed as unwritten. + */ + public function testASkippedLanguageIsNeverListedAsHavingAnUnwrittenOverlay(): void + { + $update_module_cache = static fn( + string $lang_key, + string $module, + string $identifier, + string $value + ): bool => false; + + $result = $this->createActivity(['de', 'en', 'fr'], update_module_cache: $update_module_cache)->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + 'usr_id' => 6, + ]); + + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + $this->assertSame(['de', 'en'], $result['overlay_write_failed_language_keys']); + } + + /** + * Deliberately not transactional: if replace_lang_entry() throws on the 3rd of 3 languages, the earlier + * writes stay and the exception propagates. + */ + public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEarlierLanguagesWritten(): void + { + $calls = []; + $replace_lang_entry = static function ( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ) use (&$calls): bool { + $calls['replace'][] = [$module, $identifier, $lang_key, $value, $local_change, $remarks]; + if ($lang_key === 'fr') { + throw new \RuntimeException('simulated database error while writing "fr"'); + } + return true; + }; + + $activity = $this->createActivity( + ['de', 'en', 'fr'], + replace_lang_entry: $replace_lang_entry, + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => 'Hello', + 'fr' => 'Bonjour', + ], + 'usr_id' => 6, + ]); + $this->fail('Expected the simulated \RuntimeException to propagate out of perform().'); + } catch (\RuntimeException $e) { + $this->assertSame('simulated database error while writing "fr"', $e->getMessage()); + + // "de" and "en", written before the failing "fr", remain written + $this->assertSame( + ['common', 'new_topic', 'de', 'Hallo', $calls['replace'][0][4], 'default-login'], + $calls['replace'][0] + ); + $this->assertSame( + ['common', 'new_topic', 'en', 'Hello', $calls['replace'][1][4], 'default-login'], + $calls['replace'][1] + ); + $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]); + $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]); + + // "fr" was attempted but never reached update_module_cache() + $this->assertCount(3, $calls['replace']); + $this->assertCount(2, $calls['cache']); + } + } + + /** + * Markup that TranslationMarkupPolicy allows (plain tag, link with allowed scheme) never blocks the request. + */ + public function testAllowedMarkupInEveryLanguageIsWritten(): void + { + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => 'Hello', + ], + 'usr_id' => 6, + ]); + + $this->assertSame(['de', 'en'], $result['added_language_keys']); + $this->assertSame('Hallo', $calls['replace'][0][3]); + $this->assertSame('Hello', $calls['replace'][1][3]); + } + + public function testWhitespaceOnlyValueIsTreatedAsEmptyAndSkipped(): void + { + // 'fr' avoids the separate 'de'/'en' mandatory-value rule + $calls = []; + $activity = $this->createActivity( + ['fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['fr' => " \t "], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testValueSurroundedByWhitespaceIsTrimmedBeforeBeingWritten(): void + { + $calls = []; + $activity = $this->createActivity( + ['de'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => " Hallo Welt "], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + $this->assertSame('Hallo Welt', $calls['replace'][0][3]); + $this->assertSame('Hallo Welt', $calls['cache'][0][3]); + } + + /** + * A non-string value (direct perform() caller bypassing normalizeParameters()) counts as empty. + */ + public function testNonStringTranslationValueIsTreatedAsEmptyAndSkippedRatherThanCoerced(): void + { + // 'fr' avoids the separate 'de'/'en' mandatory-value rule + $calls = []; + $activity = $this->createActivity( + ['fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['fr' => 42], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + } + + public function testNoInstalledLanguagesResultsInEmptyBucketsAndNoWrites(): void + { + $calls = []; + $activity = $this->createActivity( + [], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame([], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testUserLoginClosureIsCalledExactlyOnceWithTheGivenUsrIdRegardlessOfLanguageCount(): void + { + $login_calls = []; + $user_login = static function (int $usr_id) use (&$login_calls): string { + $login_calls[] = $usr_id; + return 'resolved-login'; + }; + + $activity = $this->createActivity(['de', 'en'], user_login: $user_login); + + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + 'usr_id' => 99, + ]); + + $this->assertSame([99], $login_calls); + } + + // a missing usr_id is allowed: a caller following the plain Activity contract never supplies one + public function testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure(): void + { + $login_calls = []; + $user_login = static function (int $usr_id) use (&$login_calls): string { + $login_calls[] = $usr_id; + return 'must-not-be-used'; + }; + + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls), + user_login: $user_login + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + ]); + + $this->assertSame(['de', 'en'], $result['added_language_keys']); + $this->assertSame([], $login_calls, 'user_login() must never be called when usr_id is absent.'); + $this->assertSame('', $calls['replace'][0][5]); + $this->assertSame('', $calls['replace'][1][5]); + } + + /** + * A missing or null usr_id must succeed; only a non-null usr_id of the wrong type is rejected. + */ + public static function validUsrIdMissingOrNullWithValidTranslationsProvider(): array + { + return [ + 'missing usr_id' => [ + ['module' => 'common', 'identifier' => 'foo', 'translations' => ['de' => 'Hallo', 'en' => 'Hello']], + ], + 'null usr_id' => [ + [ + 'module' => 'common', + 'identifier' => 'foo', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + 'usr_id' => null, + ], + ], + ]; + } + + #[DataProvider('validUsrIdMissingOrNullWithValidTranslationsProvider')] + public function testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations(array $parameters): void + { + $activity = $this->createActivity(['de', 'en']); + + $result = $activity->perform($parameters); + + $this->assertSame('common', $result['module']); + $this->assertSame('foo', $result['identifier']); + $this->assertSame(['de', 'en'], $result['added_language_keys']); + } + + /** + * An empty translations array is legal: every installed language is reported skipped, not rejected. + */ + public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLanguage(): void + { + // 'fr'/'it' avoid the separate 'de'/'en' mandatory-value rule + $rbac = $this->createStub(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(['fr', 'it'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [], + ]); + + $this->assertFalse($result->isError()); + $this->assertSame([], $result->value()['added_language_keys']); + $this->assertSame(['fr', 'it'], $result->value()['skipped_empty_language_keys']); + } +} diff --git a/components/ILIAS/Language/tests/Activities/FakeInstalledLanguageRepository.php b/components/ILIAS/Language/tests/Activities/FakeInstalledLanguageRepository.php new file mode 100644 index 000000000000..8f1a2890a019 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/FakeInstalledLanguageRepository.php @@ -0,0 +1,87 @@ +installed_languages)(); + } + + public function getInstalledLocalLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getAvailableLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getLanguageEntries(string $lang_key): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getLocalLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getInstallableLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getInvalidLocalLanguageFiles(array $language_keys): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function checkLanguage(string $lang_key): bool + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function checkLocalLanguageFile(string $lang_key): bool + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } +} diff --git a/components/ILIAS/Language/tests/Activities/FakeLanguageObject.php b/components/ILIAS/Language/tests/Activities/FakeLanguageObject.php new file mode 100644 index 000000000000..893e51fbf418 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/FakeLanguageObject.php @@ -0,0 +1,83 @@ + $unwritten_overlay_modules + */ + public function __construct( + private readonly bool $is_system_language, + private readonly bool $is_user_language, + private readonly bool $is_installed, + private readonly string $uninstall_return_value = 'uninstalled', + private readonly array $unwritten_overlay_modules = [], + ) { + } + + public function isSystemLanguage(): bool + { + return $this->is_system_language; + } + + public function isUserLanguage(): bool + { + return $this->is_user_language; + } + + public function isInstalled(): bool + { + return $this->is_installed; + } + + public function uninstall(): string + { + $this->uninstall_calls++; + + return $this->uninstall_return_value; + } + + /** + * @return list modules whose overlay the last uninstall() could not remove + */ + public function getModulesWithUnwrittenOverlay(): array + { + return $this->unwritten_overlay_modules; + } + + public function uninstallCallCount(): int + { + return $this->uninstall_calls; + } +} diff --git a/components/ILIAS/Language/tests/Activities/FakeRemoveLocalLanguageChangesObject.php b/components/ILIAS/Language/tests/Activities/FakeRemoveLocalLanguageChangesObject.php new file mode 100644 index 000000000000..c56ff172640e --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/FakeRemoveLocalLanguageChangesObject.php @@ -0,0 +1,69 @@ + $modules_with_unwritten_overlay migrated modules whose PO/MO overlay could not be written + */ + public function __construct( + private readonly bool $is_installed, + private readonly bool $remove_local_changes_return_value = true, + private readonly array $modules_with_unwritten_overlay = [], + ) { + } + + public function isInstalled(): bool + { + return $this->is_installed; + } + + public function removeLocalChanges(): bool + { + $this->remove_local_changes_calls++; + + return $this->remove_local_changes_return_value; + } + + /** + * @return list + */ + public function getModulesWithUnwrittenOverlay(): array + { + return $this->modules_with_unwritten_overlay; + } + + public function removeLocalChangesCallCount(): int + { + return $this->remove_local_changes_calls; + } +} diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php index 0b4f3ce59d81..18f39a391ce0 100644 --- a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php @@ -41,7 +41,7 @@ public function testGrindThrowsInsteadOfReturningAResultErrorWhenDescriptionIsNo { $this->expectException(\LogicException::class); - $this->host()->callGrind($this->createMock(FormInput::class), []); + $this->host()->callGrind($this->createStub(FormInput::class), []); } public static function checkboxWhitelistAcceptsRawValueProvider(): array diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageModesTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageModesTest.php new file mode 100644 index 000000000000..c96a79aee764 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageModesTest.php @@ -0,0 +1,467 @@ +createSetupLanguageMock([], [], []); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ' de ', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['de'], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + /** + * The Activity passes exactly the language key (no $create_missing_mo flag). Captures the raw arguments, as + * ->with('de') would also accept an additional one. + */ + public function testFullInstallPassesOnlyTheLanguageKey(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $calls = []; + $setup_language->expects($this->once()) + ->method('insertLanguageForInstallation') + ->willReturnCallback(static function () use (&$calls): array { + $calls[] = func_get_args(); + return []; + }); + + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame([['de']], $calls); + } + + /** + * A non-empty list from insertLanguageForInstallation() (migrated modules whose overlay could not be written) + * lands in "overlay_write_failed_language_keys"; the language still counts as installed. + */ + public function testFullInstallListsOnlyTheLanguagesWhoseOverlayCouldNotBeWritten(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation')->willReturnMap([ + ['de', ['tos', 'pilot']], + ['fr', []], + ]); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de,fr', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['de', 'fr'], $result['installed_language_keys']); + $this->assertSame(['de'], $result['overlay_write_failed_language_keys']); + } + + public function testInstallLocalModeListsTheLanguageWhoseOverlayCouldNotBeWritten(): void + { + $setup_language = $this->createSetupLanguageMock( + [ + 'de' => ['obj_id' => 1, 'status' => 'installed'], + 'en' => ['obj_id' => 2, 'status' => 'installed'], + ], + ['de', 'en'], + ['de', 'en'] + ); + $setup_language->expects($this->exactly(2))->method('insertLanguageForApplyingLocalChanges')->willReturnMap([ + ['de', []], + ['en', ['pilot']], + ]); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en'], + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame(['de', 'en'], $result['installed_with_local_language_keys']); + $this->assertSame(['en'], $result['overlay_write_failed_language_keys']); + } + + public function testNothingIsListedWhenEveryOverlayWasWrittenOrNothingWasInstalled(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + [], + ['de'] + ); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('fr')->willReturn([]); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'fr'], + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['fr'], $result['installed_language_keys']); + $this->assertSame([], $result['overlay_write_failed_language_keys']); + } + + public function testMultipleLanguagesSeparatesNotInstalledFromAlreadyInstalledUnderInstallMode(): void + { + // mode "install": the already installed 'de' is left completely alone, only 'fr' is installed + $setup_language = $this->createSetupLanguageMock( + [ + 'de' => ['obj_id' => 1, 'status' => 'installed'], + ], + [], + ['de'] + ); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('fr') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('fr'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('fr'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => [' de, fr ', 'de'], + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['fr'], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame(['de'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + public function testNewLanguageWithCustomFileIsReportedAsInstalledWithLocalFile(): void + { + // classified by "has a customizing/local file", not "newly installed" + $setup_language = $this->createSetupLanguageMock( + [], + ['de'], + [] + ); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame(['de'], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + } + + /** + * Mode "install" must not re-install or re-apply the local file of an already installed language. + */ + public function testInstallModeIsNoOpForAlreadyInstalledLanguageEvenWithACustomFile(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['de'], + ['de'] + ); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame(['de'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + public function testInstallLocalModeAppliesCustomFileToAlreadyInstalledLanguage(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['de'], + ['de'] + ); + $setup_language->expects($this->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']); + } + + public function testInstallLocalModePassesOnlyTheLanguageKey(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['de'], + ['de'] + ); + $calls = []; + $setup_language->expects($this->once()) + ->method('insertLanguageForApplyingLocalChanges') + ->willReturnCallback(static function () use (&$calls): array { + $calls[] = func_get_args(); + return []; + }); + + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame([['de']], $calls); + } + + /** + * "install_local" on an already installed language without a local file runs its write methods, but the result + * must say "already installed", since nothing observable changed. + */ + public function testInstallLocalModeOnAlreadyInstalledLanguageWithoutCustomFileIsReportedAsAlreadyInstalled(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + [], // no customizing/local file exists for 'de' + ['de'] + ); + $setup_language->expects($this->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 at all is a complete no-op. + */ + public function testInstallLocalModeOnNotInstalledLanguageIsCompleteNoOp(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame(['de'], $result['not_installed_language_keys']); + } + + /** + * If every requested language is a no-op, perform() must not read the available/local languages or scan for + * invalid local files. + */ + public function testAllRequestedLanguagesBeingNoOpsSkipsLanguageAndFileLookupsEntirely(): void + { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->method('getInstalledLanguages')->willReturn(['de', 'en']); + $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation'); + $setup_language->expects($this->never())->method('getLocalLanguages'); + $setup_language->expects($this->never())->method('getInvalidLocalLanguageFiles'); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en'], + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame(['de', 'en'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + /** + * Languages in every state at once land in their own bucket; only affected ones trigger a write (mode "install"). + */ + public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBucket(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['fr'], // only 'fr' has a pending customizing/local file + ['de'] + ); + $setup_language->expects($this->exactly(2)) + ->method('checkLanguageForInstallation') + ->willReturnMap([ + ['en', true], + ['fr', true], + ]); + $setup_language->expects($this->exactly(2))->method('flushLanguageForInstallation'); + $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage'); + $setup_language->expects($this->once()) + ->method('getInvalidLocalLanguageFiles') + ->with(['en', 'fr']); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en', 'fr'], + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['en'], $result['installed_language_keys']); + $this->assertSame(['fr'], $result['installed_with_local_language_keys']); + $this->assertSame(['de'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + } + + /** + * Mode "install_local": only insertLanguageForApplyingLocalChanges, only for already installed keys. + */ + public function testBulkRequestUnderInstallLocalModePartitionsEachKeyIntoTheCorrectBucket(): void + { + $setup_language = $this->createSetupLanguageMock( + [ + 'de' => ['obj_id' => 1, 'status' => 'installed'], + 'en' => ['obj_id' => 2, 'status' => 'installed'], + ], + ['de'], // only 'de' has a pending customizing/local file + ['de', 'en'] + ); + $setup_language->expects($this->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') + ->with(['de', 'en']); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en', 'fr'], + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame(['de'], $result['installed_with_local_language_keys']); + $this->assertSame(['en'], $result['already_installed_language_keys']); + $this->assertSame(['fr'], $result['not_installed_language_keys']); + } + + public function testInstallingNewLanguageDoesNotUpdateAlreadyInstalledLanguages(): void + { + $setup_language = $this->createSetupLanguageMock( + [ + 'en' => ['obj_id' => 1, 'status' => 'installed'], + ], + [], + ['en'] + ); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation'); + $setup_language->expects($this->once()) + ->method('registerInstalledLanguage') + ->with( + 'de', + ['en' => ['obj_id' => 1, 'status' => 'installed']], + [] + ); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['de'], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } +} diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index ec92890e4729..245c4c56b622 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -9,27 +9,32 @@ * 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 + * *********************************************************************/ declare(strict_types=1); namespace ILIAS\Language\Tests\Activities; -use ILIAS\Language\Tests\Activities\ActivityWithPerformResultContractTestCase; use ILIAS\Language\Activities\InstallLanguage; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\UI\Component\Input\Field\Text; use ILIAS\UI\Component\Input\Field\Select; -use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use ilSetupLanguage; class InstallLanguageTest extends ActivityWithPerformResultContractTestCase { + use InstallLanguageTestFixture; + protected function createDefaultActivity(): InstallLanguage { - $setup_language = $this->createSetupLanguageMock(['de'], [], []); + $setup_language = $this->createSetupLanguageStub(['de'], [], []); $setup_language->method('checkLanguageForInstallation')->willReturn(true); return $this->createActivity($setup_language); @@ -40,323 +45,6 @@ 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']); @@ -420,7 +108,7 @@ public function testMissingModeParameterIsRejected(): void { $this->expectException(InvalidInputException::class); - $this->createActivity($this->createSetupLanguageMock([], [], []))->perform([ + $this->createActivity($this->createSetupLanguageStub([], [], []))->perform([ 'language_keys' => 'de', ]); } @@ -429,7 +117,7 @@ public function testInvalidModeValueIsRejected(): void { $this->expectException(InvalidInputException::class); - $this->createActivity($this->createSetupLanguageMock([], [], []))->perform([ + $this->createActivity($this->createSetupLanguageStub([], [], []))->perform([ 'language_keys' => 'de', 'mode' => 'foo', ]); @@ -472,7 +160,7 @@ public function testInputDescriptionUsesNamedLanguageKeysAndModeFields(): void ->willReturn($group); $activity = $this->createActivity( - $this->createSetupLanguageMock([], [], []) + $this->createSetupLanguageStub([], [], []) ); $this->assertSame($group, $activity->getInputDescription($field)); @@ -511,7 +199,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void { $this->expectException(InvalidInputException::class); - $this->createActivity($this->createSetupLanguageMock([], [], []))->perform($parameters); + $this->createActivity($this->createSetupLanguageStub([], [], []))->perform($parameters); } /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ @@ -519,13 +207,13 @@ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); - $this->createActivity($this->createSetupLanguageMock([], [], []))->perform('not-an-array'); + $this->createActivity($this->createSetupLanguageStub([], [], []))->perform('not-an-array'); } /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndInstallsEachOne(): void { - $rbac = $this->createMock(\ilRbacSystem::class); + $rbac = $this->createStub(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); $setup_language = $this->createSetupLanguageMock([], [], []); @@ -564,30 +252,4 @@ public function testPermissionDeniedBeforePerform(): void $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/InstallLanguageTestFixture.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTestFixture.php new file mode 100644 index 000000000000..1c328cc08155 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTestFixture.php @@ -0,0 +1,90 @@ +rbacOf($rbac ?? $this->createStub(\ilRbacSystem::class)), + $setup_language, + $this->languageFolder() + ); + } + + protected function createSetupLanguageMock( + array $available_languages, + array $local_language_keys, + array $installed_language_keys, + array $invalid_local_language_files = [] + ): MockObject&ilSetupLanguage { + $setup_language = $this->createMock(ilSetupLanguage::class); + $this->configureSetupLanguage( + $setup_language, + $available_languages, + $local_language_keys, + $installed_language_keys, + $invalid_local_language_files + ); + + return $setup_language; + } + + /** For call sites without ->expects(): a plain mock there triggers PHPUnit's "no expectations" notice. */ + protected function createSetupLanguageStub( + array $available_languages, + array $local_language_keys, + array $installed_language_keys, + array $invalid_local_language_files = [] + ): Stub&ilSetupLanguage { + $setup_language = $this->createStub(ilSetupLanguage::class); + $this->configureSetupLanguage( + $setup_language, + $available_languages, + $local_language_keys, + $installed_language_keys, + $invalid_local_language_files + ); + + return $setup_language; + } + + private function configureSetupLanguage( + ilSetupLanguage&Stub $setup_language, + array $available_languages, + array $local_language_keys, + array $installed_language_keys, + array $invalid_local_language_files + ): void { + $setup_language->method('getAvailableLanguagesForInstallation')->willReturn($available_languages); + $setup_language->method('getLocalLanguages')->willReturn($local_language_keys); + $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys); + $setup_language->method('getInvalidLocalLanguageFiles')->willReturn($invalid_local_language_files); + } +} diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php index 908818724509..daf7f7a4dd44 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php @@ -78,7 +78,12 @@ public function testForSetupActivityStillPerformsWithoutRbac(): void $result = UpdateLanguage::forSetup($setup_language)->perform(['language_keys' => ['fr']]); self::assertSame( - ['updated_language_keys' => [], 'not_installed_language_keys' => ['fr']], + [ + 'updated_language_keys' => [], + 'not_installed_language_keys' => ['fr'], + 'overlay_write_failed_language_keys' => [], + 'invalid_markup_customizing_entries' => [], + ], $result ); } diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 4b8e490ef382..6643f00a5ae9 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -24,6 +24,7 @@ use ILIAS\Data\Description; use ILIAS\Data\Text; use ILIAS\Language\Activities\LanguageActivity; +use ILIAS\Language\ComponentTranslation\TranslationMarkupPolicy; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; @@ -142,7 +143,7 @@ public function testMaybePerformAsAnswers403ForANonUserAndNeverPerforms(): void */ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedFormKey(): void { - $rbac = $this->createMock(\ilRbacSystem::class); + $rbac = $this->createStub(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); $activity = new LanguageActivityPerformEchoTestDouble( @@ -159,6 +160,32 @@ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedForm // a non-colliding key survives the merge $this->assertSame('form', $parameters['source']); } + + /** + * Skipped entries are listed with their markup, language-neutral and without duplicates; only the requested + * languages appear, in requested order. + */ + public function testInvalidMarkupCustomizingEntriesAppendTheMarkupConcernedToEachEntry(): void + { + $policy = new TranslationMarkupPolicy(); + $skipped = [ + 'de' => [ + 'tos#:#x' => $policy->findViolations(''), + 'tos#:#plain' => [], + ], + 'en' => ['tos#:#y' => $policy->findViolations('')], + 'fr' => ['tos#:#z' => $policy->findViolations('')], + ]; + $activity = new LanguageActivityInvalidMarkupTestDouble( + $this->rbacOf($this->createStub(\ilRbacSystem::class)), + $this->languageFolder() + ); + + $this->assertSame( + ['en: tos#:#y: ']); $manager = $this->manager(); @@ -359,7 +359,7 @@ public function testAnUnchangedMultiLineShippedValueStaysUnchangedAcrossUpdates( */ public function testACorruptOverlayIsIgnoredAndRebuiltOnUpdate(): void { - $this->expectErrorLog(); + $this->expectManagerWarning(); $this->shipPo(['greeting' => 'Hallo', 'farewell' => 'Tschüss']); $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]); file_put_contents($this->overlayBase() . '.po', "msgid \"kaputt\n"); @@ -373,6 +373,6 @@ public function testACorruptOverlayIsIgnoredAndRebuiltOnUpdate(): void $this->assertSame('Tschüss', LocalChangeComments::getOriginal($overlay->find(null, 'farewell'))); // "greeting" carries the shipped value and is not written $this->assertSame(['farewell' => 'Pfiat di'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase()))); - $this->assertStringContainsString('Could not read the overlay of migrated module "pilot"', $this->errorLog()); + $this->assertStringContainsString('Could not read the overlay of migrated module "pilot"', $this->loggedWarnings()); } } diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php index f4a69fc7c7ef..2ca27a7f85ff 100644 --- a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedSourcesAndLocksTest.php @@ -174,7 +174,7 @@ public function testLocksAreAcquiredInSortedOrderRegardlessOfTheContributedDirec */ public function testAModuleWithoutAnOverlayThatGetsOneCreatedDuringTheRunIsReportedAndTheOverlaySurvives(): void { - $this->expectErrorLog(); + $this->expectManagerWarning(); $this->shipPo(['greeting' => 'Hallo']); $concurrent_edit = MigratedPoFixture::catalog(self::MODULE, [ 'greeting' => ['value' => 'Von einem parallelen Admin-Edit', 'original' => 'Hallo', 'local_change' => '2020-01-01T00:00:00Z'], From 705cb97630599ff5c090842be31a1020295fee25 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 09:12:51 +0200 Subject: [PATCH 042/103] Language: Take the client data directory from `Directories` and the client id In the component graph, the client data directory of the overlays now comes from `ILIAS\Environment\Configuration\Installation\Directories` and `ClientIdProvider` instead of `CLIENT_DATA_DIR`. Setup keeps setting it from its own resources, and callers outside the graph keep the constant or `ilias.ini.php` as fallback. --- components/ILIAS/Language/Language.php | 22 ++++++- .../classes/Setup/class.ilSetupLanguage.php | 17 ++++-- .../src/Activities/RepositoryProvider.php | 6 +- .../MigratedLanguageFilePaths.php | 2 + .../Activities/RepositoryProviderTest.php | 20 ++++++- .../LanguageComponentDependenciesTest.php | 15 +++-- .../tests/LanguageComponentGraphTest.php | 59 ++++++++++++++++++- 7 files changed, 125 insertions(+), 16 deletions(-) diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 09a8c501194f..01ef18a20387 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -34,6 +34,8 @@ use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Environment\Configuration\Installation\Directories; +use ILIAS\Environment\Configuration\Installation\ClientIdProvider; +use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; use ILIAS\Logging\Logger\LoggerFactoryInterface; class Language implements Component\Component @@ -77,13 +79,30 @@ public function init( $internal[Language\Setup\TranslationCache::class] = static fn() => new Language\Setup\TranslationCacheLegacyProxy(); + // $use must be read inside a container closure: the bootstrap only wires what is read there + $client_data_dir_resolver_of = static function ( + Directories $directories, + ClientIdProvider $client_id_provider + ): \Closure { + return static function () use ($directories, $client_id_provider): ?string { + try { + $client_id = $client_id_provider->getClientId()->toString(); + } catch (\InvalidArgumentException) { + // Missing or invalid client id: no overlay instead of a failing request. + return null; + } + return MigratedLanguageFilePaths::fromDataDirAndClientId($directories->getDataDir(), $client_id); + }; + }; + $runtime_setup_language = static fn(): \ilSetupLanguage => new \ilSetupLanguage( "en", $internal[LanguageFileDirectoryManager::class], $pull[\ILIAS\Database\Connection::class], $internal[Language\Setup\TranslationCache::class], - $use[LoggerFactoryInterface::class]->getLazy('lang') + $use[LoggerFactoryInterface::class]->getLazy('lang'), + $client_data_dir_resolver_of($use[Directories::class], $use[ClientIdProvider::class]) ); $internal[Language\Activities\LanguageSettings::class] = static fn() => @@ -97,6 +116,7 @@ public function init( $internal[Language\Activities\LanguageSettings::class], $internal[LanguageFileDirectoryManager::class], new Language\Setup\DirectoriesInstallationRoot($use[Directories::class]), + $client_data_dir_resolver_of($use[Directories::class], $use[ClientIdProvider::class]), $use[LoggerFactoryInterface::class]->getLazy('lang'), ); diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index d40485429da6..36b98cef3144 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -69,8 +69,8 @@ class ilSetupLanguage extends ilLanguage * @param LanguageFileDirectoryManager|null $language_file_directory_manager directories contributed through the * component graph; without it only main lang/ and customizing are known (usesDefaultLanguageFileDirectories()) * @param ilDBInterface|null $connection none for callers outside the component graph - * @param LoggerInterface|null $logger for the warnings of the installation manager; the Setup - * instance is built without a logger and passes none (they go to the PHP error log then). + * @param LoggerInterface|null $logger for the installation manager's warnings; none in the Setup (PHP error log then) + * @param (\Closure():?string)|null $client_data_dir_resolver without it, MigratedLanguageFilePaths::resolveClientDataDir() is used */ public function __construct( string $a_lang_key, @@ -78,6 +78,7 @@ public function __construct( private readonly ?ilDBInterface $connection = null, private readonly TranslationCache $translation_cache = new NullTranslationCache(), private readonly ?LoggerInterface $logger = null, + private readonly ?\Closure $client_data_dir_resolver = null, ) { $this->lang_key = $a_lang_key ?: $this->lang_default; $this->absolute_path = (string) realpath(__DIR__ . "/../../../../../"); @@ -135,9 +136,7 @@ private function manager(): LanguageInstallationManager } /** - * The client data directory the overlay of migrated modules is maintained in. Setup Objectives - * set it from the Setup environment (ilias.ini `datadir` + client id); without it, it is - * resolved via MigratedLanguageFilePaths::resolveClientDataDir(). `null` resets to that default. + * `null` resets to the resolver of the constructor. */ public function setClientDataDir(?string $client_data_dir): void { @@ -146,7 +145,13 @@ public function setClientDataDir(?string $client_data_dir): void public function getClientDataDir(): ?string { - return $this->client_data_dir ?? MigratedLanguageFilePaths::resolveClientDataDir($this->absolute_path); + if ($this->client_data_dir !== null) { + return $this->client_data_dir; + } + if ($this->client_data_dir_resolver !== null) { + return ($this->client_data_dir_resolver)(); + } + return MigratedLanguageFilePaths::resolveClientDataDir($this->absolute_path); } /** diff --git a/components/ILIAS/Language/src/Activities/RepositoryProvider.php b/components/ILIAS/Language/src/Activities/RepositoryProvider.php index c82fdba45540..8b9230259164 100644 --- a/components/ILIAS/Language/src/Activities/RepositoryProvider.php +++ b/components/ILIAS/Language/src/Activities/RepositoryProvider.php @@ -21,7 +21,6 @@ namespace ILIAS\Language\Activities; use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; -use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; use ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync; use ILIAS\Language\ComponentTranslation\PluralFormKey; use ILIAS\Language\Setup\InstallationRoot; @@ -34,12 +33,14 @@ */ class RepositoryProvider { + /** @param \Closure(): ?string $client_data_dir_resolver */ public function __construct( private readonly InstalledLanguageRepository $installed_language_repository, private readonly \ilDBInterface $db, private readonly LanguageSettings $settings, private readonly LanguageFileDirectoryManager $language_file_directory_manager, private readonly InstallationRoot $installation_root, + private readonly \Closure $client_data_dir_resolver, private readonly LoggerInterface $logger, ) { } @@ -154,7 +155,8 @@ public function updateModuleCache( string $value, string $remarks = '' ): bool { - $client_data_dir = MigratedLanguageFilePaths::resolveClientDataDir($this->installation_root->getPath()); + // must equal resolveClientDataDir() in replaceLangModule(): re-entrance is detected by lock path only + $client_data_dir = ($this->client_data_dir_resolver)(); // read and written under the overlay lock, so a concurrent write is not lost return MigratedLanguageFileSync::withOverlayLock( diff --git a/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFilePaths.php b/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFilePaths.php index 522f448ceb7e..14e7f260b5e9 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFilePaths.php +++ b/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFilePaths.php @@ -61,6 +61,8 @@ final class MigratedLanguageFilePaths * * A client directory that does not exist yet (fresh installation) yields `null`: the next `setup update` * creates the overlay then, instead of pre-creating a directory the installer may want to create itself. + * + * Only a fallback; the component graph and the Setup resolve it themselves. */ public static function resolveClientDataDir(?string $ilias_absolute_path = null): ?string { diff --git a/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php index fb8e0d25026f..920fb7897d89 100644 --- a/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php +++ b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php @@ -115,6 +115,22 @@ public function testUpdateModuleCacheLeavesTheCacheOfAModuleNotMaintainedInPoFil $this->assertTrue($this->provider(db: $db)->updateModuleCache('de', 'common', 'new_topic', 'Hallo', 'author')); } + /** The client data directory of the overlays is asked from the injected resolver, once per update. */ + public function testUpdateModuleCacheAsksTheInjectedResolverForTheClientDataDir(): void + { + $asked = 0; + $provider = $this->provider( + db: $this->dbFetching(null, []), + client_data_dir_resolver: static function () use (&$asked): ?string { + $asked++; + return null; + } + ); + + $this->assertTrue($provider->updateModuleCache('de', 'common', 'new_topic', 'Hallo')); + $this->assertSame(1, $asked); + } + /** * For a module that is maintained in PO files (a shipped `.po` exists) with neither a readable overlay nor a * usable lng_modules row the module content is rebuilt from lng_data, which already holds the new entry. @@ -238,7 +254,8 @@ private function provider( ?\ilDBInterface $db = null, ?LanguageFileDirectoryManager $manager = null, ?LanguageSettings $settings = null, - ?LoggerInterface $logger = null + ?LoggerInterface $logger = null, + ?\Closure $client_data_dir_resolver = null ): RepositoryProvider { return new RepositoryProvider( $installed ?? $this->createStub(InstalledLanguageRepository::class), @@ -246,6 +263,7 @@ private function provider( $settings ?? $this->createStub(LanguageSettings::class), $manager ?? new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory()), new FixedInstallationRoot($this->iliasRoot()), + $client_data_dir_resolver ?? static fn(): ?string => null, $logger ?? $this->createStub(LoggerInterface::class) ); } diff --git a/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php index 09e40e3c49ad..cc258f31e30e 100644 --- a/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php @@ -23,12 +23,10 @@ use ILIAS\Component\Dependencies\Mocks\EvalLightMockBuilder; use ILIAS\Component\Dependencies\Reader; use ILIAS\Database\Connection; +use ILIAS\Environment\Configuration\Installation\ClientIdProvider; +use ILIAS\Environment\Configuration\Installation\Directories; use PHPUnit\Framework\TestCase; -/** - * The declared dependencies of the Language component: the database - * comes from the graph (pull), RBAC is used (use). - */ class LanguageComponentDependenciesTest extends TestCase { /** @return list */ @@ -53,4 +51,13 @@ public function testRbacIsUsed(): void { self::assertContains(RBAC::class, $this->names(InType::USE)); } + + /** Read while the container closures are evaluated, else the bootstrap does not wire them for Language. */ + public function testTheDirectoriesAndTheClientIdProviderAreUsed(): void + { + $used = $this->names(InType::USE); + + self::assertContains(Directories::class, $used); + self::assertContains(ClientIdProvider::class, $used); + } } diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index 1d219830f753..e0fce4b0c06e 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -67,6 +67,7 @@ private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\ $use[\ILIAS\Environment\Configuration\Installation\Directories::class] ??= $this->createDirectories( (string) realpath(__DIR__ . '/../../../../') ); + $use[\ILIAS\Environment\Configuration\Installation\ClientIdProvider::class] ??= $this->createClientIdProvider('default'); $use[\ILIAS\Logging\Logger\LoggerFactoryInterface::class] ??= $this->createStub( \ILIAS\Logging\Logger\LoggerFactoryInterface::class ); @@ -241,14 +242,68 @@ public function testImplementCandidatesAreOfferedInDeclarationOrder(): void ); } - private function createDirectories(string $root): \ILIAS\Environment\Configuration\Installation\Directories - { + private function createDirectories( + string $root, + string $data_dir = '/nonexistent-ilias-data' + ): \ILIAS\Environment\Configuration\Installation\Directories { $directories = $this->createStub(\ILIAS\Environment\Configuration\Installation\Directories::class); $directories->method('getRoot')->willReturn($root); + $directories->method('getDataDir')->willReturn($data_dir); return $directories; } + private function createClientIdProvider(string $client_id): \ILIAS\Environment\Configuration\Installation\ClientIdProvider + { + $provider = $this->createStub(\ILIAS\Environment\Configuration\Installation\ClientIdProvider::class); + $provider->method('getClientId')->willReturn(new \ILIAS\Data\ClientId($client_id)); + + return $provider; + } + + public function testTheRuntimeClientDataDirComesFromDirectoriesAndTheClientId(): void + { + $data_dir = sys_get_temp_dir() . '/ilias_lang_graph_' . bin2hex(random_bytes(4)); + mkdir($data_dir . '/myclient', 0775, true); + try { + [, , , , , $provide, , $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( + (string) realpath(__DIR__ . '/../../../../'), + $data_dir + ), + \ILIAS\Environment\Configuration\Installation\ClientIdProvider::class => $this->createClientIdProvider('myclient'), + ]); + $setup_language = self::readPrivateProperty($provide[InstallLanguage::class], 'setup_language'); + $provider_resolver = self::readPrivateProperty($internal[RepositoryProvider::class], 'client_data_dir_resolver'); + + self::assertSame($data_dir . '/myclient', $setup_language->getClientDataDir()); + self::assertSame($data_dir . '/myclient', $provider_resolver()); + } finally { + rmdir($data_dir . '/myclient'); + rmdir($data_dir); + } + } + + public function testAnInvalidClientIdYieldsNoClientDataDir(): void + { + $client_id_provider = $this->createStub(\ILIAS\Environment\Configuration\Installation\ClientIdProvider::class); + $client_id_provider->method('getClientId')->willReturnCallback( + static fn(): \ILIAS\Data\ClientId => new \ILIAS\Data\ClientId('') + ); + [, , , , , $provide, , $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\ClientIdProvider::class => $client_id_provider, + ]); + $setup_language = self::readPrivateProperty($provide[InstallLanguage::class], 'setup_language'); + $provider_resolver = self::readPrivateProperty($internal[RepositoryProvider::class], 'client_data_dir_resolver'); + + self::assertNull($setup_language->getClientDataDir()); + self::assertNull($provider_resolver()); + } + public function testRepositoryGetsTheRootFromDirectories(): void { [, , , , , , , $internal] = $this->initComponent([ From f57ff67c396d4da36a966462a5745ea73f33438f Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 09:15:05 +0200 Subject: [PATCH 043/103] Poll, TermsOfService: Drop the return type of the language file directory contribution The trunk dropped the return types of the arrow functions in the bootstrap wiring (9d2773496a2). The contributions of the migrated modules and the hint of the conversion tool follow that. --- components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php | 2 +- .../ILIAS/Language/tools/po-migration/convert_module_to_po.php | 2 +- components/ILIAS/Poll/Poll.php | 2 +- components/ILIAS/TermsOfService/TermsOfService.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php index 625b4e72c94d..40ecad3d8703 100644 --- a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -459,7 +459,7 @@ public function testAnInvalidPatternFailsWithExitOneAndWritesNothing(): void /** The contribution --remove-from-lang looks for in a component class. */ private static function contribution(string $module, string $further_arguments = ''): string { - return '$contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => ' + return '$contribute[LanguageFileDirectory::class] = fn() => ' . "new ComponentLanguageFileDirectory(\$this, '$module'$further_arguments);"; } diff --git a/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php index 58c7eac53dd4..bef4fbe84c5e 100644 --- a/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php +++ b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php @@ -492,7 +492,7 @@ } return "$component_file contributes no ComponentLanguageFileDirectory for module '$module' (expected: " - . "\$contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory(\$this, '$module'); " + . "\$contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory(\$this, '$module'); " . "register the contribution first)."; }; diff --git a/components/ILIAS/Poll/Poll.php b/components/ILIAS/Poll/Poll.php index 52af9272985e..ef9d33784296 100644 --- a/components/ILIAS/Poll/Poll.php +++ b/components/ILIAS/Poll/Poll.php @@ -40,7 +40,7 @@ public function init( $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\ComponentJS($this, "ilPoll.js"); $contribute[SetupAgent::class] = fn() => new \ilPollSetupAgent($pull[RefineryFactory::class]); - $contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory( + $contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory( $this, 'poll' ); diff --git a/components/ILIAS/TermsOfService/TermsOfService.php b/components/ILIAS/TermsOfService/TermsOfService.php index d7ae45a6a980..b0c5210dff0c 100644 --- a/components/ILIAS/TermsOfService/TermsOfService.php +++ b/components/ILIAS/TermsOfService/TermsOfService.php @@ -35,7 +35,7 @@ public function init( array | \ArrayAccess &$pull, array | \ArrayAccess &$internal, ): void { - $contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory( + $contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory( $this, 'tos' ); From cccad98664a0b48238b62e7584a08986d4937d3f Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 09:15:05 +0200 Subject: [PATCH 044/103] Language: Check for shipped PO modules in one place `ilSetupLanguage` and `LanguageInstallationManager` both looped over the shipped module files to find out whether migrated modules exist. `ilSetupLanguage` now delegates to `MigratedLanguageFileSync::hasShippedModules()`, which needs no database; the unused `LanguageInstallationManager::hasMigratedModules()` is removed. --- .../classes/Setup/class.ilSetupLanguage.php | 22 ++++++------------- .../MigratedLanguageFileSync.php | 19 ++++++++++++++++ .../src/Setup/LanguageInstallationManager.php | 21 ------------------ .../MigratedLanguageFileSyncReadersTest.php | 9 ++++++++ 4 files changed, 35 insertions(+), 36 deletions(-) diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index 36b98cef3144..f2d529b75e92 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -295,9 +295,7 @@ public function insertLanguageForApplyingLocalChanges(string $lang_key): array } /** - * See LanguageInstallationManager::findUnwritableOverlayDirectories() - checked by Setup before - * installing/updating languages. File-based like the manager's, but without needing a database - * (the manager is only built with one, see manager()). + * File-based, needs no database (unlike manager()). * * @param list $lang_keys * @param int|null $for_user_id evaluate the writability for this user instead of the current process @@ -315,23 +313,17 @@ public function findUnwritableOverlayDirectories(array $lang_keys, ?int $for_use } /** - * See LanguageInstallationManager::hasMigratedModules() - file-based, needs no database. + * File-based, needs no database. * * @param list $lang_keys */ public function hasMigratedModules(array $lang_keys): bool { - foreach ($lang_keys as $lang_key) { - if (MigratedLanguageFileSync::findShippedModuleFiles( - $this->language_file_directory_manager, - $this->absolute_path, - $lang_key - ) !== []) { - return true; - } - } - - return false; + return MigratedLanguageFileSync::hasShippedModules( + $this->language_file_directory_manager, + $this->absolute_path, + $lang_keys + ); } /** diff --git a/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php b/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php index 650ac1cba1e6..2d6ba57d17cd 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php +++ b/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php @@ -1386,6 +1386,25 @@ public static function loadShippedModules( return $modules; } + /** + * Whether any module is migrated for any of $lang_keys, see findShippedModuleFiles(). + * + * @param list $lang_keys + */ + public static function hasShippedModules( + LanguageFileDirectoryManager $language_file_directory_manager, + string $ilias_absolute_path, + array $lang_keys + ): bool { + foreach ($lang_keys as $lang_key) { + if (self::findShippedModuleFiles($language_file_directory_manager, $ilias_absolute_path, $lang_key) !== []) { + return true; + } + } + + return false; + } + /** * Every module migrated for $lang_key (its shipped `.po` exists, parseable or not) with the path of that `.po`. * diff --git a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php index f81c2b75bf98..76c0ff7c26e2 100644 --- a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php +++ b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php @@ -385,27 +385,6 @@ public function findUnwritableOverlayDirectories(array $lang_keys, ?int $for_use ); } - /** - * Whether any module is migrated to PO/MO for any of $lang_keys, i.e. an overlay may have to be - * maintained for it (for its local changes). - * - * @param list $lang_keys - */ - public function hasMigratedModules(array $lang_keys): bool - { - foreach ($lang_keys as $lang_key) { - if (MigratedLanguageFileSync::findShippedModuleFiles( - $this->language_file_directory_manager, - $this->absolute_path, - $lang_key - ) !== []) { - return true; - } - } - - return false; - } - /** * Writes the data of the given $directories/$lang_array seed; the callers above decide both. * diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php index a9ab6fb051db..1ae6b3ab2ffe 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php @@ -252,6 +252,15 @@ public function testFindShippedModuleFilesListsEveryModuleWithAShippedPoForTheLa ); } + public function testHasShippedModulesOnlyForALanguageWithAShippedPo(): void + { + $this->seedShipped(['greeting' => 'Hallo']); + + $this->assertFalse(MigratedLanguageFileSync::hasShippedModules($this->manager, $this->ilias_root, [])); + $this->assertFalse(MigratedLanguageFileSync::hasShippedModules($this->manager, $this->ilias_root, ['en'])); + $this->assertTrue(MigratedLanguageFileSync::hasShippedModules($this->manager, $this->ilias_root, ['en', 'de'])); + } + /** * The extracted comments ("#.", the counterpart of a .lang "###" comment) of an entry are joined * into one line; an entry without one gets `null`, entries of another context are not returned. From 77f5f6efe611691ec664862989d795c323d19830 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 09:18:57 +0200 Subject: [PATCH 045/103] Language: Merge into the shipped PO files regardless of the number of clients The merge of local changes only wrote the shipped PO files on an installation with exactly one client. ILIAS no longer supports several clients (`ClientIdReadObjective`), so the check, its message and the language variables `lng_merge_po_not_single_client` and `lng_merge_po_single_client_info` are removed. --- components/ILIAS/Language/README.md | 17 ++--- .../classes/class.ilObjLanguageExt.php | 76 ++----------------- .../classes/class.ilObjLanguageExtGUI.php | 16 +--- .../ShippedValuesOfMigratedModulesTest.php | 70 +---------------- .../ilObjLanguageExtGUIMaintenanceTest.php | 22 ------ docs/development/language.md | 2 +- lang/ilias_de.lang | 2 - lang/ilias_en.lang | 2 - 8 files changed, 18 insertions(+), 189 deletions(-) diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 9992d7986c33..31471251df76 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -286,16 +286,13 @@ Where the rules apply: * "merge" (translation mode only, checked on the server) writes the global `.lang` file for modules that are not migrated, and the local changes of migrated modules into their shipped `.po` (new keys also into the `.pot`; - remarks become an additional `#.` line). This is done only if the - installation has exactly one client, since the shipped files are shared by - all clients; otherwise only the `.lang` file is written and the modules are - reported. The previous files are copied to `lang/customizing/` (ignored by - git), the written files belong to the web server user. Values with markup - that is not allowed are not taken over. The overlay keeps serving the merged - values until `php cli/setup.php build` and then `php cli/setup.php update` - ran. Afterwards remove the lines of the merged keys from - `lang/customizing/ilias_.lang.local`; otherwise the next update takes - them as local changes again (and a remark appears twice). + remarks become an additional `#.` line). The previous files are copied to + `lang/customizing/` (ignored by git), the written files belong to the web + server user. Values with markup that is not allowed are not taken over. The + overlay keeps serving the merged values until `php cli/setup.php build` and + then `php cli/setup.php update` ran. Afterwards remove the lines of the + merged keys from `lang/customizing/ilias_.lang.local`; otherwise the + next update takes them as local changes again (and a remark appears twice). ### Setup `setup update` (and "update" in the administration) reconciles each entry of a diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php index 959c694d3b76..8b1bcf754bdd 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php @@ -395,17 +395,11 @@ public function getUnreadableShippedPoModules(): array * skipped: list, * invalid_markup: array>, * not_merged: list, - * unwritten_database: list, - * not_single_client: list - * } for the modules maintained in PO files: written - the shipped .po files written (relative to - * the ILIAS directory); skipped - the modules left out as a whole (e.g. not writable, logged - * with the reason); invalid_markup - module.separator.identifier => violations of values not - * taken over because of markup that is not allowed; not_merged - module.separator.identifier of - * entries that could not be taken over (see ShippedPoMerger, logged); unwritten_database - modules whose lng_data - * rows could not be updated (logged; their files are written); not_single_client - the modules - * maintained in PO files with local changes (an overlay) left out because the installation does - * not have exactly one client: their shipped .po are shared by every client (see - * isSingleClientInstallation()), the global language file is written regardless + * unwritten_database: list + * } for the modules maintained in PO files: written - shipped .po files written (relative to the ILIAS directory); + * skipped - modules left out as a whole (logged); invalid_markup - module.separator.identifier => violations of + * values not taken over; not_merged - module.separator.identifier of entries not taken over (logged); + * unwritten_database - modules whose lng_data rows could not be updated (logged; their files are written) */ public function mergeLocalChangesIntoGlobalLanguageFile(): array { @@ -427,68 +421,10 @@ public function mergeLocalChangesIntoGlobalLanguageFile(): array ))); $global_file_obj->write(); - if (!self::isSingleClientInstallation()) { - return [ - 'written' => [], - 'skipped' => [], - 'invalid_markup' => [], - 'not_merged' => [], - 'unwritten_database' => [], - 'not_single_client' => $this->modulesWithOverlay($po_modules), - ]; - } $result = $this->mergeLocalChangesIntoShippedPoFiles(); $this->shipped_migrated_modules = null; - return $result + ['not_single_client' => []]; - } - - /** - * The modules of $modules (maintained in PO files) with local changes - an overlay, the modules - * ShippedPoMerger::merge() takes over. - * - * @param list $modules - * @return list - */ - private function modulesWithOverlay(array $modules): array - { - global $DIC; - - if (!$DIC->offsetExists(LanguageFileDirectoryManager::class)) { - return []; - } - $client_data_dir = MigratedLanguageFilePaths::resolveClientDataDir(ILIAS_ABSOLUTE_PATH); - if ($client_data_dir === null) { - return []; - } - - return array_values(array_filter( - $modules, - fn(string $module): bool => MigratedLanguageFileSync::hasOverlay( - $DIC[LanguageFileDirectoryManager::class], - $this->key, - $module, - $client_data_dir - ) - )); - } - - /** - * Whether this installation has exactly one client - a client directory with a client.ini.php - * next to the one of the current client, found like ilSoapAdministration::getInstallationInfoXML() - * does. The shipped files (the global language file, the shipped .po of the modules maintained in - * PO files) are shared by every client, but a merge only takes over the local changes of the - * current client and updates only its database: with more than one client the others no longer - * match their shipped files. `false` if the client directory is not known (e.g. no web request). - */ - public static function isSingleClientInstallation(): bool - { - if (!defined('CLIENT_WEB_DIR')) { - return false; - } - $client_ini_files = glob(dirname(rtrim((string) CLIENT_WEB_DIR, '/')) . '/*/client.ini.php'); - - return is_array($client_ini_files) && count($client_ini_files) === 1; + return $result; } /** diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php index 691dcfcc7383..c29085ff74b7 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php @@ -860,12 +860,7 @@ public function maintainObject(): void $ro->setInfo(sprintf($this->lng->txt("language_remove_local_file_info"), $this->object->key)); $rg->addOption($ro); $ro = new ilRadioOption($this->lng->txt("language_merge_local_changes"), "merge"); - // with the warning that the shipped .po of the modules maintained in PO files are shared - // by every client - they are only merged on an installation with a single client - $ro->setInfo( - sprintf($this->lng->txt("language_merge_local_changes_info"), $this->object->key) - . ' ' . $this->lng->txt("lng_merge_po_single_client_info") - ); + $ro->setInfo(sprintf($this->lng->txt("language_merge_local_changes_info"), $this->object->key)); $rg->addOption($ro); } $ro = new ilRadioOption($this->lng->txt("language_save_dist"), "save_dist"); @@ -1352,7 +1347,7 @@ private function setSuccessOrOverlayWarning(string $success_message, array $modu * Success, plus everything that failed or was left out in one failure message (setOnScreenMessage() keeps one * per type); details are in the log. * - * @param array{written: list, skipped: list, invalid_markup: array>, not_merged: list, unwritten_database: list, not_single_client?: list} $result + * @param array{written: list, skipped: list, invalid_markup: array>, not_merged: list, unwritten_database: list} $result */ private function mergeMessages(array $result): void { @@ -1367,13 +1362,6 @@ private function mergeMessages(array $result): void $this->tpl->setOnScreenMessage('success', $success, true); $failures = []; - if (($result['not_single_client'] ?? []) !== []) { - // left out on purpose: the installation does not have exactly one client - $failures[] = sprintf( - $this->lng->txt("lng_merge_po_not_single_client"), - $escape(implode(', ', $result['not_single_client'])) - ); - } if ($result['skipped'] !== []) { // not written at all (e.g. no write permission): an error, not an intended skip $failures[] = sprintf( diff --git a/components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php b/components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php index d9309b65891f..2e2ccf2cd9a5 100644 --- a/components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php +++ b/components/ILIAS/Language/tests/ShippedValuesOfMigratedModulesTest.php @@ -132,20 +132,6 @@ private function seedOverlay(array $entries): void ); } - /** - * Makes CLIENT_WEB_DIR name one of $clients client directories (each with a client.ini.php) below - * the fixture directory - the constant can be defined once per process (every test has its own). - */ - private function defineClientDirectories(int $clients): void - { - $web = $this->fixture_directory . '/web'; - for ($i = 1; $i <= $clients; $i++) { - mkdir($web . '/client_' . $i, 0775, true); - file_put_contents($web . '/client_' . $i . '/client.ini.php', ''); - } - define('CLIENT_WEB_DIR', $web . '/client_1'); - } - private function registerDirectoryManager(): void { $this->setGlobalVariable( @@ -480,7 +466,7 @@ public function testMergeSkipsModulesMaintainedInPoFilesAndKeepsTheirLinesByteFo // "itest" has no overlay (its lng_data rows are only the dual-write fallback): nothing to merge, so not reported as skipped $this->assertSame( - ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => [], 'not_single_client' => []], + ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => []], $result ); $this->assertSame( @@ -504,7 +490,7 @@ public function testMergeWithoutModulesMaintainedInPoFilesMergesEverything(): vo ]; $this->assertSame( - ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => [], 'not_single_client' => []], + ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => []], $this->languageObject()->mergeLocalChangesIntoGlobalLanguageFile() ); $this->assertSame( @@ -533,7 +519,6 @@ public function testMergeUpdatesLngDataOfTheTakenOverValueAndRemarkToNull(): voi $logger_factory->method('getComponentLogger')->willReturn($logger); $this->setGlobalVariable('ilLoggerFactory', $logger_factory); - $this->defineClientDirectories(1); $object = $this->languageObject(); $object->cust_lang_path = $this->fixture_directory; $result = $object->mergeLocalChangesIntoGlobalLanguageFile(); @@ -547,55 +532,4 @@ public function testMergeUpdatesLngDataOfTheTakenOverValueAndRemarkToNull(): voi $this->manipulate_calls ); } - - /** - * With more than one client the shared shipped .po stays as it is (their lng_data would no longer - * match it); the global language file is written, and the modules with local changes are reported. - */ - public function testMergeInAMultiClientInstallationLeavesTheSharedShippedPoAloneAndReportsTheModules(): void - { - $this->defineClientDirectories(2); - $this->seedOverlay(['greeting' => 'Lokal geändert']); - $po_before = (string) file_get_contents($this->shippedPo()); - $this->value_rows = [['module' => 'common', 'identifier' => 'yes', 'value' => 'Jawohl']]; - $this->setGlobalVariable('ilLoggerFactory', $this->createStub(ilLoggerFactory::class)); - - $result = $this->languageObject()->mergeLocalChangesIntoGlobalLanguageFile(); - - $this->assertSame( - ['written' => [], 'skipped' => [], 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => [], 'not_single_client' => ['itest']], - $result - ); - $this->assertSame($po_before, file_get_contents($this->shippedPo())); - $this->assertSame([], $this->manipulate_calls, 'lng_data of the PO module is not touched'); - $this->assertContains('common#:#yes#:#Jawohl###Zustimmung', $this->entryLinesOfTheGlobalFile()); - } - - public function testMergeInAMultiClientInstallationReportsNoModuleWithoutLocalChanges(): void - { - $this->defineClientDirectories(2); - $this->setGlobalVariable('ilLoggerFactory', $this->createStub(ilLoggerFactory::class)); - - $result = $this->languageObject()->mergeLocalChangesIntoGlobalLanguageFile(); - - $this->assertSame([], $result['not_single_client']); - } - - public function testASingleClientInstallationHasExactlyOneClientDirectoryWithAClientIni(): void - { - $this->assertFalse(defined('CLIENT_WEB_DIR'), 'precondition'); - $this->assertFalse(ilObjLanguageExt::isSingleClientInstallation(), 'client directory unknown'); - - $web = $this->fixture_directory . '/web'; - mkdir($web . '/client_a', 0775, true); - define('CLIENT_WEB_DIR', $web . '/client_a'); - $this->assertFalse(ilObjLanguageExt::isSingleClientInstallation(), 'no client.ini.php at all'); - - file_put_contents($web . '/client_a/client.ini.php', ''); - $this->assertTrue(ilObjLanguageExt::isSingleClientInstallation()); - - mkdir($web . '/client_b'); - file_put_contents($web . '/client_b/client.ini.php', ''); - $this->assertFalse(ilObjLanguageExt::isSingleClientInstallation(), 'a second client'); - } } diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php index 1d13279c1b89..b83504e5195e 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUIMaintenanceTest.php @@ -224,7 +224,6 @@ public function testMergeWithoutModulesMaintainedInPoFilesShowsOnlyTheSuccess(): 'invalid_markup' => [], 'not_merged' => [], 'unwritten_database' => [], - 'not_single_client' => [], ]); $messages = []; @@ -233,27 +232,6 @@ public function testMergeWithoutModulesMaintainedInPoFilesShowsOnlyTheSuccess(): $this->assertSame([['success', 'language_merged_global']], $messages); } - public function testMergeNamesTheModulesLeftOutBecauseTheInstallationHasSeveralClients(): void - { - $object = $this->maintenanceLanguageObject(); - $object->expects($this->once())->method('mergeLocalChangesIntoGlobalLanguageFile')->willReturn([ - 'written' => [], - 'skipped' => [], - 'invalid_markup' => [], - 'not_merged' => [], - 'unwritten_database' => [], - 'not_single_client' => ['pilot'], - ]); - $messages = []; - - $this->runMaintenance('merge', $object, $messages); - - $this->assertSame( - [['success', 'language_merged_global'], ['failure', 'lng_merge_po_not_single_client: pilot']], - $messages - ); - } - /** A failed database write (unwritten_database) is reported like any partial failure; the written files stay. */ public function testMergeReportsModulesWhoseDatabaseUpdateFailed(): void { diff --git a/docs/development/language.md b/docs/development/language.md index 582b6c677e12..ec3e675f2a7e 100755 --- a/docs/development/language.md +++ b/docs/development/language.md @@ -74,7 +74,7 @@ The initial files are generated with `components/ILIAS/Language/tools/po-migrati afterwards they are maintained directly, and `php cli/setup.php build` MUST be run after every change of a shipped `.po`. The developer mode action that merges local changes into the global language file writes the local changes of these modules -into their shipped `.po` files (only on an installation with exactly one client). Each installation +into their shipped `.po` files. Each installation keeps its local changes of these modules in the client data directory, which is why the ILIAS setup MUST be run as the web server user. Plural forms are available for these modules through `ilLanguage::ntxt()`. See diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index 744effc29c02..38c75d0ff1a7 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -11151,9 +11151,7 @@ lng#:#lng_enable_language_detection#:#Automatische Spracherkennung aktivieren lng#:#lng_error_clear_shipped_po_unreadable#:#Die lokalen Änderungen wurden nicht gelöscht: Die ausgelieferten PO-Dateien folgender Module können nicht gelesen werden: %s. Details stehen im Log. lng#:#lng_markup_not_allowed#:#Nicht erlaubtes Markup in folgenden Einträgen: %s lng#:#lng_merge_database_not_updated#:#Die ausgelieferten PO-Dateien folgender Module wurden geschrieben, ihre Einträge in der Datenbank konnten aber nicht aktualisiert werden (siehe Log): %s -lng#:#lng_merge_po_not_single_client#:#Diese Installation hat mehr als einen Mandanten: Die lokalen Änderungen folgender über PO-Dateien gepflegter Module wurden nicht in ihre ausgelieferten PO-Dateien übernommen, die alle Mandanten gemeinsam nutzen: %s lng#:#lng_merge_po_not_written#:#Die lokalen Änderungen folgender Module konnten nicht in ihre ausgelieferten PO-Dateien übernommen werden (siehe Log): %s -lng#:#lng_merge_po_single_client_info#:#Module, die über PO-Dateien gepflegt werden, werden in ihre ausgelieferten PO-Dateien im Code-Verzeichnis übernommen, die alle Mandanten dieser Installation gemeinsam nutzen. Das geschieht nur, wenn die Installation genau einen Mandanten hat. lng#:#lng_merged_po_modules#:#Die lokalen Änderungen folgender Module wurden in ihre ausgelieferten PO-Dateien übernommen: %s lng#:#lng_not_translated#:#Nicht übersetzt lng#:#lng_po_overlay_not_written#:#Die Änderungen wurden in der Datenbank gespeichert, aber die PO/MO-Dateien folgender über PO-Dateien gepflegter Module konnten nicht geschrieben werden: %s. Für diese Module werden weiterhin die bisherigen Texte angezeigt. Bitte prüfen Sie, ob der Webserver-Nutzer Schreibrechte auf das Verzeichnis "lang" im Client-Datenverzeichnis hat. Details stehen im Log. diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 987da9e66d8c..5716a76fd14f 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -11124,9 +11124,7 @@ lng#:#lng_enable_language_detection#:#Enable Language Detection lng#:#lng_error_clear_shipped_po_unreadable#:#The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details. lng#:#lng_markup_not_allowed#:#Markup that is not allowed in the following entries: %s lng#:#lng_merge_database_not_updated#:#The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s -lng#:#lng_merge_po_not_single_client#:#This installation has more than one client: the local changes of the following modules maintained in PO files were not merged into their shipped PO files, which all clients share: %s lng#:#lng_merge_po_not_written#:#The local changes of the following modules could not be merged into their shipped PO files (see the log): %s -lng#:#lng_merge_po_single_client_info#:#Modules maintained in PO files are merged into their shipped PO files in the code directory, which all clients of this installation share. This is only done if the installation has exactly one client. lng#:#lng_merged_po_modules#:#The local changes of the following modules were merged into their shipped PO files: %s lng#:#lng_not_translated#:#Not translated lng#:#lng_po_overlay_not_written#:#The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory "lang" in the client data directory. See the log for details. From 5dc0c62ce246dfd3e7338858a3dffeb5507a9f3d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 13:44:59 +0200 Subject: [PATCH 046/103] TermsOfService: Remove the tos module from the global language files Its PO files are the only source now (convert_module_to_po.php --remove-from-lang). The test that re-created them from the global language files is dropped with its source. --- .../tests/ConvertModuleToPoToolTest.php | 21 ------------------- lang/ilias_ar.lang | 17 --------------- lang/ilias_bg.lang | 17 --------------- lang/ilias_cs.lang | 17 --------------- lang/ilias_da.lang | 17 --------------- lang/ilias_de.lang | 17 --------------- lang/ilias_el.lang | 17 --------------- lang/ilias_en.lang | 17 --------------- lang/ilias_es.lang | 17 --------------- lang/ilias_et.lang | 17 --------------- lang/ilias_fa.lang | 17 --------------- lang/ilias_fr.lang | 17 --------------- lang/ilias_hr.lang | 17 --------------- lang/ilias_hu.lang | 17 --------------- lang/ilias_it.lang | 17 --------------- lang/ilias_ja.lang | 17 --------------- lang/ilias_ka.lang | 17 --------------- lang/ilias_lt.lang | 17 --------------- lang/ilias_nl.lang | 17 --------------- lang/ilias_pl.lang | 17 --------------- lang/ilias_pt.lang | 17 --------------- lang/ilias_ro.lang | 17 --------------- lang/ilias_ru.lang | 17 --------------- lang/ilias_sk.lang | 17 --------------- lang/ilias_sl.lang | 17 --------------- lang/ilias_sq.lang | 17 --------------- lang/ilias_sr.lang | 17 --------------- lang/ilias_sv.lang | 17 --------------- lang/ilias_tr.lang | 17 --------------- lang/ilias_uk.lang | 17 --------------- lang/ilias_vi.lang | 17 --------------- lang/ilias_zh.lang | 17 --------------- 32 files changed, 548 deletions(-) diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php index 40ecad3d8703..d6a987310c6d 100644 --- a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -28,7 +28,6 @@ class ConvertModuleToPoToolTest extends TestCase { private const string TOOL = __DIR__ . '/../tools/po-migration/convert_module_to_po.php'; - private const string SHIPPED_DIRECTORY = __DIR__ . '/../../TermsOfService/lang'; private const string POLL_SHIPPED_DIRECTORY = __DIR__ . '/../../Poll/lang'; private string $directory; @@ -121,26 +120,6 @@ private static function files(string $directory): array return $files; } - /** The tool keeps the headers of an existing target file, so it runs over a copy of the shipped files. */ - public function testReconvertingTosReproducesTheShippedFilesByteForByte(): void - { - $shipped = self::files(self::SHIPPED_DIRECTORY); - $this->assertNotSame([], $shipped, 'the shipped tos files were not found'); - foreach ($shipped as $name => $content) { - file_put_contents($this->directory . '/out/' . $name, $content); - } - - [$exit_code, $stdout, $stderr] = $this->runTool('tos', 'en', $this->directory . '/out'); - - $this->assertSame(0, $exit_code, $stdout . $stderr); - $this->assertSame('', $stderr, 'no warnings, notices or deprecations'); - $this->assertStringContainsString('round-trip identically through PO', $stdout); - $this->assertSame(array_keys($shipped), array_keys(self::files($this->directory . '/out')), 'no file added or lost'); - foreach (self::files($this->directory . '/out') as $name => $content) { - $this->assertSame($shipped[$name], $content, $name); - } - } - /** * Plural pilot "poll": re-running reproduces the shipped files byte for byte, including single-form "ja". * A "n==1 -> 0, else 1" language gets msgstr[0] from the configured singular key and msgstr[1] from the diff --git a/lang/ilias_ar.lang b/lang/ilias_ar.lang index cfceaf87323e..f59d23e487b9 100644 --- a/lang/ilias_ar.lang +++ b/lang/ilias_ar.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy tbl#:#tbl_export_csv#:#تصدير .csv tbl#:#tbl_export_excel#:#تصدير .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_bg.lang b/lang/ilias_bg.lang index 9ac950f988e4..19b1f6c46fc8 100644 --- a/lang/ilias_bg.lang +++ b/lang/ilias_bg.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_cs.lang b/lang/ilias_cs.lang index cd8fa5206783..737426da43c0 100644 --- a/lang/ilias_cs.lang +++ b/lang/ilias_cs.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Nastavení taxonomie tax#:#tax_taxonomy#:#Taxonomie tbl#:#tbl_export_csv#:#Exportovat .csv tbl#:#tbl_export_excel#:#Exportovat .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#Vlastní registrace není možná kvůli chybějící uživatelské smlouvě. Další informace vám poskytne správce systému . -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Smluvní podmínky -tos#:#tos_disabled_no_docs_left#:#Uživatelské smlouvy jsou nyní zakázány, protože všechny dokumenty byly vymazány. Před opětovným povolením uživatelských smluv vytvořte alespoň jeden dokument. -tos#:#tos_last_reset_date#:#Podmínky služby byly resetovány na %s. -tos#:#tos_never_reset#:#Podmínky služby nebyly dosud resetovány. -tos#:#tos_no_documents_exist#:#V současné době neexistují žádné podmínky služeb pro žádný z nainstalovaných jazyků. -tos#:#tos_no_documents_exist_cant_save#:#V současné době neexistují žádné podmínky služeb pro žádný z nainstalovaných jazyků. Přidejte alespoň jeden dokument pro každý z nainstalovaných jazyků. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#Podmínky služby byly úspěšně resetovány. -tos#:#tos_status_desc#:#Pokud jsou aktivováni, uživatelé jsou nuceni přijmout podmínky služby dříve, než jim je povoleno vstoupit do ILIAS. -tos#:#tos_status_enable#:#Povolit -tos#:#tos_sure_reset_tos#:#Opravdu chcete resetovat podmínky služby pro všechny uživatele v systému? To platí i pro ty účty, které se používají pro webové služby SOAP, chatový server nebo úkol cron. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_da.lang b/lang/ilias_da.lang index 55a5d3fe2857..dc438cd33992 100644 --- a/lang/ilias_da.lang +++ b/lang/ilias_da.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Eksport .csv tbl#:#tbl_export_excel#:#Eksport .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###16 09 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###16 09 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###16 09 2013 new variable -tos#:#tos_status_enable#:#Enable###16 09 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###16 09 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index 38c75d0ff1a7..b67b2253abbb 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -17278,23 +17278,6 @@ tbl#:#tbl_template_created#:#Die aktuelle Ansicht wurde gespeichert. tbl#:#tbl_template_delete#:#Gespeicherte Ansicht löschen tbl#:#tbl_template_deleted#:#Die gespeicherte Ansicht wurde gelöscht. tbl#:#tbl_templates#:#Ansicht -tos#:#tos_accept_usr_agreement_anonymous#:#Nutzungsvereinbarung -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Bevor Sie zu ILIAS wechseln, akzeptieren Sie die folgende Nutzungsvereinbarung. -tos#:#tos_account_reg_not_possible#:#Eine Selbstregistrierung ist aktuell nicht möglich, da keine Nutzungsvereinbarung angeboten wird. Bitte kontaktieren Sie die technische Betreuung für weitere Informationen. -tos#:#tos_agree_date#:#Nutzungsvereinbarung zugestimmt am -tos#:#tos_agreement#:#Nutzungsvereinbarung -tos#:#tos_disabled_no_docs_left#:#Die Nutzungsvereinbarung wurde deaktiviert, da alle Dokumente gelöscht wurden. Erstellen Sie mindestens ein Dokument, bevor Sie die Nutzungsvereinbarung wieder aktivieren. -tos#:#tos_last_reset_date#:#Die Nutzungsvereinbarung wurde zuletzt am %s zurückgesetzt. Setzen Sie die Nutzungsvereinbarung nur zurück, wenn sich die Dokumente in der Zwischenzeit geändert haben und eine Zustimmung zu dieser geänderten Vereinbarung gewünscht ist. -tos#:#tos_never_reset#:#Die Nutzungsvereinbarung wurde bisher noch nicht zurückgesetzt. Setzen Sie die Nutzungsvereinbarung nur zurück, wenn sich die Dokumente in der Zwischenzeit geändert haben und eine Zustimmung zu dieser geänderten Vereinbarung gewünscht ist. -tos#:#tos_no_documents_exist#:#Aktuell liegen keine Dokumente für die Nutzungsvereinbarung vor. -tos#:#tos_no_documents_exist_cant_save#:#Aktuell liegt keine Nutzungsvereinbarung vor. Bitte erstellen Sie mindestens ein Dokument um den Service zu aktivieren. -tos#:#tos_reset_for_all_users#:#Nutzungsvereinbarung zurücksetzen -tos#:#tos_reset_successful#:#Die Nutzungsvereinbarung wurde erfolgreich zurückgesetzt. -tos#:#tos_status_desc#:#Benutzer werden zum Unterzeichnen der Nutzungsvereinbarung aufgefordert, bevor sie ILIAS nutzen können. -tos#:#tos_status_enable#:#Nutzungsvereinbarung aktivieren -tos#:#tos_sure_reset_tos#:#Sind Sie sicher, dass die Nutzungsvereinbarung für alle Benutzer im System zurückgesetzt werden soll? Dies betrifft auch jene Anmeldekonten, die z.B. für die SOAP-Web-Services genutzt werden. -tos#:#tos_withdrawal_usr_deletion#:#Löschung des ILIAS-Kontos beim Widerruf der Nutzungsvereinbarung -tos#:#tos_withdrawal_usr_deletion_desc#:#Wird die Zustimmung zur Nutzungsvereinbarung widerrufen, erfolgt automatisch die Löschung des ILIAS-Kontos. trac#:#cmix_lp_mode_deactivated#:#Lernfortschritt wird deaktiviert trac#:#cmix_lp_mode_deactivated_info#:#Der Lernfortschrittsstatus wird nicht angezeigt und hat keinen Einfluss auf übergeordnete Objekte. trac#:#cmix_lp_mode_when_completed#:#Abgeschlossen, wenn "completed". diff --git a/lang/ilias_el.lang b/lang/ilias_el.lang index 992ece1b4052..9a656a99d80e 100644 --- a/lang/ilias_el.lang +++ b/lang/ilias_el.lang @@ -17084,23 +17084,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 5716a76fd14f..bffe6cf52243 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -17220,23 +17220,6 @@ tbl#:#tbl_template_created#:#The current view has been saved. tbl#:#tbl_template_delete#:#Delete Saved View tbl#:#tbl_template_deleted#:#The saved view has been deleted. tbl#:#tbl_templates#:#View -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service. -tos#:#tos_account_reg_not_possible#:#Self-registration is currently not possible because there is no Terms of Service agreement available. Please contact your system administrator for further information. -tos#:#tos_agree_date#:#ToS agreed on -tos#:#tos_agreement#:#Terms of Service -tos#:#tos_disabled_no_docs_left#:#Terms of Service documents can no longer be displayed, because all ToS documents have been deleted. Users will no longer be required to agree to a ToS document when registering an account. Please create at least one document before re-checking the ‘Display Terms of Service’ box. -tos#:#tos_last_reset_date#:#The Terms of Service were reset on %s. Only reset the Terms of Service if changes have been made to the ToS document(s) and you require all users to agree to the new ToS. -tos#:#tos_never_reset#:#The Terms of Service have not yet been reset. Only reset the Terms of Service if changes have been made to the ToS document(s) and you require all users to agree to the new ToS. -tos#:#tos_no_documents_exist#:#There are currently no Terms of Service documents available. -tos#:#tos_no_documents_exist_cant_save#:#There are currently no Terms of Service documents available. Please add at least one document in order to activate this service. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service -tos#:#tos_reset_successful#:#Terms of Service successfully reset. -tos#:#tos_status_desc#:#Display relevant language-based Terms of Service documents at the end of the registration form for new users and, if applicable, upon the user’s first login. Users are required to accept the Terms of Service before they can enter ILIAS. -tos#:#tos_status_enable#:#Display Terms of Service -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the Terms of Service for all users in the system? This also applies, for example, to those accounts that are used for the SOAP web services. -tos#:#tos_withdrawal_usr_deletion#:#Account deletion upon ToS withdrawal -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account. trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects. trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed' diff --git a/lang/ilias_es.lang b/lang/ilias_es.lang index 7d4c8dd616a2..010f60568073 100644 --- a/lang/ilias_es.lang +++ b/lang/ilias_es.lang @@ -17153,23 +17153,6 @@ tax#:#tax_tax_settings#:#Ajustes de taxonomía tax#:#tax_taxonomy#:#Taxonomía tbl#:#tbl_export_csv#:#Exportar CSV tbl#:#tbl_export_excel#:#Exportar Excel -tos#:#tos_accept_usr_agreement_anonymous#:#Condiciones del servicio -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Antes de continuar a ILIAS, debe aceptar las siguientes Condiciones del servicio. -tos#:#tos_account_reg_not_possible#:#El registro autónomo no es posible actualmente porque no hay un acuerdo de Condiciones del servicio disponible. Por favor, contacte con su administrador del sistema para más información. -tos#:#tos_agree_date#:#Condiciones aceptadas el -tos#:#tos_agreement#:#Condiciones del servicio -tos#:#tos_disabled_no_docs_left#:#Los documentos de Condiciones del servicio ya no pueden mostrarse porque todos han sido eliminados. Ya no se requerirá a los usuarios aceptar un documento de Condiciones al registrar una cuenta. Por favor, cree al menos un documento antes de volver a marcar la casilla 'Mostrar Condiciones del servicio'. -tos#:#tos_last_reset_date#:#Las Condiciones del Servicio se restablecieron el %s. Solo restablezca las Condiciones del Servicio si se han realizado cambios en los documentos de los TyC y requiere que todos los usuarios acepten los nuevos términos. -tos#:#tos_never_reset#:#Las Condiciones del Servicio aún no se han restablecido. Solo restablezca las Condiciones del Servicio si se han realizado cambios en los documentos de los TyC y requiere que todos los usuarios acepten los nuevos términos. -tos#:#tos_no_documents_exist#:#Actualmente no hay documentos de Condiciones del Servicio disponibles. -tos#:#tos_no_documents_exist_cant_save#:#Actualmente no hay documentos de Condiciones del Servicio disponibles. Por favor, añada al menos un documento para activar este servicio. -tos#:#tos_reset_for_all_users#:#Restablecer Condiciones del Servicio -tos#:#tos_reset_successful#:#Condiciones del Servicio restablecidas con éxito. -tos#:#tos_status_desc#:#Muestra los documentos de Condiciones del Servicio pertinentes basados en el idioma al final del formulario de registro para nuevos usuarios y, si procede, en el primer inicio de sesión del usuario. Los usuarios deben aceptar las Condiciones del Servicio antes de poder entrar en ILIAS. -tos#:#tos_status_enable#:#Mostrar Condiciones del Servicio -tos#:#tos_sure_reset_tos#:#¿Está seguro de que desea restablecer las Condiciones del Servicio para todos los usuarios del sistema? Esto también se aplica, por ejemplo, a aquellas cuentas que se utilizan para los servicios web SOAP. -tos#:#tos_withdrawal_usr_deletion#:#Eliminación de la cuenta al retirar los TyC -tos#:#tos_withdrawal_usr_deletion_desc#:#Si un usuario retira su aceptación de un documento de Condiciones del Servicio aceptado previamente, esto resultará en la eliminación de la cuenta del usuario. trac#:#cmix_lp_mode_deactivated#:#El progreso de aprendizaje está desactivado trac#:#cmix_lp_mode_deactivated_info#:#El estado del progreso de aprendizaje no se muestra y no influye en los objetos superiores. trac#:#cmix_lp_mode_when_completed#:#Completado cuando esté 'completado' diff --git a/lang/ilias_et.lang b/lang/ilias_et.lang index 5916f5bbb97a..6a8682a607d5 100644 --- a/lang/ilias_et.lang +++ b/lang/ilias_et.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy tbl#:#tbl_export_csv#:#Export .csv tbl#:#tbl_export_excel#:#Export .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s. -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages. -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS. -tos#:#tos_status_enable#:#Enable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_fa.lang b/lang/ilias_fa.lang index 4d3f13fc94d8..f7f529e85bbd 100644 --- a/lang/ilias_fa.lang +++ b/lang/ilias_fa.lang @@ -17084,23 +17084,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#صدور .csv tbl#:#tbl_export_excel#:#صدور .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_fr.lang b/lang/ilias_fr.lang index 02135a6cf0ed..04126a7433f8 100644 --- a/lang/ilias_fr.lang +++ b/lang/ilias_fr.lang @@ -17068,23 +17068,6 @@ tax#:#tax_tax_settings#:#Paramètres de taxonomie tax#:#tax_taxonomy#:#Taxonomie tbl#:#tbl_export_csv#:#Export .CSV tbl#:#tbl_export_excel#:#Export .XLS -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information. -tos#:#tos_agree_date#:#ToS agreed on###31 10 2023 new variable -tos#:#tos_agreement#:#Conditions d'Utilisation -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again. -tos#:#tos_last_reset_date#:#Les conditions d'utilisation ont été réinitialisées le %s. -tos#:#tos_never_reset#:#The terms of service have not been reset, yet. -tos#:#tos_no_documents_exist#:#Il n'y a actuellement aucune condition d'utilisation définie pour les langues installées. -tos#:#tos_no_documents_exist_cant_save#:#Il n’y a actuellement aucun terme de service pour aucune des langues installées. Veuillez ajouter au moins un document pour l’une des langues installées. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###31 10 2023 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully. -tos#:#tos_status_desc#:#Lorsque activé, les utilisateurs doivent accepter les conditions d'utilisation avant de pouvoir entrer dans ILIAS. -tos#:#tos_status_enable#:#Activer -tos#:#tos_sure_reset_tos#:#Etes-vous sûr de vouloir réinitialiser les conditions d'utilisation pour tous les utilisateurs du système ? Ceci s'applique aussi aux comptes qui sont utilisés pour les WebServices SOAP, serveur de Chat et tâches Cron. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion on Terms Of Service Withdrawal -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###31 10 2023 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects. trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed' diff --git a/lang/ilias_hr.lang b/lang/ilias_hr.lang index c00028edebfe..fb5461507599 100644 --- a/lang/ilias_hr.lang +++ b/lang/ilias_hr.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings tax#:#tax_taxonomy#:#Taxonomy tbl#:#tbl_export_csv#:#Izvezi CSV tbl#:#tbl_export_excel#:#Izvezi Excel -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#Samoregistracija nije moguća zbog nedostatka korisničkog sporazuma. Za dodatne informacije obratite se svom administratoru sustava. -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Uvjeti pružanja usluge -tos#:#tos_disabled_no_docs_left#:#Korisnički ugovori sada su onemogućeni jer su svi dokumenti izbrisani. Izradite barem jedan dokument prije ponovnog omogućavanja korisničkih sporazuma. -tos#:#tos_last_reset_date#:#Uvjeti korištenja usluge resetirani su dana %s. -tos#:#tos_never_reset#:#Uvjeti korištenja usluge još nisu resetirani. -tos#:#tos_no_documents_exist#:#Trenutačno uopće ne postoje dokumenti o uvjetima korištenja usluge. -tos#:#tos_no_documents_exist_cant_save#:#Trenutačno uopće ne postoje dokumenti o uvjetima korištenja. Prije aktiviranja ove usluge dodajte barem jedan dokument .. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#Uvjeti korištenja usluge uspješno su resetirani. -tos#:#tos_status_desc#:#Ako je aktivirano, korisnici su prisiljeni prihvatiti uvjete korištenja usluge prije nego što uđu u ILIAS. -tos#:#tos_status_enable#:#Omogući -tos#:#tos_sure_reset_tos#:#Jeste li sigurni da želite resetirati uvjete korištenja usluge za sve korisnike u sustavu? To se također odnosi na one račune koji se na primjer koriste za internetsku uslugu SOAP. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion on Terms Of Service Withdrawal ###04 06 2021 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###04 06 2021 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###04 06 2021 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###04 06 2021 new variable diff --git a/lang/ilias_hu.lang b/lang/ilias_hu.lang index d4253380e0f8..82cb21a39a62 100644 --- a/lang/ilias_hu.lang +++ b/lang/ilias_hu.lang @@ -17148,23 +17148,6 @@ tbl#:#tbl_template_created#:#Ezt a nézetet sikeresen mentette. tbl#:#tbl_template_delete#:#Mentett nézet törlése tbl#:#tbl_template_deleted#:#A mentett nézetet sikeresen törölte. tbl#:#tbl_templates#:#Nézet -tos#:#tos_accept_usr_agreement_anonymous#:#Szolgáltatási feltételek -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Mielőtt továbblép az ILIAS-hoz, elfogadja az alábbi Szolgáltatási feltételeket -tos#:#tos_account_reg_not_possible#:#Nem regisztrálhatja saját magát, mert hiányzik a Szolgáltatási feltételek. További információkért vegye fel a kapcsolatot a rendszerüzemeltetőkkel. -tos#:#tos_agree_date#:#Elfogadás időpontja -tos#:#tos_agreement#:#Szolgáltatási feltételek -tos#:#tos_disabled_no_docs_left#:#A felhasználási feltételeket kikapcsoltuk, mert az összes dokumentumot letörölték. Legalább egy dokumentumot hozzon létre a felhasználási feltételek újbóli bekapcsolása előtt. -tos#:#tos_last_reset_date#:#A szolgáltatási feltételek alapértékre állítva: %s. -tos#:#tos_never_reset#:#A Szolgáltatási feltételeket még sosem állították még alapértékre. -tos#:#tos_no_documents_exist#:#Jelenleg egy telepített nyelvhez sincs szolgáltatási feltétel. -tos#:#tos_no_documents_exist_cant_save#:#Jelenleg egy telepített nyelvhez sincs szolgáltatási feltétel. Kérem, legalább egy nyelvhez hozzon létre. -tos#:#tos_reset_for_all_users#:#Szolgáltatási feltételek elfogadásainak törlése -tos#:#tos_reset_successful#:#Szolgáltatási feltételek elfogadásait sikeresen törölte. -tos#:#tos_status_desc#:#A felhasználóknak az ILAS-ba belépés előtt el kell fogadniuk a Szolgáltatási feltételeket. -tos#:#tos_status_enable#:#Engedélyezés -tos#:#tos_sure_reset_tos#:#Biztos, hogy törli az összes felhasználó Szolgáltatási feltételeit? Ez azokra a fiókokra is hatással van, melyeket SOAP webszolgáltatások, a csevegőszerver, vagy az ütemezett feladatok használnak. -tos#:#tos_withdrawal_usr_deletion#:#ILIAS-fiók törlése a Szolgáltatási feltételek elutasítás esetén -tos#:#tos_withdrawal_usr_deletion_desc#:#Ha egy felhasználó visszavonja egy korábbi Szolgáltatási feltételek elfogadását, az ILIAS-fiókja törlését vonja maga után. trac#:#cmix_lp_mode_deactivated#:#A tanulási haladás ki van kapcsolva trac#:#cmix_lp_mode_deactivated_info#:#A tanulási haladás állapota nem jelenik meg és nincs hatással a szülőobjektumokra. trac#:#cmix_lp_mode_when_completed#:#Sikeresen teljesített amikor ‘befejezte’ diff --git a/lang/ilias_it.lang b/lang/ilias_it.lang index d0f69ec9dbe4..52c4879f0381 100644 --- a/lang/ilias_it.lang +++ b/lang/ilias_it.lang @@ -17090,23 +17090,6 @@ tax#:#tax_tax_settings#:#Impostazioni tassonomia tax#:#tax_taxonomy#:#Tassonomia tbl#:#tbl_export_csv#:#Esporta .csv tbl#:#tbl_export_excel#:#Esporta .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###18 06 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###18 06 2024 new variable -tos#:#tos_account_reg_not_possible#:#Una registrazione automatica non è possibile a causa del contratto utente mancante. Per favore contatta il tuoamministratore di sistema per maggiori informazioni. -tos#:#tos_agree_date#:#ToS agreed on###30 04 2024 new variable -tos#:#tos_agreement#:#Termini di servizio -tos#:#tos_disabled_no_docs_left#:#I contratti utente sono ora disabilitati, poiché tutti i documenti sono stati eliminati. Si prega di creare almeno un documento prima di abilitare nuovamente gli accordi utente. -tos#:#tos_last_reset_date#:#Gli accordi utente sono stati reimpostati l'ultima volta il %s. Reimposta il contratto utente solo se nel frattempo è cambiato e desideri approvare il contratto modificato. -tos#:#tos_never_reset#:#I termini di servizio non sono ancora stati ripristinati. -tos#:#tos_no_documents_exist#:#Attualmente non ci sono termini di servizio per ogni lingua installata. -tos#:#tos_no_documents_exist_cant_save#:#Attualmente non ci sono termini di servizio per ogni lingua installata. Si prega di aggiungere almeno un documento per ogni lingua installata. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###30 04 2024 new variable -tos#:#tos_reset_successful#:#I termini di servizio sono stati ripristinati correttamente. -tos#:#tos_status_desc#:#Se attivato, gli utenti sono costretti ad accettare i termini di servizio prima di poter entrare in ILIAS. -tos#:#tos_status_enable#:#Attiva i contratti di utilizzo -tos#:#tos_sure_reset_tos#:#Sei sicuro di voler ripristinare i termini di servizio per tutti gli utenti del sistema? Questo vale anche per quegli account che vengono utilizzati per i servizi web SOAP, il server di chat o il cron job. -tos#:#tos_withdrawal_usr_deletion#:#Eliminazione dell'account in caso di ritiro dei termini di servizio -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###30 04 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Il progresso di apprendimento è disattivato trac#:#cmix_lp_mode_deactivated_info#:#Lo stato di avanzamento dell'apprendimento non viene visualizzato e non influenza gli oggetti padre. trac#:#cmix_lp_mode_when_completed#:#Completato quando "completato" diff --git a/lang/ilias_ja.lang b/lang/ilias_ja.lang index a2cf44b8a09f..43bdb9ac4cad 100644 --- a/lang/ilias_ja.lang +++ b/lang/ilias_ja.lang @@ -17147,23 +17147,6 @@ tbl#:#tbl_template_created#:#現在の表示が保存されました。 tbl#:#tbl_template_delete#:#保存した表示を削除します tbl#:#tbl_template_deleted#:#保全した表示を削除しました。 tbl#:#tbl_templates#:#表示 -tos#:#tos_accept_usr_agreement_anonymous#:#利用規約 -tos#:#tos_accept_usr_agreement_anonymous_intro#:#ILIASを利用する前に次の利用規約を承諾するものとします。 -tos#:#tos_account_reg_not_possible#:#ユーザ許諾が無いので自己登録はできません。詳細に関してはシステム管理者へコンタクトしてください。 -tos#:#tos_agree_date#:#使用許諾 承諾日 -tos#:#tos_agreement#:#サービス使用許諾 -tos#:#tos_disabled_no_docs_left#:#全てのドキュメントが削除されたのでユーザ許諾書は現在無効です。ユーザ許諾書を再度有効にする前に一つはドキュメントを作成してください。 -tos#:#tos_last_reset_date#:#サービス使用許諾は%sにリセットされました。 -tos#:#tos_never_reset#:#サービス規約はまだリセットされていません。 -tos#:#tos_no_documents_exist#:#現在インストール済言語のサービス使用許諾書はありません -tos#:#tos_no_documents_exist_cant_save#:#インストール済の言語にはサービス用語が現在何もありません。どれかインストール済の言語用に一つはドキュメントを追加してください。 -tos#:#tos_reset_for_all_users#:#サービス使用許諾をリセット -tos#:#tos_reset_successful#:#サービス規約は正しくリセットされました。 -tos#:#tos_status_desc#:#アクティブにするとユーザはILIASログイン前にサービス使用許諾への承諾を要求されます。 -tos#:#tos_status_enable#:#有効 -tos#:#tos_sure_reset_tos#:#本当にシステム内の全ユーザのサービス使用許諾をリセットしますか?SOAP webサービス、chatサービス、クローンjobを使用しているユーザアカウントに対しても適用される事になります。 -tos#:#tos_withdrawal_usr_deletion#:#利用許諾撤回の削除 -tos#:#tos_withdrawal_usr_deletion_desc#:#ユーザが事前に承諾したサービス使用許諾ドキュメントの承諾を破棄すると、ユーザのアカウント削除につながります。 trac#:#cmix_lp_mode_deactivated#:#学習進捗は解除されます trac#:#cmix_lp_mode_deactivated_info#:#学習進捗ステータスは表示されないで親オブジェクトへの影響はありません。 trac#:#cmix_lp_mode_when_completed#:#'完了'時に完了しました diff --git a/lang/ilias_ka.lang b/lang/ilias_ka.lang index 845cefc37b2c..0965e7355714 100644 --- a/lang/ilias_ka.lang +++ b/lang/ilias_ka.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy tbl#:#tbl_export_csv#:#ექსპორტი .csv tbl#:#tbl_export_excel#:#ექსპორტი .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_lt.lang b/lang/ilias_lt.lang index 1b08a060d88f..50c01a43aa0a 100644 --- a/lang/ilias_lt.lang +++ b/lang/ilias_lt.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_nl.lang b/lang/ilias_nl.lang index 943aad1973f6..6117a4d6d470 100644 --- a/lang/ilias_nl.lang +++ b/lang/ilias_nl.lang @@ -17084,23 +17084,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomie tbl#:#tbl_export_csv#:#Exporteren .csv tbl#:#tbl_export_excel#:#Exporteren .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Gebruikersovereenkomst -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#De gebruikersovereenkomst is gereset op %s. -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Er zijn momenteel geen gebruikersvoorwaarden voor de geïnstalleerde talen. -tos#:#tos_no_documents_exist_cant_save#:#Momenteel zijn er geen gebruikersovereenkomsten voor enige geïnstalleerde taal. Voeg tenminste een overeenkomst toe voor een van deze talen. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#Indien ingeschakeld zijn gebruikers verplicht de gebruikersovereenkomst te accepteren voor ze ILIAS kunnen gebruiken. -tos#:#tos_status_enable#:#Inschakelen -tos#:#tos_sure_reset_tos#:#Weet je zeker dat je de gebruikersovereenkomst wilt resetten voor alle gebruikers in het systeem? Dit geldt zan ook voor die accounts doe worden gebruikt voor de SOAP web services, de chat server. en de cron job. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_pl.lang b/lang/ilias_pl.lang index f1a299be6934..05e9bae4ea03 100644 --- a/lang/ilias_pl.lang +++ b/lang/ilias_pl.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Ustawienia dotyczące klasyfikacji tax#:#tax_taxonomy#:#Klasyfikacja tbl#:#tbl_export_csv#:#Eksportuj do .csv tbl#:#tbl_export_excel#:#Eksportuj do .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information. -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Zasady użytkowania -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again. -tos#:#tos_last_reset_date#:#Zasady użytkowania zostały po raz ostatni resetowane w dniu %s. -tos#:#tos_never_reset#:#The terms of service have not been reset, yet. -tos#:#tos_no_documents_exist#:#Aktualnie brak zasad użytkowania dostępnych w zapisanych językach. -tos#:#tos_no_documents_exist_cant_save#:#Aktualnie brak zasad użytkowania dostępnych w zapisanych językach. Dlatego nie możesz aktywować tej usługi. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully. -tos#:#tos_status_desc#:#Jeśli ta opcja jest aktywna, użytkownicy przed korzystaniem z ILIAS proszeni są o podpisanie zasad użytkowania. -tos#:#tos_status_enable#:#Aktywuj -tos#:#tos_sure_reset_tos#:#Jesteś pewien, że zasady użytkowania mają zostać zresetowane w systemie dla wszystkich użytkowników? Dotyczy to również tych kont użytkowników, które są ewentualnie wykorzystywane do usług sieciowych SOAP, serwerów czatowych i zadań Cron. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_pt.lang b/lang/ilias_pt.lang index ed1cfa06ecfa..7508d4455bba 100644 --- a/lang/ilias_pt.lang +++ b/lang/ilias_pt.lang @@ -17182,23 +17182,6 @@ tbl#:#tbl_template_created#:#A vista atual foi guardada. tbl#:#tbl_template_delete#:#Apagar vista guardada tbl#:#tbl_template_deleted#:#A vista atual foi apagada. tbl#:#tbl_templates#:#Visão -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###03 09 2026 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###03 09 2026 new variable -tos#:#tos_account_reg_not_possible#:#Não é possível registar-se a si próprio porque falta um acordo do utilizador. Entre em contacto com o administrador do seu sistema para mais informação. -tos#:#tos_agree_date#:#ToS agreed on###03 09 2026 new variable -tos#:#tos_agreement#:#Termos de serviço -tos#:#tos_disabled_no_docs_left#:#Os acordos do utilizador estão agora desativados porque todos os documentos foram apagados. Crie pelo menos um documento antes de ativar novamente os acordos do utilizador. -tos#:#tos_last_reset_date#:#Os termos de serviço foram repostos em %s. -tos#:#tos_never_reset#:#Os termos de serviço ainda não foram repostos. -tos#:#tos_no_documents_exist#:#Atualmente não há termos de serviço para qualquer idioma instalado. -tos#:#tos_no_documents_exist_cant_save#:#Atualmente não há termos de serviço para qualquer idioma instalado. Adicione pelo menos um documento para qualquer idioma instalado. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###03 09 2026 new variable -tos#:#tos_reset_successful#:#Os termos de serviço foram repostos com sucesso. -tos#:#tos_status_desc#:#Se estiver ativado, os utilizadores são forçados a aceitar os termos de serviço antes de poderem entrar em ILIAS. -tos#:#tos_status_enable#:#Ativar -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion on Terms Of Service Withdrawal###30 04 2021 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###03 09 2026 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_ro.lang b/lang/ilias_ro.lang index 7e858af76cb4..ab1308c849ec 100644 --- a/lang/ilias_ro.lang +++ b/lang/ilias_ro.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_ru.lang b/lang/ilias_ru.lang index 556e15baeea9..8d10b387ec7a 100644 --- a/lang/ilias_ru.lang +++ b/lang/ilias_ru.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy tbl#:#tbl_export_csv#:#Экспортировать в .csv tbl#:#tbl_export_excel#:#Экспортировать в .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Правила сервиса -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s. -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages. -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS. -tos#:#tos_status_enable#:#Enable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_sk.lang b/lang/ilias_sk.lang index 06906fbf5ae1..20036c8567ea 100644 --- a/lang/ilias_sk.lang +++ b/lang/ilias_sk.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv tbl#:#tbl_export_excel#:#Export .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_sl.lang b/lang/ilias_sl.lang index 556859b6fc48..b2e1d6c1dfba 100644 --- a/lang/ilias_sl.lang +++ b/lang/ilias_sl.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings tax#:#tax_taxonomy#:#Taxonomy tbl#:#tbl_export_csv#:#Izvoz .CSV tbl#:#tbl_export_excel#:#Izvoz Excel -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#Samoregistracija trenutno ni mogoča zaradi pomanjkanja uporabniške pogodbe. Za nadaljnje informacije se obrnite na svojega Sistemskega administratorja>. -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Pogoji uporabe -tos#:#tos_disabled_no_docs_left#:#Pogoji uporabe so bili deaktivirani, ker so bili vsi dokumenti izbrisani. Ustvarite vsaj en dokument, preden ponovno aktivirate pogoje uporabe. -tos#:#tos_last_reset_date#:#Pogoji uporabe se bili nazadnje ponastavljeni dne %s. Pogoje uporabe ponastavite samo takrat, ko so se ti v vmesnem času spremenili in je zaželeno soglasje za te spremenjene pogoje. -tos#:#tos_never_reset#:#Pogoji uporabe do sedaj še niso bili ponastavljeni. Pogoje uporabe ponastavite samo takrat, ko so se ti v vmesnem času spremenili in je zaželeno soglasje za te spremenjene pogoje. -tos#:#tos_no_documents_exist#:#Trenutno ni na voljo nobenih dokumentov o pogojih uporabe. -tos#:#tos_no_documents_exist_cant_save#:#Trenutno ni na voljo nobenih pogojev uporabe. Ustvarite vsaj en dokument, da aktivirate storitev. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#Pogoji uporabe so bili uspešno ponastavljeni. -tos#:#tos_status_desc#:#Uporabniki morajo podpisati pogoje uporabe, preden lahko začnejo uporabljati ILIAS. -tos#:#tos_status_enable#:#Aktiviraj pogoje uporabe -tos#:#tos_sure_reset_tos#:#Ali ste prepričani, da želite ponastaviti pogoje uporabe za vse uporabnike v sistemu? To velja tudi za tiste uporabniške račune, ki se npr. uporabljajo za internetno storitev SOAP. -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion on Terms Of Service Withdrawal###19 08 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects. trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed' diff --git a/lang/ilias_sq.lang b/lang/ilias_sq.lang index dc1bc25b4818..05f71afe9851 100644 --- a/lang/ilias_sq.lang +++ b/lang/ilias_sq.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_sr.lang b/lang/ilias_sr.lang index 0a70fbca6768..f311fa37e2b8 100644 --- a/lang/ilias_sr.lang +++ b/lang/ilias_sr.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_sv.lang b/lang/ilias_sv.lang index e0bdeffb9245..a5de15914ce7 100644 --- a/lang/ilias_sv.lang +++ b/lang/ilias_sv.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomiska inställningar tax#:#tax_taxonomy#:#Taxonomi tbl#:#tbl_export_csv#:#.CSV-export tbl#:#tbl_export_excel#:#Excel export -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#Självregistrering är för närvarande inte möjlig eftersom inget användaravtal erbjuds i denna installation. Kontakta din systemadministratör för mer information. -tos#:#tos_agree_date#:#ToS agreed on###21 11 2023 new variable -tos#:#tos_agreement#:#Använda avtal -tos#:#tos_disabled_no_docs_left#:#Användaravtalen har inaktiverats eftersom alla dokument har raderats. Skapa minst ett dokument innan du återaktiverar användaravtalen. -tos#:#tos_last_reset_date#:#Användaravtalen återställdes senast den %s. Återställ endast användaravtalen om de har ändrats under tiden och samtycke till detta ändrade avtal önskas. -tos#:#tos_never_reset#:#Användaravtalen har ännu inte återställts. Återställ endast användaravtalen om de har ändrats under tiden och samtycke till detta ändrade avtal önskas. -tos#:#tos_no_documents_exist#:#Det finns för närvarande inga tillgängliga dokument om användningsavtal. -tos#:#tos_no_documents_exist_cant_save#:#Det finns för närvarande inga tillgängliga användaravtal. Vänligen skapa minst ett dokument för att aktivera tjänsten. -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###21 11 2023 new variable -tos#:#tos_reset_successful#:#Användaravtalen har återställts framgångsrikt. -tos#:#tos_status_desc#:#Användare ombeds att underteckna användaravtalet innan de kan använda ILIAS. -tos#:#tos_status_enable#:#Aktivera användningsavtal -tos#:#tos_sure_reset_tos#:#Är du säker på att användaravtalen för alla användare i systemet ska återställas? Detta gäller även de inloggningskonton som används för SOAP-webbtjänsterna, till exempel. -tos#:#tos_withdrawal_usr_deletion#:#Radering av ILIAS-kontot efter återkallelse -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###21 11 2023 new variable trac#:#cmix_lp_mode_deactivated#:#Learning progress är avaktiverad trac#:#cmix_lp_mode_deactivated_info#:#Inlärningsstatus visas inte och har inget inflytande på objekt på högre nivå. trac#:#cmix_lp_mode_when_completed#:#Completed när "completed". diff --git a/lang/ilias_tr.lang b/lang/ilias_tr.lang index f1e0bfeb0fa6..2d4b1acb1d2b 100644 --- a/lang/ilias_tr.lang +++ b/lang/ilias_tr.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taksonomi tbl#:#tbl_export_csv#:#.xsv Dışa Aktar tbl#:#tbl_export_excel#:#. xls Dışa Aktar -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_uk.lang b/lang/ilias_uk.lang index 36d8f5e1d1f8..a50af3e16b28 100644 --- a/lang/ilias_uk.lang +++ b/lang/ilias_uk.lang @@ -17083,23 +17083,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_vi.lang b/lang/ilias_vi.lang index 3268c537b397..a4a24116e2b6 100644 --- a/lang/ilias_vi.lang +++ b/lang/ilias_vi.lang @@ -17085,23 +17085,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#Taxonomy###28 08 2012 new variable tbl#:#tbl_export_csv#:#Export .csv###16 07 2010 new variable tbl#:#tbl_export_excel#:#Export .xls###16 07 2010 new variable -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable diff --git a/lang/ilias_zh.lang b/lang/ilias_zh.lang index d64e59774a01..fd24d790a8d8 100644 --- a/lang/ilias_zh.lang +++ b/lang/ilias_zh.lang @@ -17082,23 +17082,6 @@ tax#:#tax_tax_settings#:#Taxonomy Settings###31 08 2017 new variable tax#:#tax_taxonomy#:#分类学 tbl#:#tbl_export_csv#:#导出 .csv tbl#:#tbl_export_excel#:#导出 .xls -tos#:#tos_accept_usr_agreement_anonymous#:#Terms of Service###26 08 2024 new variable -tos#:#tos_accept_usr_agreement_anonymous_intro#:#Before you proceed to ILIAS you accept the following Terms of Service.###26 08 2024 new variable -tos#:#tos_account_reg_not_possible#:#A self registration is not possible because of a missing user agreement. Please contact your system administrator for further information.###10 11 2018 new variable -tos#:#tos_agree_date#:#ToS agreed on###26 08 2024 new variable -tos#:#tos_agreement#:#Terms of Service###24 10 2013 new variable -tos#:#tos_disabled_no_docs_left#:#The user agreements are now disabled, because all documents have been deleted. Please create at least one document before enabling the user agreements again.###10 11 2018 new variable -tos#:#tos_last_reset_date#:#The terms of service have been reset on %s.###24 10 2013 new variable -tos#:#tos_never_reset#:#The terms of service have not been reset, yet.###10 11 2018 new variable -tos#:#tos_no_documents_exist#:#Currently there are no terms of service for any of the installed languages.###10 02 2014 new variable -tos#:#tos_no_documents_exist_cant_save#:#Currently there are no terms of service for any of the installed languages. Please add at least one document for any of the installed languages.###20 07 2014 new variable -tos#:#tos_reset_for_all_users#:#Reset Terms of Service###26 08 2024 new variable -tos#:#tos_reset_successful#:#The terms of service have been reset successfully.###10 11 2018 new variable -tos#:#tos_status_desc#:#If activated, users are forced to accept the terms of service before they can enter ILIAS.###24 10 2013 new variable -tos#:#tos_status_enable#:#Enable###24 10 2013 new variable -tos#:#tos_sure_reset_tos#:#Are you sure you want to reset the terms of service for all users in system? This also applies to those accounts that are used for the SOAP webservices, the chat server, or the cron job.###24 10 2013 new variable -tos#:#tos_withdrawal_usr_deletion#:#Account Deletion Upon Withdrawal From Terms of Service###29 07 2022 new variable -tos#:#tos_withdrawal_usr_deletion_desc#:#If a user withdraws their acceptance from a previously accepted Terms of Service document, this will result in the deletion of the user’s account.###26 08 2024 new variable trac#:#cmix_lp_mode_deactivated#:#Learning Progress is Deactivated###07 02 2020 new variable trac#:#cmix_lp_mode_deactivated_info#:#The learning progress status is not displayed and does not influence parent objects.###07 02 2020 new variable trac#:#cmix_lp_mode_when_completed#:#Completed when 'completed'###07 02 2020 new variable From 246db8fefae45dedefd80657079c446ad5aa3b76 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 13:45:58 +0200 Subject: [PATCH 047/103] Poll: Remove the poll module from the global language files Its PO files are the only source now (convert_module_to_po.php --remove-from-lang). The test that re-created them from the global language files is dropped with its source, and the examples of the tool show the migration with --remove-from-lang. --- .../tests/ConvertModuleToPoToolTest.php | 27 -------- .../Language/tools/po-migration/README.md | 12 ++-- lang/ilias_ar.lang | 69 ------------------- lang/ilias_bg.lang | 69 ------------------- lang/ilias_cs.lang | 69 ------------------- lang/ilias_da.lang | 69 ------------------- lang/ilias_de.lang | 69 ------------------- lang/ilias_el.lang | 69 ------------------- lang/ilias_en.lang | 68 ------------------ lang/ilias_es.lang | 69 ------------------- lang/ilias_et.lang | 69 ------------------- lang/ilias_fa.lang | 69 ------------------- lang/ilias_fr.lang | 69 ------------------- lang/ilias_hr.lang | 69 ------------------- lang/ilias_hu.lang | 69 ------------------- lang/ilias_it.lang | 69 ------------------- lang/ilias_ja.lang | 68 ------------------ lang/ilias_ka.lang | 69 ------------------- lang/ilias_lt.lang | 69 ------------------- lang/ilias_nl.lang | 69 ------------------- lang/ilias_pl.lang | 69 ------------------- lang/ilias_pt.lang | 68 ------------------ lang/ilias_ro.lang | 69 ------------------- lang/ilias_ru.lang | 69 ------------------- lang/ilias_sk.lang | 69 ------------------- lang/ilias_sl.lang | 69 ------------------- lang/ilias_sq.lang | 69 ------------------- lang/ilias_sr.lang | 69 ------------------- lang/ilias_sv.lang | 69 ------------------- lang/ilias_tr.lang | 69 ------------------- lang/ilias_uk.lang | 69 ------------------- lang/ilias_vi.lang | 69 ------------------- lang/ilias_zh.lang | 69 ------------------- 33 files changed, 5 insertions(+), 2170 deletions(-) diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php index d6a987310c6d..dfeab05ce859 100644 --- a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -28,7 +28,6 @@ class ConvertModuleToPoToolTest extends TestCase { private const string TOOL = __DIR__ . '/../tools/po-migration/convert_module_to_po.php'; - private const string POLL_SHIPPED_DIRECTORY = __DIR__ . '/../../Poll/lang'; private string $directory; @@ -120,32 +119,6 @@ private static function files(string $directory): array return $files; } - /** - * Plural pilot "poll": re-running reproduces the shipped files byte for byte, including single-form "ja". - * A "n==1 -> 0, else 1" language gets msgstr[0] from the configured singular key and msgstr[1] from the - * message's own value; otherwise every form gets the message's own value. - */ - public function testReconvertingPollReproducesTheShippedFilesByteForByteIncludingPluralForms(): void - { - $shipped = self::files(self::POLL_SHIPPED_DIRECTORY); - $this->assertNotSame([], $shipped, 'the shipped poll files were not found'); - $this->assertStringContainsString('msgid_plural "poll_population_plural"', $shipped['poll_de.po'] ?? '', 'precondition: poll_de.po has the expected plural message'); - foreach ($shipped as $name => $content) { - file_put_contents($this->directory . '/out/' . $name, $content); - } - - [$exit_code, $stdout, $stderr] = $this->runTool('--skip-unmigratable-keys', 'poll', 'en', $this->directory . '/out'); - - $this->assertSame(0, $exit_code, $stdout . $stderr); - // poll_import is missing in en, so the shipped files leave it out - $this->assertMatchesRegularExpression('/\AWARNING: [^\n]+\n( [a-z]+: poll_import\n)+\z/', $stderr, 'only poll_import is left out, no notices or deprecations'); - $this->assertStringContainsString('(0 entries filled from \'en\', flagged fuzzy)', $stdout); - $this->assertSame(array_keys($shipped), array_keys(self::files($this->directory . '/out')), 'no file added or lost'); - foreach (self::files($this->directory . '/out') as $name => $content) { - $this->assertSame($shipped[$name], $content, $name); - } - } - /** Every language's keys must be a subset of the reference language's, else the run fails before writing. */ public function testFailsWithExitOneAndWritesNothingWhenALanguageHasAKeyMissingFromTheReferenceLanguage(): void { diff --git a/components/ILIAS/Language/tools/po-migration/README.md b/components/ILIAS/Language/tools/po-migration/README.md index d6ecf46f5b55..9bea19c31818 100644 --- a/components/ILIAS/Language/tools/po-migration/README.md +++ b/components/ILIAS/Language/tools/po-migration/README.md @@ -61,12 +61,11 @@ Options: Unknown options fail, so a typo is never taken as module name or directory. -Examples (the shipped files of both pilot modules are reproduced byte for -byte): +Examples (how the pilot modules were migrated): ``` -php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php tos en components/ILIAS/TermsOfService/lang -php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php poll en components/ILIAS/Poll/lang +php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php --remove-from-lang tos en components/ILIAS/TermsOfService/lang +php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php --remove-from-lang poll en components/ILIAS/Poll/lang ``` ### Input @@ -172,9 +171,8 @@ not kept in the repository. ## Tests -* `components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php`: re-creates - the shipped files of `tos` and `poll` byte for byte and covers options, - failures, fuzzy handling, plural messages and `--remove-from-lang`. +* `components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php`: covers + options, failures, fuzzy handling, plural messages and `--remove-from-lang`. * `components/ILIAS/Language/tests/ComponentTranslation/PluralFormsShippedRulesTest.php`: every header of `plurals.json` against an independent CLDR reference. diff --git a/lang/ilias_ar.lang b/lang/ilias_ar.lang index f59d23e487b9..d11e11a15acc 100644 --- a/lang/ilias_ar.lang +++ b/lang/ilias_ar.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved. pdesk#:#bookmark_select_target#:#Please select target. -poll#:#poll_absolute#:#Current Votes -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll. -poll#:#poll_add#:#Add Poll -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll. -poll#:#poll_block_message_no_answers#:#This poll is incomplete. -poll#:#poll_block_results_available_on#:#The results will be available on %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll -poll#:#poll_delete_votes#:#Delete All Votes -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes? -poll#:#poll_edit#:#Edit Poll -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image -poll#:#poll_import#:#Import Poll -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage -poll#:#poll_population#:#%s Votes -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question -poll#:#poll_result#:#Voting Result -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results -poll#:#poll_view_results_after_period#:#After Voting Period -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote -poll#:#poll_view_results_always#:#Always -poll#:#poll_view_results_never#:#Never -poll#:#poll_vote#:#Vote -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes. -poll#:#poll_voting_period_and_results#:#Voting Period and Results -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_bg.lang b/lang/ilias_bg.lang index 19b1f6c46fc8..35745a876d14 100644 --- a/lang/ilias_bg.lang +++ b/lang/ilias_bg.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'.###15 09 2008 new variable pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_cs.lang b/lang/ilias_cs.lang index 737426da43c0..c3eb7e4489b7 100644 --- a/lang/ilias_cs.lang +++ b/lang/ilias_cs.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Nadcházející pd#:#pd_view_select_at_least_one#:#Musíte vybrat alespoň jedno zobrazení, také 'Vybrané položky' a/nebo 'Má členství'. pdesk#:#bookmark_moved_ok#:#Záložka byla přesunuta. pdesk#:#bookmark_select_target#:#Vyberte prosím cíl. -poll#:#poll_absolute#:#Aktuální hlasy -poll#:#poll_activation_online_info#:#Aktivujte toto nastavení ke zpřístupnění hlasování uživatelům. -poll#:#poll_add#:#Přidat anketu -poll#:#poll_anonymous_warning#:#Vaše jméno není ve výsledcích ankety viditelné. -poll#:#poll_answer#:#Odpověď -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Možné odpovědi -poll#:#poll_barchart#:#Sloupcový graf -poll#:#poll_block_message_already_voted#:#V této anketě jste již hlasoval(a). -poll#:#poll_block_message_no_answers#:#Tato anketa není kompletní. -poll#:#poll_block_results_available_on#:#Tyto výsledky budou dostupné na %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Komentáře -poll#:#poll_copy#:#Kopírovat anketu -poll#:#poll_delete_votes#:#Smazat všechny hlasy -poll#:#poll_delete_votes_sure#:#Skutečně chcete smazat všechny hlasy? -poll#:#poll_edit#:#Upravit anketu -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Obrázek -poll#:#poll_import#:#Importovat anketu -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximální počet odpovědí na účastníka -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Režim -poll#:#poll_mode_anonymous#:#Anonymní -poll#:#poll_mode_anonymous_info#:#Nelze zpětně vysledovat, jak účastníci hlasovali. -poll#:#poll_mode_personal#:#Osobní -poll#:#poll_mode_personal_info#:#Seznam účastníků s jejich příslušnými hlasy je k dispozici při dostatečném povolení. -poll#:#poll_new#:#Přidat novou anketu -poll#:#poll_non_anonymous_warning#:#Vaše jméno a hlas jsou viditelné správcům ve výsledcích. -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Aktivovat oznámení -poll#:#poll_notification_unsubscribe#:#Deaktivovat oznámení -poll#:#poll_percentage#:#Aktuální procento -poll#:#poll_population#:#%s Hlasů -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Otázka -poll#:#poll_result#:#Výsledky hlasování -poll#:#poll_result_answers#:#Hlasy -poll#:#poll_result_sorting#:#Třídění -poll#:#poll_result_sorting_answers#:#V pořadí odpovědí -poll#:#poll_result_sorting_votes#:#Podle počtu hlasů (sestupně) -poll#:#poll_result_users#:#Účastníci -poll#:#poll_show_results_as#:#Zobrazit výsledky jako -poll#:#poll_sortorder#:#Pořadí -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Zobrazit výsledky -poll#:#poll_view_results_after_period#:#Po době určené k hlasování -poll#:#poll_view_results_after_period_impossible#:#Doba hlasování není omezena. -poll#:#poll_view_results_after_vote#:#Po hlasování -poll#:#poll_view_results_always#:#Vždy -poll#:#poll_view_results_never#:#Nikdy -poll#:#poll_vote#:#Hlas -poll#:#poll_vote_error_multi#:#Vyberte více než %s odpovědí. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Vyberte prosím 1 odpověď. -poll#:#poll_vote_notification_body#:#součástí následující ankety bylo hlasování. -poll#:#poll_vote_notification_link#:#Odkaz na Anketu -poll#:#poll_vote_notification_reason#:#Dostáváte tento e-mail, protože jste aktivovali upozornění na výše uvedenou anketu. -poll#:#poll_vote_notification_subject#:#Anketa "%s": nové hlasování -poll#:#poll_votes_no_edit#:#Tato anketa již obsahuje hlasy. Nemůžete tuto anketu upravovat, dokud tyto hlasy nesmažete. -poll#:#poll_voting_period_and_results#:#Období hlasování a výsledky -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Doba hlasování od %s do %s -poll#:#poll_voting_period_info#:#Termín pro hlasování: %s -poll#:#poll_voting_period_limited#:#Omezeno prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_da.lang b/lang/ilias_da.lang index dc438cd33992..185d6b3fc93d 100644 --- a/lang/ilias_da.lang +++ b/lang/ilias_da.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#Du skal vælge mindst en visning, enten 'Valgte elementer' og/eller/ 'Mine medlemsskaber'. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Stemmer indtil nu -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Valg -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Mulige valg -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#Du har allerede stemt i afstemningen -poll#:#poll_block_message_no_answers#:#Afstemningen er ufuldstændig -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Kopier afstemning -poll#:#poll_delete_votes#:#Slet alle stemmer -poll#:#poll_delete_votes_sure#:#Er du sikker på du vil slette alle stemmer? -poll#:#poll_edit#:#Ret afstemning -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Billede -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Lav ny afstemning -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Nuværende procent -poll#:#poll_population#:#%s stemmer -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Spørgsmål -poll#:#poll_result#:#Afstemningsresultat -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Sortering -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Vis resultat -poll#:#poll_view_results_after_period#:#Efter stemmeperiode -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#Efter stemmeafgivelse -poll#:#poll_view_results_always#:#Altid -poll#:#poll_view_results_never#:#Aldrig -poll#:#poll_vote#:#Stem -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#Afstemningen kan ikke ændres på grund af de eksisternde stemmer. -poll#:#poll_voting_period_and_results#:#Afstemningsperiode og resultat -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###11 10 2013 new variable -poll#:#poll_voting_period_limited#:#Begrænset prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index b67b2253abbb..f324968d1849 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -13462,75 +13462,6 @@ pd#:#pd_upcoming#:#Bevorstehend pd#:#pd_view_select_at_least_one#:#Bitte wählen Sie mindestens eine Sicht, entweder 'Ausgewählte Angebote' und/oder 'Kurse und Gruppen'. pdesk#:#bookmark_moved_ok#:#Bookmark wurde verschoben. pdesk#:#bookmark_select_target#:#Bitte wählen Sie das Ziel. -poll#:#poll_absolute#:#Aktuelle Stimmen -poll#:#poll_activation_online_info#:#Diese Abstimmung verfügbar machen. -poll#:#poll_add#:#Abstimmung anlegen -poll#:#poll_anonymous_warning#:#Dies ist eine anonyme Abstimmung. Ihre Stimme wird gespeichert, Ihr Name aber nicht in den Abstimmungsergebnissen angezeigt. -poll#:#poll_answer#:#Antwort -poll#:#poll_answer_selected_alt_text#:#Ausgewählt -poll#:#poll_answers#:#Mögliche Antworten -poll#:#poll_barchart#:#Balkendiagramm -poll#:#poll_block_message_already_voted#:#Sie haben bereits abgestimmt. -poll#:#poll_block_message_no_answers#:#Diese Abstimmung ist nicht komplett. -poll#:#poll_block_results_available_on#:#Die Ergebnisse werden %s veröffentlicht. -poll#:#poll_cannot_set_online_no_answers#:#Der Status kann nicht auf "online (aktiviert)" geändert werden, da der Abstimmung noch keine Frage hinzugefügt wurde! -poll#:#poll_chart_votes#:#Stimmen -poll#:#poll_comments#:#Kommentare -poll#:#poll_copy#:#Abstimmung kopieren -poll#:#poll_delete_votes#:#Alle Stimmabgaben löschen -poll#:#poll_delete_votes_sure#:#Sind Sie sicher, dass Sie alle Stimmabgaben löschen möchten? -poll#:#poll_edit#:#Abstimmung bearbeiten -poll#:#poll_edit_question#:#Frage bearbeiten -poll#:#poll_image#:#Bild -poll#:#poll_import#:#Abstimmung importieren -poll#:#poll_limit_not_below_answer_count#:#Die maximale Anzahl der auswählbaren Antworten muss geringer sein als die Gesamtzahl der möglichen Antworten. -poll#:#poll_limit_number_of_answers#:#Anzahl der auswählbaren Antworten begrenzen -poll#:#poll_max_number_of_answers#:#Maximale Anzahl pro Person -poll#:#poll_max_number_of_answers_info#:#Sie dürfen bis zu %s Antworten auswählen. -poll#:#poll_mode#:#Modus -poll#:#poll_mode_anonymous#:#Ohne Namen / Anonym -poll#:#poll_mode_anonymous_info#:#Im Reiter „Abstimmungsergebnisse“ werden keine Namen aufgeführt. Systeminterne IDs der teilnehmenden Personen werden in der Datenbank gespeichert. -poll#:#poll_mode_personal#:#Mit Namen -poll#:#poll_mode_personal_info#:#Im Reiter „Abstimmungsergebnisse“ werden die Namen der teilnehmenden Personen mit ihren Antworten aufgeführt. Personen, die Zugriff auf diesen Reiter haben, können sehen, wer welche Antwort gegeben hat. -poll#:#poll_new#:#Neue Abstimmung anlegen -poll#:#poll_non_anonymous_warning#:#Ihr Name und Votum sind für die Administration in den Ergebnissen sichtbar. -poll#:#poll_notification_activated#:#Benachrichtigungen aktiviert. -poll#:#poll_notification_deactivated#:#Benachrichtigungen deaktiviert. -poll#:#poll_notification_subscribe#:#Benachrichtigungen aktivieren -poll#:#poll_notification_unsubscribe#:#Benachrichtigungen deaktivieren -poll#:#poll_percentage#:#Aktueller Anteil -poll#:#poll_population#:#%s Stimmabgaben -poll#:#poll_population_singular#:#Eine Stimmabgabe -poll#:#poll_question#:#Frage -poll#:#poll_result#:#Abstimmungsergebnisse -poll#:#poll_result_answers#:#Stimmen -poll#:#poll_result_sorting#:#Sortierung -poll#:#poll_result_sorting_answers#:#In der Reihenfolge der Antworten -poll#:#poll_result_sorting_votes#:#Nach Anzahl der Stimmen (absteigend) -poll#:#poll_result_users#:#Pro Person -poll#:#poll_show_results_as#:#Ergebnisse anzeigen als -poll#:#poll_sortorder#:#Sortierung -poll#:#poll_stacked_chart#:#Stapeldiagramm -poll#:#poll_view_results#:#Ergebnisse anzeigen -poll#:#poll_view_results_after_period#:#Nach Stimmperiode -poll#:#poll_view_results_after_period_impossible#:#Damit die Abstimmungsergebnisse erst nach Ende der Stimmperiode gezeigt werden können, müssen Sie oben eine zeitlich begrenzte Stimmperiode einrichten. -poll#:#poll_view_results_after_vote#:#Nach Stimmabgabe -poll#:#poll_view_results_always#:#Immer -poll#:#poll_view_results_never#:#Niemals -poll#:#poll_vote#:#Abstimmen -poll#:#poll_vote_error_multi#:#Wählen Sie maximal %s Antworten aus. -poll#:#poll_vote_error_multi_no_answer#:#Wählen Sie mindestens eine Antwort aus. -poll#:#poll_vote_error_single#:#Wählen Sie eine Antwort aus. -poll#:#poll_vote_notification_body#:#die folgende Abstimmung hat eine Stimme erhalten -poll#:#poll_vote_notification_link#:#Link zur Abstimmung -poll#:#poll_vote_notification_reason#:#Sie erhalten diese Nachricht, da Sie die Benachrichtigungsfunktion für die oben genannte Abstimmung aktiviert haben. -poll#:#poll_vote_notification_subject#:#Abstimmung „%s“ – neue Stimmabgabe -poll#:#poll_votes_no_edit#:#Diese Abstimmung beinhaltet bereits Stimmen. Sie kann nicht bearbeitet werden so lange Sie die Stimmen nicht löschen. -poll#:#poll_voting_period_and_results#:#Stimmperiode und Ergebnisse -poll#:#poll_voting_period_ended_info#:#Das Ende der Abstimmung war %s. -poll#:#poll_voting_period_full_info#:#Die Abstimmung in dieser Umfrage ist vom %s bis zum %s möglich -poll#:#poll_voting_period_info#:#Die Frist für die Abstimmung endet am %s. -poll#:#poll_voting_period_limited#:#Zeitlich begrenzte Stimmperiode prg#:#access_ctr_by_orgu_position#:#Berechtigungen anhand Positionen in Organisationseinheiten prg#:#active_only#:#nur aktive prg#:#add_automembership_source#:#Automatismus hinzufügen diff --git a/lang/ilias_el.lang b/lang/ilias_el.lang index 9a656a99d80e..79b9ea7ebc56 100644 --- a/lang/ilias_el.lang +++ b/lang/ilias_el.lang @@ -13282,75 +13282,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index bffe6cf52243..68e598c6d253 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -13427,74 +13427,6 @@ pd#:#pd_upcoming#:#Upcoming pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either ‘Selected Items’ and/or ‘My Memberships’. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved. pdesk#:#bookmark_select_target#:#Please select target. -poll#:#poll_absolute#:#Current Votes -poll#:#poll_activation_online_info#:#Make this poll accessible to users. -poll#:#poll_add#:#Add Poll -poll#:#poll_anonymous_warning#:#This is an anonymous poll. Individual votes are recorded, but your name will not be shown in the poll results. -poll#:#poll_answer#:#Answer -poll#:#poll_answer_selected_alt_text#:#Selected -poll#:#poll_answers#:#Possible Answers -poll#:#poll_barchart#:#Bar chart -poll#:#poll_block_message_already_voted#:#You have already voted in this poll. -poll#:#poll_block_message_no_answers#:#This poll is incomplete. -poll#:#poll_block_results_available_on#:#The results will be available: %s. -poll#:#poll_cannot_set_online_no_answers#:#This poll cannot be set to "Online" because it doesn’t have a question! -poll#:#poll_chart_votes#:#Votes -poll#:#poll_comments#:#Comments -poll#:#poll_copy#:#Copy Poll -poll#:#poll_delete_votes#:#Delete All Votes Cast -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete the votes of all users who have taken part in this poll? -poll#:#poll_edit#:#Edit Poll -poll#:#poll_edit_question#:#Edit Question -poll#:#poll_image#:#Image -poll#:#poll_limit_not_below_answer_count#:#The maximum number of selectable answer options must be smaller than the total number of possible answers. -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers Selectable -poll#:#poll_max_number_of_answers#:#Maximum Selectable per Participant -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers. -poll#:#poll_mode#:#Mode -poll#:#poll_mode_anonymous#:#Without names / anonymous -poll#:#poll_mode_anonymous_info#:#The names of poll participants are not shown in the 'Results' tab. Please note: participant user IDs are, however, recorded in the database. -poll#:#poll_mode_personal#:#With names -poll#:#poll_mode_personal_info#:#The names of poll participants and their respective answers are listed in the ‘Results’ tab. Users with access to this tab can inspect who has given which answer. -poll#:#poll_new#:#Add New Poll -poll#:#poll_non_anonymous_warning#:#Your name and how you have voted will be visible to anyone with administration permissions for this poll. -poll#:#poll_notification_activated#:#Notifications activated. -poll#:#poll_notification_deactivated#:#Notifications deactivated -poll#:#poll_notification_subscribe#:#Activate Notifications -poll#:#poll_notification_unsubscribe#:#Deactivate Notifications -poll#:#poll_percentage#:#Current Percentage -poll#:#poll_population#:#%s votes cast -poll#:#poll_population_singular#:#One vote cast -poll#:#poll_question#:#Question -poll#:#poll_result#:#Results -poll#:#poll_result_answers#:#Votes -poll#:#poll_result_sorting#:#Presentation Order -poll#:#poll_result_sorting_answers#:#In the same order as the possible answers -poll#:#poll_result_sorting_votes#:#By number of votes (descending) -poll#:#poll_result_users#:#Participants -poll#:#poll_show_results_as#:#Results Format -poll#:#poll_sortorder#:#Order -poll#:#poll_stacked_chart#:#Stacked chart -poll#:#poll_view_results#:#Show Results -poll#:#poll_view_results_after_period#:#When voting period has ended -poll#:#poll_view_results_after_period_impossible#:#In order for the results to be first shown when the voting period has ended, you need to establish a limited voting period by entering dates above. -poll#:#poll_view_results_after_vote#:#After participant has voted -poll#:#poll_view_results_always#:#Always -poll#:#poll_view_results_never#:#Never -poll#:#poll_vote#:#Vote -poll#:#poll_vote_error_multi#:#Please select no more than %s answers. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least one of the answers. -poll#:#poll_vote_error_single#:#Please select one of the answers. -poll#:#poll_vote_notification_body#:#A vote has been cast in the following poll: -poll#:#poll_vote_notification_link#:#Link to Poll -poll#:#poll_vote_notification_reason#:#You have received this e-mail because you activated notifications for the poll mentioned above. -poll#:#poll_vote_notification_subject#:#Poll "%s" – new vote cast -poll#:#poll_votes_no_edit#:#This poll has already received votes. You cannot edit the poll unless you delete these votes. -poll#:#poll_voting_period_and_results#:#Voting Period and Results -poll#:#poll_voting_period_ended_info#:#The voting period ended %s. -poll#:#poll_voting_period_full_info#:#Voting in this poll will be possible from %s until %s -poll#:#poll_voting_period_info#:#The deadline for voting is %s. -poll#:#poll_voting_period_limited#:#Limited Voting Period prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions prg#:#active_only#:#active users only prg#:#add_automembership_source#:#Add Source diff --git a/lang/ilias_es.lang b/lang/ilias_es.lang index 010f60568073..3dcc6365b36b 100644 --- a/lang/ilias_es.lang +++ b/lang/ilias_es.lang @@ -13346,75 +13346,6 @@ pd#:#pd_upcoming#:#Próximamente pd#:#pd_view_select_at_least_one#:#Debe seleccionar al menos una vista, ya sea 'Elementos seleccionados' y/o 'Mis membresías'. pdesk#:#bookmark_moved_ok#:#El marcador ha sido movido. pdesk#:#bookmark_select_target#:#Por favor, seleccione el destino. -poll#:#poll_absolute#:#Votos actuales -poll#:#poll_activation_online_info#:#Active esta configuración para que la encuesta sea accesible a los usuarios. -poll#:#poll_add#:#Añadir encuesta -poll#:#poll_anonymous_warning#:#Los votos individuales se registran, pero su nombre no se mostrará en los resultados de la encuesta. -poll#:#poll_answer#:#Respuesta -poll#:#poll_answer_selected_alt_text#:#Seleccionado -poll#:#poll_answers#:#Respuestas posibles -poll#:#poll_barchart#:#Gráfico de barras -poll#:#poll_block_message_already_voted#:#Ya ha votado en esta encuesta. -poll#:#poll_block_message_no_answers#:#Esta encuesta está incompleta. -poll#:#poll_block_results_available_on#:#Los resultados estarán disponibles el %s. -poll#:#poll_cannot_set_online_no_answers#:#¡El estado no se puede cambiar a "en línea" porque esta encuesta no tiene pregunta! -poll#:#poll_chart_votes#:#Votos -poll#:#poll_comments#:#Comentarios -poll#:#poll_copy#:#Copiar Encuesta -poll#:#poll_delete_votes#:#Eliminar Votos de Todos los Usuarios -poll#:#poll_delete_votes_sure#:#¿Está seguro de que desea eliminar los votos de todos los usuarios que participaron en esta encuesta? -poll#:#poll_edit#:#Editar Encuesta -poll#:#poll_edit_question#:#Editar Pregunta -poll#:#poll_image#:#Imagen -poll#:#poll_import#:#Importar Encuesta -poll#:#poll_limit_not_below_answer_count#:#El límite de respuestas debe ser inferior al número de respuestas posibles. -poll#:#poll_limit_number_of_answers#:#Limitar Número de Respuestas por Participante -poll#:#poll_max_number_of_answers#:#Máx. Respuestas -poll#:#poll_max_number_of_answers_info#:#Puede elegir hasta %s respuestas. -poll#:#poll_mode#:#Modo -poll#:#poll_mode_anonymous#:#Sin Nombres / Anónimo -poll#:#poll_mode_anonymous_info#:#Los nombres de los participantes de la encuesta no se muestran en la pestaña 'Resultados'. Tenga en cuenta: los ID de usuario de los participantes, sin embargo, se registran en la base de datos. -poll#:#poll_mode_personal#:#Con Nombres -poll#:#poll_mode_personal_info#:#En la pestaña 'Resultados' se enumeran los nombres de los participantes y sus respectivas respuestas. Los usuarios con acceso a esta pestaña pueden inspeccionar quién ha dado cada respuesta. -poll#:#poll_new#:#Añadir Nueva Encuesta -poll#:#poll_non_anonymous_warning#:#Su nombre y voto son visibles para los administradores en los resultados. -poll#:#poll_notification_activated#:#Notificación Activada -poll#:#poll_notification_deactivated#:#Notificación Desactivada -poll#:#poll_notification_subscribe#:#Activar Notificación -poll#:#poll_notification_unsubscribe#:#Desactivar Notificación -poll#:#poll_percentage#:#Porcentaje Actual -poll#:#poll_population#:#%s Participantes -poll#:#poll_population_singular#:#1 Participante -poll#:#poll_question#:#Pregunta -poll#:#poll_result#:#Resultados -poll#:#poll_result_answers#:#Votos -poll#:#poll_result_sorting#:#Ordenación -poll#:#poll_result_sorting_answers#:#En el orden de las respuestas -poll#:#poll_result_sorting_votes#:#Por número de votos (descendente) -poll#:#poll_result_users#:#Participantes -poll#:#poll_show_results_as#:#Mostrar resultados como -poll#:#poll_sortorder#:#Orden -poll#:#poll_stacked_chart#:#Gráfico apilado -poll#:#poll_view_results#:#Mostrar resultados -poll#:#poll_view_results_after_period#:#Después del periodo de votación -poll#:#poll_view_results_after_period_impossible#:#El periodo de votación no está limitado. -poll#:#poll_view_results_after_vote#:#Después de votar -poll#:#poll_view_results_always#:#Siempre -poll#:#poll_view_results_never#:#Nunca -poll#:#poll_vote#:#Votar -poll#:#poll_vote_error_multi#:#Por favor, seleccione no más de %s respuestas. -poll#:#poll_vote_error_multi_no_answer#:#Por favor, seleccione al menos 1 respuesta. -poll#:#poll_vote_error_single#:#Por favor, seleccione 1 respuesta. -poll#:#poll_vote_notification_body#:#la siguiente encuesta ha recibido un voto. -poll#:#poll_vote_notification_link#:#Enlace a la encuesta -poll#:#poll_vote_notification_reason#:#Usted recibe este correo electrónico porque activó las notificaciones para la encuesta mencionada anteriormente. -poll#:#poll_vote_notification_subject#:#Encuesta "%s": nuevo voto -poll#:#poll_votes_no_edit#:#Esta encuesta ya contiene votos. No puede editar la encuesta hasta que elimine estos votos. -poll#:#poll_voting_period_and_results#:#Periodo de votación y resultados -poll#:#poll_voting_period_ended_info#:#El periodo de votación terminó el %s. -poll#:#poll_voting_period_full_info#:#Periodo de votación del %s al %s -poll#:#poll_voting_period_info#:#La fecha límite para votar es el %s. -poll#:#poll_voting_period_limited#:#Periodo de votación limitado prg#:#access_ctr_by_orgu_position#:#Control de acceso según los puestos de la unidad organizativa prg#:#active_only#:#solo usuarios activos prg#:#add_automembership_source#:#Añadir fuente diff --git a/lang/ilias_et.lang b/lang/ilias_et.lang index 6a8682a607d5..0b65ca2efee8 100644 --- a/lang/ilias_et.lang +++ b/lang/ilias_et.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#Vali üks vaadetest: kas 'Valitud objektid' või Minu liikmelisus'. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved. pdesk#:#bookmark_select_target#:#Please select target. -poll#:#poll_absolute#:#Current Votes -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll. -poll#:#poll_add#:#Add Poll -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results. -poll#:#poll_answer#:#Answer -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers -poll#:#poll_barchart#:#Bar Chart -poll#:#poll_block_message_already_voted#:#You already voted in this poll. -poll#:#poll_block_message_no_answers#:#This poll is incomplete. -poll#:#poll_block_results_available_on#:#The results will be available on %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments -poll#:#poll_copy#:#Copy Poll -poll#:#poll_delete_votes#:#Delete All Votes -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes? -poll#:#poll_edit#:#Edit Poll -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image -poll#:#poll_import#:#Import Poll -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode -poll#:#poll_mode_anonymous#:#Anonymous -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants. -poll#:#poll_mode_personal#:#Personal -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission. -poll#:#poll_new#:#Add New Poll -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results. -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification -poll#:#poll_notification_unsubscribe#:#Deactivate Notification -poll#:#poll_percentage#:#Current Percentage -poll#:#poll_population#:#%s Votes -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question -poll#:#poll_result#:#Voting Result -poll#:#poll_result_answers#:#Votes -poll#:#poll_result_sorting#:#Sorting -poll#:#poll_result_sorting_answers#:#In the order of the answers -poll#:#poll_result_sorting_votes#:#By number of votes (descending) -poll#:#poll_result_users#:#Participants -poll#:#poll_show_results_as#:#Show Results as -poll#:#poll_sortorder#:#Order -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results -poll#:#poll_view_results_after_period#:#After Voting Period -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited. -poll#:#poll_view_results_after_vote#:#After Vote -poll#:#poll_view_results_always#:#Always -poll#:#poll_view_results_never#:#Never -poll#:#poll_vote#:#Vote -poll#:#poll_vote_error_multi#:#Please select no more than %s answers. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer. -poll#:#poll_vote_notification_body#:#the following poll has received a vote. -poll#:#poll_vote_notification_link#:#Link to Poll -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above. -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes. -poll#:#poll_voting_period_and_results#:#Voting Period and Results -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s -poll#:#poll_voting_period_limited#:#Limited prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_fa.lang b/lang/ilias_fa.lang index f7f529e85bbd..b8678d242d2b 100644 --- a/lang/ilias_fa.lang +++ b/lang/ilias_fa.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_fr.lang b/lang/ilias_fr.lang index 04126a7433f8..b19e4b1477fc 100644 --- a/lang/ilias_fr.lang +++ b/lang/ilias_fr.lang @@ -13266,75 +13266,6 @@ pd#:#pd_upcoming#:#À venir pd#:#pd_view_select_at_least_one#:#Vous devez sélectionner au moins une vue : 'Items sélectionnés' et/ou 'Mes appartenances' pdesk#:#bookmark_moved_ok#:#Le favori a été déplacé. pdesk#:#bookmark_select_target#:#Veuillez sélectionner une destination. -poll#:#poll_absolute#:#Votes Actuels -poll#:#poll_activation_online_info#:#Lorsque hors-ligne, seuls les administrateurs peuvent voir et éditer le vote. -poll#:#poll_add#:#Ajouter Vote -poll#:#poll_anonymous_warning#:#Votre nom n'est pas visible dans les résultats du sondage. -poll#:#poll_answer#:#Réponse -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Réponses Possibles -poll#:#poll_barchart#:#Graphique à barres -poll#:#poll_block_message_already_voted#:#Vous avez déjà voté. -poll#:#poll_block_message_no_answers#:#Ce vote est incomplet. -poll#:#poll_block_results_available_on#:#Les résultats seront disponibles le %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###31 10 2023 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Commentaires -poll#:#poll_copy#:#Copier Vote -poll#:#poll_delete_votes#:#Supprimer Tous les Votes -poll#:#poll_delete_votes_sure#:#Etes-vous sûr de vouloir supprimer tous les votes ? -poll#:#poll_edit#:#Editer Vote -poll#:#poll_edit_question#:#Edit Question###31 10 2023 new variable -poll#:#poll_image#:#Image -poll#:#poll_import#:#Importer Vote -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###31 10 2023 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###31 10 2023 new variable -poll#:#poll_max_number_of_answers#:#Nombre maximum de réponses par participant -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###31 10 2023 new variable -poll#:#poll_mode#:#Mode -poll#:#poll_mode_anonymous#:#Anonyme -poll#:#poll_mode_anonymous_info#:#Les votes ne peuvent pas être attribués aux participants. -poll#:#poll_mode_personal#:#Personnel -poll#:#poll_mode_personal_info#:#Une liste de participants avec leurs votes respectifs est disponible à condition d’avoir suffisamment de permissions. -poll#:#poll_new#:#Ajouter Nouveau Vote -poll#:#poll_non_anonymous_warning#:#Votre nom et le vote sont visibles pour les administrateurs dans les résultats. -poll#:#poll_notification_activated#:#Notification Activated###31 10 2023 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###31 10 2023 new variable -poll#:#poll_notification_subscribe#:#Activer la notification -poll#:#poll_notification_unsubscribe#:#Désactiver la notification -poll#:#poll_percentage#:#Pourcentage Actuel -poll#:#poll_population#:#%s Votes -poll#:#poll_population_singular#:#1 Participant###31 10 2023 new variable -poll#:#poll_question#:#Question -poll#:#poll_result#:#Résultats du Vote -poll#:#poll_result_answers#:#Votes -poll#:#poll_result_sorting#:#Triage -poll#:#poll_result_sorting_answers#:#Dans l’ordre des réponses -poll#:#poll_result_sorting_votes#:#Par nombre de votes (décroissant) -poll#:#poll_result_users#:#Participants -poll#:#poll_show_results_as#:#Montrer les résultats sous forme de -poll#:#poll_sortorder#:#Ordre -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Afficher Résultats -poll#:#poll_view_results_after_period#:#Après Période de Vote -poll#:#poll_view_results_after_period_impossible#:#La période de vote n’a pas de limite. -poll#:#poll_view_results_after_vote#:#Après Vote -poll#:#poll_view_results_always#:#Toujours -poll#:#poll_view_results_never#:#Jamais -poll#:#poll_vote#:#Voter -poll#:#poll_vote_error_multi#:#Veuillez ne pas sélectionnez plus de% réponses. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###31 10 2023 new variable -poll#:#poll_vote_error_single#:#veuillez sélectionner 1 réponse. -poll#:#poll_vote_notification_body#:#le sondage suivant a reçu un vote. -poll#:#poll_vote_notification_link#:#Lien vers sondage -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above. -poll#:#poll_vote_notification_subject#:#Sondage «%s»: nouveau vote -poll#:#poll_votes_no_edit#:#Des votes ont déjà été soumis. Vous ne pouvez pas éditer l'objet à moins de supprimer les votes soumis. -poll#:#poll_voting_period_and_results#:#Période de Vote et Résultats -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###31 10 2023 new variable -poll#:#poll_voting_period_full_info#:#Période de vote de %s à %s -poll#:#poll_voting_period_info#:#Date limite de vote : %s -poll#:#poll_voting_period_limited#:#Limité prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions prg#:#active_only#:#active users only###31 10 2023 new variable prg#:#add_automembership_source#:#Add Source diff --git a/lang/ilias_hr.lang b/lang/ilias_hr.lang index fb5461507599..f8c69b463b87 100644 --- a/lang/ilias_hr.lang +++ b/lang/ilias_hr.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Nadolazeće pd#:#pd_view_select_at_least_one#:#Morate odabrati barem jedan prikaz, ili 'Odabrane stavke' i/ili 'Moja članstva'. pdesk#:#bookmark_moved_ok#:#Oznaka je premještena. pdesk#:#bookmark_select_target#:#Molimo odaberite cilj. -poll#:#poll_absolute#:#Trenutačni glasovi -poll#:#poll_activation_online_info#:#Aktiviraj ovu postavku kako bi glasovanje bilo dostupno korisnicima. -poll#:#poll_add#:#Dodaj glasovanje -poll#:#poll_anonymous_warning#:#Vaše ime nije vidljivo u rezultatima glasovanja. -poll#:#poll_answer#:#Odgovor -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Mogući odgovori -poll#:#poll_barchart#:#Grafikon -poll#:#poll_block_message_already_voted#:#Već ste glasali u ovom glasovanju. -poll#:#poll_block_message_no_answers#:#Ovo glasovanje je nepotpuno. -poll#:#poll_block_results_available_on#:#Rezultati će biti dostupni %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Komentari -poll#:#poll_copy#:#Kopiraj glasovanje -poll#:#poll_delete_votes#:#Izbriši sve glasove -poll#:#poll_delete_votes_sure#:#Jeste li sigurni da želite izbrisati sve glasove? -poll#:#poll_edit#:#Uredi glasovanje -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Slika -poll#:#poll_import#:#Uvezi glasovanje -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maksimalan broj odgovora po sudioniku -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Način rada -poll#:#poll_mode_anonymous#:#Anonimno -poll#:#poll_mode_anonymous_info#:#Glasovi se ne mogu pratiti unatrag do sudionika. -poll#:#poll_mode_personal#:#Osobno -poll#:#poll_mode_personal_info#:#Popis sudionika s njihovim glasovima dostupan je kad postoji odgovarajuće odobrenje. -poll#:#poll_new#:#Dodaj novo glasovanje -poll#:#poll_non_anonymous_warning#:#Vaše ime i glas vidljivi su za administratore u rezultatima. -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Aktiviraj obavijest -poll#:#poll_notification_unsubscribe#:#Deaktiviraj obavijest -poll#:#poll_percentage#:#Trenutni postotak -poll#:#poll_population#:#%s sudionika -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Pitanje -poll#:#poll_result#:#Rezultati -poll#:#poll_result_answers#:#Glasovi -poll#:#poll_result_sorting#:#Sortiranje -poll#:#poll_result_sorting_answers#:#Redoslijedom odgovora -poll#:#poll_result_sorting_votes#:#Po broju glasova (silazno) -poll#:#poll_result_users#:#Sudionici -poll#:#poll_show_results_as#:#Prikaži rezultate kao -poll#:#poll_sortorder#:#Narudžba -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Prikaži rezultate -poll#:#poll_view_results_after_period#:#Nakon razdoblja glasanja -poll#:#poll_view_results_after_period_impossible#:#Razdoblje glasanja nije ograničeno. -poll#:#poll_view_results_after_vote#:#Nakon glasanja -poll#:#poll_view_results_always#:#Uvijek -poll#:#poll_view_results_never#:#Nikada -poll#:#poll_vote#:#Glasanje -poll#:#poll_vote_error_multi#:#Molimo odaberite najviše %s odgovora. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Odaberite 1 odgovor. -poll#:#poll_vote_notification_body#:#sljedeće glasovanje je dobilo jedan glas. -poll#:#poll_vote_notification_link#:#Poveznica na glasovanje -poll#:#poll_vote_notification_reason#:#Ovu e-poštu primate jer ste aktivirali obavijesti za gore spomenuto glasovanje. -poll#:#poll_vote_notification_subject#:#Glasovanje "%s": novi glas -poll#:#poll_votes_no_edit#:#Ovo glasovanje već sadrži glasove. Ne možete uređivati glasovanje dok ne izbrišete ove glasove. -poll#:#poll_voting_period_and_results#:#Razdoblje glasanja i rezultati -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Razdoblje glasanja od %s do %s -poll#:#poll_voting_period_info#:#Rok za glasanje: %s -poll#:#poll_voting_period_limited#:#Ograničeno razdoblje glasanja prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###04 06 2021 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###04 06 2021 new variable diff --git a/lang/ilias_hu.lang b/lang/ilias_hu.lang index 82cb21a39a62..d2d8b6d37830 100644 --- a/lang/ilias_hu.lang +++ b/lang/ilias_hu.lang @@ -13360,75 +13360,6 @@ pd#:#pd_upcoming#:#Jövőbeli pd#:#pd_view_select_at_least_one#:#Legalább egy nézetet ki kell választania vagy a ‘Kigyűjtött objektumok’ és/vagy a ‘Tagságaim’ közül. pdesk#:#bookmark_moved_ok#:#A könyvjelző áthelyeződött. pdesk#:#bookmark_select_target#:#Válasszon célt -poll#:#poll_absolute#:#Szavazatok száma -poll#:#poll_activation_online_info#:#Kapcsolja be, hogy a szavazást elérhessék a felhasználók. -poll#:#poll_add#:#Szavazás létrehozása -poll#:#poll_anonymous_warning#:#Ez egy névtelen szavazás. A szavazatát rögzítjük, de a neve nem fog megjelenni a szavazás eredményében. -poll#:#poll_answer#:#Válasz -poll#:#poll_answer_selected_alt_text#:#Kiválasztott -poll#:#poll_answers#:#Lehetséges válaszok -poll#:#poll_barchart#:#Oszlopdiagram -poll#:#poll_block_message_already_voted#:#Már szavazott ebben a szavazásban. -poll#:#poll_block_message_no_answers#:#Ez a szavazás még nem készült el teljesen. -poll#:#poll_block_results_available_on#:#Az eredmény elérhető ettől: %s. -poll#:#poll_cannot_set_online_no_answers#:#Az állapot nem módosítható "online"-ra, mert egy kérdés sincs még a szavazásban! -poll#:#poll_chart_votes#:#Szavazatok -poll#:#poll_comments#:#Nyilvános megjegyzések -poll#:#poll_copy#:#Szavazás másolása -poll#:#poll_delete_votes#:#Összes leadott szavazat törlése -poll#:#poll_delete_votes_sure#:#Biztos, hogy törli az összes szavazatot? -poll#:#poll_edit#:#Szavazás módosítása -poll#:#poll_edit_question#:#Kérdés módosítása -poll#:#poll_image#:#Kép -poll#:#poll_import#:#Szavazás importálása -poll#:#poll_limit_not_below_answer_count#:#A válaszok megengedett maximális száma kevesebb kell, hogy legyen, mint a válaszlehetősége száma. -poll#:#poll_limit_number_of_answers#:#Résztvevőkénti válaszok számának korlátozása -poll#:#poll_max_number_of_answers#:#Részvevőnkénti válaszok maximális száma -poll#:#poll_max_number_of_answers_info#:#Maximálisan %s választ adhat. -poll#:#poll_mode#:#Mód -poll#:#poll_mode_anonymous#:#Névtelen -poll#:#poll_mode_anonymous_info#:#A szavazás résztvevőinek neve nem jelenik meg az ‘Eredmények’ lapon. Kérjük, vegye figyelembe: a résztvevők felhasználói azonosítóit azonban rögzítjük az adatbázisban. -poll#:#poll_mode_personal#:#Nevesített -poll#:#poll_mode_personal_info#:#A szavazás résztvevőinek nevei és válaszai az ‘Eredmények’ lapon szerepelnek. Az ehhez a laphoz hozzáféréssel rendelkező felhasználók ellenőrizhetik, hogy ki melyik választ adta. -poll#:#poll_new#:#Új szavazás létrehozása -poll#:#poll_non_anonymous_warning#:#A szavazás szerkesztői megtekintheti a szavazók neveit. -poll#:#poll_notification_activated#:#Értesítések bekapcsolva -poll#:#poll_notification_deactivated#:#Értesítések kikapcsolva -poll#:#poll_notification_subscribe#:#Értesítés bekapcsolása -poll#:#poll_notification_unsubscribe#:#Értesítés kikapcsolása -poll#:#poll_percentage#:#Szavazatok aránya -poll#:#poll_population#:#%s leadott szavazat -poll#:#poll_population_singular#:#1 leadott szavazat -poll#:#poll_question#:#Kérdés -poll#:#poll_result#:#Végeredmények -poll#:#poll_result_answers#:#Szavazatok -poll#:#poll_result_sorting#:#Rendezés -poll#:#poll_result_sorting_answers#:#Lehetséges válaszok sorrendjében -poll#:#poll_result_sorting_votes#:#Szavazatok száma szerint ↓ -poll#:#poll_result_users#:#Résztvevők -poll#:#poll_show_results_as#:#Eredmények formátuma -poll#:#poll_sortorder#:#Sorrend -poll#:#poll_stacked_chart#:#Halmozott diagram -poll#:#poll_view_results#:#Eredmények megjelenítése -poll#:#poll_view_results_after_period#:#Szavazási időszak után -poll#:#poll_view_results_after_period_impossible#:#Ahhoz, hogy az eredmények csak a szavazási időszak végén jelenjenek meg, a fenti dátumok megadásával a szavazási időszakot korlátoznia kell. -poll#:#poll_view_results_after_vote#:#Szavazás után -poll#:#poll_view_results_always#:#Mindig -poll#:#poll_view_results_never#:#Soha -poll#:#poll_vote#:#Szavazás -poll#:#poll_vote_error_multi#:#Ne válasszon többet, mint %s. -poll#:#poll_vote_error_multi_no_answer#:#Legalább 1 választ jelöljön meg. -poll#:#poll_vote_error_single#:#Egyet válassz. -poll#:#poll_vote_notification_body#:#ezúton tájékoztatjuk, hogy új szavazat érkezett. -poll#:#poll_vote_notification_link#:#Link a Szavazásra -poll#:#poll_vote_notification_reason#:#Ezt a levelet azért kapta, mert fent említett szavazásnál beállította, hogy kér értesítést. -poll#:#poll_vote_notification_subject#:#‘%s’ szavazás: új szavazat -poll#:#poll_votes_no_edit#:#Ez a szavazás már tartalmaz szavazatokat. A szavazás nem módosítható, míg nem törli a szavazatokat. -poll#:#poll_voting_period_and_results#:#Szavazási időszak és eredmények -poll#:#poll_voting_period_ended_info#:#A szavazási időszak véget ért %s. -poll#:#poll_voting_period_full_info#:#Szavazási időszak: %s → %s -poll#:#poll_voting_period_info#:#Szavazás határideje: %s -poll#:#poll_voting_period_limited#:#Korlátozott prg#:#access_ctr_by_orgu_position#:#Hozzáférés-kezelés szervezeti egység pozíciói alapján prg#:#active_only#:#csak aktív felhasználók prg#:#add_automembership_source#:#Forrása hozzáadása diff --git a/lang/ilias_it.lang b/lang/ilias_it.lang index 52c4879f0381..88cd75049758 100644 --- a/lang/ilias_it.lang +++ b/lang/ilias_it.lang @@ -13287,75 +13287,6 @@ pd#:#pd_upcoming#:#Prossimo pd#:#pd_view_select_at_least_one#:#Devi selezionare almeno una vista: "Oggetti Selezionati" e/o "La Mia Iscrizione" pdesk#:#bookmark_moved_ok#:#Il segnalibro è stato spostato. pdesk#:#bookmark_select_target#:#Seleziona un target per favore. -poll#:#poll_absolute#:#Voti correnti -poll#:#poll_activation_online_info#:#Se selezionato rendera' possibile agli utenti al sondaggio -poll#:#poll_add#:#Aggiungi sondaggio -poll#:#poll_anonymous_warning#:#Il tuo nome non è visibile nel gruppo di risultati. -poll#:#poll_answer#:#Risposta -poll#:#poll_answer_selected_alt_text#:#Selected###17 01 2024 new variable -poll#:#poll_answers#:#Risposte possibili -poll#:#poll_barchart#:#Diagramma a barre -poll#:#poll_block_message_already_voted#:#Hai già votato in questo sondaggio -poll#:#poll_block_message_no_answers#:#Questo sondaggio non è completo -poll#:#poll_block_results_available_on#:#I risultati saranno disponibili il %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###30 04 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 11 2025 new variable -poll#:#poll_comments#:#Commenti -poll#:#poll_copy#:#Copia sondaggio -poll#:#poll_delete_votes#:#Cancella tutte le votazioni -poll#:#poll_delete_votes_sure#:#Sei sicureo di voler cancellare tutte le votazioni? -poll#:#poll_edit#:#Modifica sondaggio -poll#:#poll_edit_question#:#Edit Question###30 04 2024 new variable -poll#:#poll_image#:#Immagine -poll#:#poll_import#:#Importa gruppo -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###30 04 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###30 04 2024 new variable -poll#:#poll_max_number_of_answers#:#Numero massimo di risposte per partecipante -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###30 04 2024 new variable -poll#:#poll_mode#:#Modalità -poll#:#poll_mode_anonymous#:#Anonimo -poll#:#poll_mode_anonymous_info#:#I voti non possono essere ricondotti ai partecipanti. -poll#:#poll_mode_personal#:#Personale -poll#:#poll_mode_personal_info#:#Un elenco dei partecipanti con i rispettivi voti è disponibile quando si dispone di un permesso sufficiente. -poll#:#poll_new#:#Aggiungi nuovo sondaggio -poll#:#poll_non_anonymous_warning#:#Il tuo nome e il tuo voto sono visibili agli amministratori nei risultati. -poll#:#poll_notification_activated#:#Notification Activated###30 04 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###30 04 2024 new variable -poll#:#poll_notification_subscribe#:#Attiva notifica -poll#:#poll_notification_unsubscribe#:#Disattiva notifica -poll#:#poll_percentage#:#Percentuale corrente -poll#:#poll_population#:#%s partecipanti -poll#:#poll_population_singular#:#1 Participant###30 04 2024 new variable -poll#:#poll_question#:#Domanda -poll#:#poll_result#:#Risultati della votazione -poll#:#poll_result_answers#:#Voti -poll#:#poll_result_sorting#:#Ordinamento -poll#:#poll_result_sorting_answers#:#Nell’ordine delle risposte -poll#:#poll_result_sorting_votes#:#Per numero di voti (decrescente) -poll#:#poll_result_users#:#Partecipanti -poll#:#poll_show_results_as#:#Mostra risultati come -poll#:#poll_sortorder#:#Ordinamento -poll#:#poll_stacked_chart#:#Stacked Chart###28 11 2025 new variable -poll#:#poll_view_results#:#Mostra i rislutati -poll#:#poll_view_results_after_period#:#Periodo post voto -poll#:#poll_view_results_after_period_impossible#:#Il periodo di votazione non è limitato. -poll#:#poll_view_results_after_vote#:#Dopo voto -poll#:#poll_view_results_always#:#Sempre -poll#:#poll_view_results_never#:#Mai -poll#:#poll_vote#:#Vota -poll#:#poll_vote_error_multi#:#Si preda di non selezionare più di %s risposte. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###30 04 2024 new variable -poll#:#poll_vote_error_single#:#Si prega di selezionare 1 risposta. -poll#:#poll_vote_notification_body#:#il seguente sondaggio ha ricevuto un voto. -poll#:#poll_vote_notification_link#:#Collega al sondaggio -poll#:#poll_vote_notification_reason#:#Stai ricevendo questa e-mail perché hai attivato le notifiche per il sondaggio sopra menzionato. -poll#:#poll_vote_notification_subject#:#Sondaggio "%s": nuovo voto -poll#:#poll_votes_no_edit#:#Questo sondaggio contiene già delle voci. Non potra' essere modificato finche' le voci non saranno cancellate. -poll#:#poll_voting_period_and_results#:#Periodo di voto e risultati -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###30 04 2024 new variable -poll#:#poll_voting_period_full_info#:#Periodo di votazione da %s a %s -poll#:#poll_voting_period_info#:#Scadenza per la votazione: %s -poll#:#poll_voting_period_limited#:#Limitato prg#:#access_ctr_by_orgu_position#:#Controllo dell'accesso secondo le posizioni dell'unità organizzativa prg#:#active_only#:#active users only###31 03 2023 new variable prg#:#add_automembership_source#:#Aggiungi fonte diff --git a/lang/ilias_ja.lang b/lang/ilias_ja.lang index 43bdb9ac4cad..436136590ae7 100644 --- a/lang/ilias_ja.lang +++ b/lang/ilias_ja.lang @@ -13360,74 +13360,6 @@ pd#:#pd_upcoming#:#近日中 pd#:#pd_view_select_at_least_one#:# '選択したアイテム' か'マイメンバーシップ'のどちらかを最低一つ選択しなければなりません。 pdesk#:#bookmark_moved_ok#:#ブックマークは移動されました。 pdesk#:#bookmark_select_target#:#目標を選択してください。 -poll#:#poll_absolute#:#現在の投票 -poll#:#poll_activation_online_info#:#オフラインの場合は管理者のみしか投票の閲覧と編集はできません。 -poll#:#poll_add#:#投票を追加 -poll#:#poll_anonymous_warning#:#投票結果の中にあなたの名前はありません。 -poll#:#poll_answer#:#回答 -poll#:#poll_answer_selected_alt_text#:#選択済 -poll#:#poll_answers#:#予想回答 -poll#:#poll_barchart#:#棒グラフ -poll#:#poll_block_message_already_voted#:#この投票にすでに投票済です。 -poll#:#poll_block_message_no_answers#:#投票は完了しました。 -poll#:#poll_block_results_available_on#:#結果は%sに判明します。 -poll#:#poll_cannot_set_online_no_answers#:#この問題集には問題がないため、ステータスを "online" へ変更できません。 -poll#:#poll_chart_votes#:#投票 -poll#:#poll_comments#:#コメント -poll#:#poll_copy#:#投票をコピー -poll#:#poll_delete_votes#:#全投票を削除 -poll#:#poll_delete_votes_sure#:#本当に全ての投票を削除しますか? -poll#:#poll_edit#:#投票を編集 -poll#:#poll_edit_question#:#問題編集を編集 -poll#:#poll_image#:#イメージ -poll#:#poll_limit_not_below_answer_count#:#回答期限は以下の回答可能な数字である必要があります。 -poll#:#poll_limit_number_of_answers#:#受験者当たりの回答制限回数 -poll#:#poll_max_number_of_answers#:#最大回答数 -poll#:#poll_max_number_of_answers_info#:#回答数は %s まで選択できます。 -poll#:#poll_mode#:#モード -poll#:#poll_mode_anonymous#:#匿名 -poll#:#poll_mode_anonymous_info#:#投票は参加者を追跡調査できません。 -poll#:#poll_mode_personal#:#パーソナル -poll#:#poll_mode_personal_info#:#適切なアクセス権があればそれぞれの投票付き参加者リストは利用可能です。 -poll#:#poll_new#:#新しい投票を追加 -poll#:#poll_non_anonymous_warning#:#結果の中にあるあなたの名前と投票は管理者へ表示されます。 -poll#:#poll_notification_activated#:#通知を有効にしました。 -poll#:#poll_notification_deactivated#:#通知を無効にしました。 -poll#:#poll_notification_subscribe#:#通知を有効化 -poll#:#poll_notification_unsubscribe#:#通知を無効化 -poll#:#poll_percentage#:#現在のパーセント -poll#:#poll_population#:#%s 投票者 -poll#:#poll_population_singular#:#1 受験者 -poll#:#poll_question#:#質問 -poll#:#poll_result#:#結果 -poll#:#poll_result_answers#:#投票 -poll#:#poll_result_sorting#:#ソート -poll#:#poll_result_sorting_answers#:#回答順 -poll#:#poll_result_sorting_votes#:#投票数順(降順) -poll#:#poll_result_users#:#参加者 -poll#:#poll_show_results_as#:#結果表示 -poll#:#poll_sortorder#:#順 -poll#:#poll_stacked_chart#:#積み上げグラフ -poll#:#poll_view_results#:#結果表示 -poll#:#poll_view_results_after_period#:#投票期間後 -poll#:#poll_view_results_after_period_impossible#:#投票期間の期限はありません。 -poll#:#poll_view_results_after_vote#:#投票後 -poll#:#poll_view_results_always#:#常時 -poll#:#poll_view_results_never#:#無 -poll#:#poll_vote#:#投票 -poll#:#poll_vote_error_multi#:#回答数%s以上を選択してください。 -poll#:#poll_vote_error_multi_no_answer#:#回答を一つは選択してください。 -poll#:#poll_vote_error_single#:#回答を一つ選択してください。 -poll#:#poll_vote_notification_body#:#次の投票結果が投票から得られました。 -poll#:#poll_vote_notification_link#:#投票結果へリンク -poll#:#poll_vote_notification_reason#:#上記の投票結果説明の通知機能を有効にしているためこのメールを受信します。 -poll#:#poll_vote_notification_subject#:#投票"%s": 新規投票 -poll#:#poll_votes_no_edit#:#この投票は投票中のため編集できません。 -poll#:#poll_voting_period_and_results#:#投票期間と結果 -poll#:#poll_voting_period_ended_info#:#投票期間は %s に終了しました。 -poll#:#poll_voting_period_full_info#:#%sから%sの投票期間 -poll#:#poll_voting_period_info#:#投票期限: %s -poll#:#poll_voting_period_limited#:#期限限定 prg#:#access_ctr_by_orgu_position#:#Organisational Unit Positionに準じたアクセスコントロール prg#:#active_only#:#アクティブユーザ限定 prg#:#add_automembership_source#:#ソースを追加 diff --git a/lang/ilias_ka.lang b/lang/ilias_ka.lang index 0965e7355714..083d40f89852 100644 --- a/lang/ilias_ka.lang +++ b/lang/ilias_ka.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#თქვენ უნდა მონიშნოთ სულ მცირე ერთი ხედვა, ან "მინიშნული ნივთები" ან "ჩემი წევრობა" pdesk#:#bookmark_moved_ok#:#Bookmark has been moved. pdesk#:#bookmark_select_target#:#Please select target. -poll#:#poll_absolute#:#Current Votes -poll#:#poll_activation_online_info#:#Activate this setting to make the poll accessible to users. -poll#:#poll_add#:#Add Poll -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll. -poll#:#poll_block_message_no_answers#:#This poll is incomplete. -poll#:#poll_block_results_available_on#:#The results will be available on %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll -poll#:#poll_delete_votes#:#Delete All Votes -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes? -poll#:#poll_edit#:#Edit Poll -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image -poll#:#poll_import#:#Import Poll -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage -poll#:#poll_population#:#%s Votes -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question -poll#:#poll_result#:#Voting Result -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results -poll#:#poll_view_results_after_period#:#After Voting Period -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote -poll#:#poll_view_results_always#:#Always -poll#:#poll_view_results_never#:#Never -poll#:#poll_vote#:#Vote -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll already contains votes. You cannot edit the poll until you delete these votes. -poll#:#poll_voting_period_and_results#:#Voting Period and Results -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_lt.lang b/lang/ilias_lt.lang index 50c01a43aa0a..03c04ddc74e3 100644 --- a/lang/ilias_lt.lang +++ b/lang/ilias_lt.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'.###15 09 2008 new variable pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_nl.lang b/lang/ilias_nl.lang index 6117a4d6d470..704399676be2 100644 --- a/lang/ilias_nl.lang +++ b/lang/ilias_nl.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved. pdesk#:#bookmark_select_target#:#Selecteer doel -poll#:#poll_absolute#:#Huidige stemmen -poll#:#poll_activation_online_info#:#Indien deze instelling actief is, is de poll beschikbaar voor alle gebruikers. -poll#:#poll_add#:#Toevoegen poll -poll#:#poll_anonymous_warning#:#Je naam is niet zichtbaar is de poll resultaten. -poll#:#poll_answer#:#Stem -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Mogelijke stemmen -poll#:#poll_barchart#:#Bar Chart -poll#:#poll_block_message_already_voted#:#Je hebt al gestemd in deze poll. -poll#:#poll_block_message_no_answers#:#De poll is niet volledig. -poll#:#poll_block_results_available_on#:#De resultaten zullen beschikbaar zijn op %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Commentaar -poll#:#poll_copy#:#kopiëren Poll -poll#:#poll_delete_votes#:#Verwijder alle stemmen -poll#:#poll_delete_votes_sure#:#Weet je zeker dat je alle stemmen wilt verwijderen? -poll#:#poll_edit#:#Wijzigen Poll -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Afbeelding -poll#:#poll_import#:#Importeren Poll -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximaal aantal antwoorden per deelnemer -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Modus -poll#:#poll_mode_anonymous#:#Anoniem -poll#:#poll_mode_anonymous_info#:#Stemmen kunnen niet worden herleid naar deelnemers -poll#:#poll_mode_personal#:#Persoonlijk -poll#:#poll_mode_personal_info#:#Een lijst met deelnemers en hun stemmen is beschikbaar als je voldoende rechten hebt -poll#:#poll_new#:#Toevoegen nieuwe poll -poll#:#poll_non_anonymous_warning#:#Je naam en stem zijn zichtbaar voor beheerders -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Inschakelen berichtgeving -poll#:#poll_notification_unsubscribe#:#Uitschakelen berichtgeving -poll#:#poll_percentage#:#Huidig Percentage -poll#:#poll_population#:#%s Stemmen -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Vraag -poll#:#poll_result#:#Stemresultaten -poll#:#poll_result_answers#:#Stemmen -poll#:#poll_result_sorting#:#Volgorde -poll#:#poll_result_sorting_answers#:#In de volgorde van de antwoorden -poll#:#poll_result_sorting_votes#:#Per aantal stemmen (aflopend) -poll#:#poll_result_users#:#Deelnemers -poll#:#poll_show_results_as#:#Toon resultaten als -poll#:#poll_sortorder#:#Volgorde -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Tonen resultaten -poll#:#poll_view_results_after_period#:#Na stemperiode -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###06 02 2015 new variable -poll#:#poll_view_results_after_vote#:#Na het stemmen -poll#:#poll_view_results_always#:#Altijd -poll#:#poll_view_results_never#:#Nooit -poll#:#poll_vote#:#Stemmen -poll#:#poll_vote_error_multi#:#Niet meer dan %s antwoorden selecteren -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Selecteer één antwoord -poll#:#poll_vote_notification_body#:#Er is een stem uitgebracht voor de volgende pool. -poll#:#poll_vote_notification_link#:#Koppeling naar poll -poll#:#poll_vote_notification_reason#:#Je ontvangt deze e-mail omdat je berichtgeving voor de poll hierboven hebt ingeschakeld. -poll#:#poll_vote_notification_subject#:#Poll "%s": nieuwe stem -poll#:#poll_votes_no_edit#:#Deze poll kan niet worden verwijderd vanwege bestaande stemmen. -poll#:#poll_voting_period_and_results#:#Stemperiode and resultaten -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline om te stemmen: %s -poll#:#poll_voting_period_limited#:#Beperkt prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_pl.lang b/lang/ilias_pl.lang index 05e9bae4ea03..20ac2baecdd6 100644 --- a/lang/ilias_pl.lang +++ b/lang/ilias_pl.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Planowane pd#:#pd_view_select_at_least_one#:#Musisz wybrać co najmniej jeden widok, albo 'Wybrane elementy' i/lub 'Moje członkostwo'. pdesk#:#bookmark_moved_ok#:#Przesunięto zakładkę. pdesk#:#bookmark_select_target#:#Wybierz element docelowy. -poll#:#poll_absolute#:#Aktualne głosy -poll#:#poll_activation_online_info#:#Wybierz to ustawienie, aby udostępnić głosowanie użytkownikom. -poll#:#poll_add#:#Utwórz głosowanie -poll#:#poll_anonymous_warning#:#Twoja nazwa nie jest wyświetlana w wynikach głosowania. -poll#:#poll_answer#:#Odpowiedź -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Możliwe odpowiedzi -poll#:#poll_barchart#:#Wykres słupkowy -poll#:#poll_block_message_already_voted#:#Już zagłosowałeś. -poll#:#poll_block_message_no_answers#:#To głosowanie nie jest kompletne. -poll#:#poll_block_results_available_on#:#Wyniki opublikowano w dniu %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Komentarze -poll#:#poll_copy#:#Kopiuj głosowanie -poll#:#poll_delete_votes#:#Usuń wszystkie głosy -poll#:#poll_delete_votes_sure#:#Jesteś pewien, że chcesz usunąć wszystkie głosy? -poll#:#poll_edit#:#Edytuj głosowanie -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Obraz -poll#:#poll_import#:#Importuj głosowanie -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maksymalna liczba odpowiedzi na każdego uczestnika -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Tryb -poll#:#poll_mode_anonymous#:#Anonim -poll#:#poll_mode_anonymous_info#:#Oddanych głosów nie można przydzielić do uczestników. -poll#:#poll_mode_personal#:#Spersonalizowane -poll#:#poll_mode_personal_info#:#Lista uczestników, którzy oddali głosy, dostępna jest w wystarczającymi uprawnieniami. -poll#:#poll_new#:#Utwórz nowe głosowanie -poll#:#poll_non_anonymous_warning#:#Twoja nazwa i oddany głos widoczne są w wynikach głosowania dla administratorów. -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Aktywowanie powiadamiania -poll#:#poll_notification_unsubscribe#:#Deaktywowanie powiadamiania -poll#:#poll_percentage#:#Aktualna zawartość -poll#:#poll_population#:#%s uczestników -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Pytanie -poll#:#poll_result#:#Wyniki głosowania -poll#:#poll_result_answers#:#Głosy -poll#:#poll_result_sorting#:#Sortowanie -poll#:#poll_result_sorting_answers#:#W kolejności odpowiedzi -poll#:#poll_result_sorting_votes#:#Wg liczby głosów (malejąco) -poll#:#poll_result_users#:#Uczestnicy -poll#:#poll_show_results_as#:#Pokaż wyniki jako -poll#:#poll_sortorder#:#Sortowanie -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Pokaż wyniki -poll#:#poll_view_results_after_period#:#Po okresie głosowania -poll#:#poll_view_results_after_period_impossible#:#Okres głosowania nie jest ograniczony czasowo. -poll#:#poll_view_results_after_vote#:#Po oddaniu głosu -poll#:#poll_view_results_always#:#Zawsze -poll#:#poll_view_results_never#:#Nigdy -poll#:#poll_vote#:#Głosuj -poll#:#poll_vote_error_multi#:#Wybierz maksymalnie %s odpowiedzi. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Wybierz odpowiedź. -poll#:#poll_vote_notification_body#:#w poniższym głosowaniu oddano jeden głos -poll#:#poll_vote_notification_link#:#Link do głosowania -poll#:#poll_vote_notification_reason#:#Otrzymałeś tę wiadomość, ponieważ aktywowałeś funkcję powiadamiania dla powyżej wymienionego głosowania. -poll#:#poll_vote_notification_subject#:#Głosowanie "%s": nowy głos -poll#:#poll_votes_no_edit#:#To głosowanie zawiera już głosy. Nie można go edytować do momentu usunięcia głosów. -poll#:#poll_voting_period_and_results#:#Okres głosowania i wyniki -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Głosowanie od %s do %s -poll#:#poll_voting_period_info#:#Koniec głosowania: %s -poll#:#poll_voting_period_limited#:#Czasowo ograniczony okres głosowania prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_pt.lang b/lang/ilias_pt.lang index 7508d4455bba..058becb904c2 100644 --- a/lang/ilias_pt.lang +++ b/lang/ilias_pt.lang @@ -13392,74 +13392,6 @@ pd#:#pd_upcoming#:#Futuros pd#:#pd_view_select_at_least_one#:#Este módulo de aprendizagem já foi exportado. Abra-o com o computador onde instalou o Firefox com o addon do leitor offline SCORM. pdesk#:#bookmark_moved_ok#:#Os favoritos foram movidos. pdesk#:#bookmark_select_target#:#Selecione destino. -poll#:#poll_absolute#:#Votos atuais -poll#:#poll_activation_online_info#:#A transferir dados de rastreio para ILIAS. Aguarde um momento. -poll#:#poll_add#:#Adicionar sondagem -poll#:#poll_anonymous_warning#:#O seu nome não está visível nos resultados da sondagem. -poll#:#poll_answer#:#Resposta -poll#:#poll_answer_selected_alt_text#:#Selected###03 09 2026 new variable -poll#:#poll_answers#:#Possíveis respostas -poll#:#poll_barchart#:#Gráfico de barras -poll#:#poll_block_message_already_voted#:#Já votou nesta sondagem. -poll#:#poll_block_message_no_answers#:#Esta sondagem está incompleta. -poll#:#poll_block_results_available_on#:#Os resultados estão disponíveis em %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###03 09 2026 new variable -poll#:#poll_chart_votes#:#Votes###03 09 2026 new variable -poll#:#poll_comments#:#Comentários -poll#:#poll_copy#:#Copiar sondagem -poll#:#poll_delete_votes#:#Apagar todos os votos -poll#:#poll_delete_votes_sure#:#Tem a certeza que quer apagar todos os votos? -poll#:#poll_edit#:#Editar sondagem -poll#:#poll_edit_question#:#Edit Question###03 09 2026 new variable -poll#:#poll_image#:#Imagem -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###03 09 2026 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###03 09 2026 new variable -poll#:#poll_max_number_of_answers#:#Número máximo de respostas por participante -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###03 09 2026 new variable -poll#:#poll_mode#:#Modo -poll#:#poll_mode_anonymous#:#Anónimo -poll#:#poll_mode_anonymous_info#:#Os votos não podem ser associados aos participantes. -poll#:#poll_mode_personal#:#Pessoal -poll#:#poll_mode_personal_info#:#Uma lista de participantes com os respetivos votos está disponível se tiver autorização suficiente. -poll#:#poll_new#:#Adicionar nova sondagem -poll#:#poll_non_anonymous_warning#:#O seu nome e voto estão visíveis aos administradores nos resultados. -poll#:#poll_notification_activated#:#Notification Activated###03 09 2026 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###03 09 2026 new variable -poll#:#poll_notification_subscribe#:#Ativar notificação -poll#:#poll_notification_unsubscribe#:#Desativar notificação -poll#:#poll_percentage#:#Percentagem atual -poll#:#poll_population#:#A transferência dados de rastreio foi bem-sucedida. -poll#:#poll_population_singular#:#1 Participant###03 09 2026 new variable -poll#:#poll_question#:#Pergunta -poll#:#poll_result#:#Importar dados de rastreio -poll#:#poll_result_answers#:#Votos -poll#:#poll_result_sorting#:#Ordenar -poll#:#poll_result_sorting_answers#:#Pela ordem das respostas -poll#:#poll_result_sorting_votes#:#Por número de votos (descendente) -poll#:#poll_result_users#:#Participantes -poll#:#poll_show_results_as#:#Mostrar resultados como -poll#:#poll_sortorder#:#Ordem -poll#:#poll_stacked_chart#:#Stacked Chart###03 09 2026 new variable -poll#:#poll_view_results#:#Exibir resultados -poll#:#poll_view_results_after_period#:#Exportar módulos de aprendizagem SCORM para uso offline -poll#:#poll_view_results_after_period_impossible#:#O período de votação não está limitado. -poll#:#poll_view_results_after_vote#:#Após o voto -poll#:#poll_view_results_always#:#Sempre -poll#:#poll_view_results_never#:#Nunca -poll#:#poll_vote#:#Votar -poll#:#poll_vote_error_multi#:#Não selecione mais do que %s respostas. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###03 09 2026 new variable -poll#:#poll_vote_error_single#:#Selecione 1 resposta. -poll#:#poll_vote_notification_body#:#a seguinte sondagem recebeu um voto. -poll#:#poll_vote_notification_link#:#Link para sondagem -poll#:#poll_vote_notification_reason#:#As variáveis less foram atualizadas. Importante: Note que o caching pode impedir que as suas alterações sejam exibidas. Pode precisar de limpar o cache do seu navegador. -poll#:#poll_vote_notification_subject#:#Sondagem "%s": novo voto -poll#:#poll_votes_no_edit#:#Iniciar gestor offline SCORM -poll#:#poll_voting_period_and_results#:#Período de votação e resultados -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###03 09 2026 new variable -poll#:#poll_voting_period_full_info#:#Período de votação de %s até %s -poll#:#poll_voting_period_info#:#Prazo para votar: %s -poll#:#poll_voting_period_limited#:#Todos os estilos selecionados são obrigatórios e não podem ser apagados. prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###03 09 2026 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_ro.lang b/lang/ilias_ro.lang index ab1308c849ec..92d76aa3e417 100644 --- a/lang/ilias_ro.lang +++ b/lang/ilias_ro.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'.###15 09 2008 new variable pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_ru.lang b/lang/ilias_ru.lang index 8d10b387ec7a..58480ec129ab 100644 --- a/lang/ilias_ru.lang +++ b/lang/ilias_ru.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#Вы должны выбрать как минимум один вид из "Выбранные элементы" или "Участие". pdesk#:#bookmark_moved_ok#:#Закладка перемещена. pdesk#:#bookmark_select_target#:#Пожалуйста, выберите цель закладки. -poll#:#poll_absolute#:#Количество голосов -poll#:#poll_activation_online_info#:#Сделать голосование доступным для пользователей -poll#:#poll_add#:#Добавить голосование -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Ответ -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Возможные ответы -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#Вы уже проголосовали. -poll#:#poll_block_message_no_answers#:#Голосование определено не полностью. -poll#:#poll_block_results_available_on#:#Результаты буду доступны %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Копировать голосование -poll#:#poll_delete_votes#:#Удалить все голоса -poll#:#poll_delete_votes_sure#:#Вы уверены что хотите удалить все голоса? -poll#:#poll_edit#:#Редактировать голосование -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Изображение -poll#:#poll_import#:#Импортировать голосование -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Добавить новое голосование -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Текущий процент -poll#:#poll_population#:#%s голосов -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Вопросы -poll#:#poll_result#:#Результаты голосования -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Сортировка -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Показать результаты -poll#:#poll_view_results_after_period#:#После периода голосования -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#После голосования -poll#:#poll_view_results_always#:#Всегда -poll#:#poll_view_results_never#:#Никогда -poll#:#poll_vote#:#Голосовать -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#Голосование уже имеет проголосовавших. Вы не можете редактировать голосование пока не удалите эти голоса. -poll#:#poll_voting_period_and_results#:#Период голосования и результаты -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s -poll#:#poll_voting_period_limited#:#Ограничен prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_sk.lang b/lang/ilias_sk.lang index 20036c8567ea..c35153df1b1f 100644 --- a/lang/ilias_sk.lang +++ b/lang/ilias_sk.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#Musíte vybrat alespoň jedno zobrazení, také 'Vybrané položky' a/nebo 'Má členství'. pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_sl.lang b/lang/ilias_sl.lang index b2e1d6c1dfba..326cc127a130 100644 --- a/lang/ilias_sl.lang +++ b/lang/ilias_sl.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Predhodno pd#:#pd_view_select_at_least_one#:#Izberite vsaj en pogled, ali 'Izbrane ponudbe' in/ali 'Tečaji in skupine'. pdesk#:#bookmark_moved_ok#:#Zaznamek je bil premaknjen. pdesk#:#bookmark_select_target#:#Izberite cilj. -poll#:#poll_absolute#:#Aktualni glasovi -poll#:#poll_activation_online_info#:#Izberite to nastavitev, da bo glasovanje na voljo uporabnikom. -poll#:#poll_add#:#Ustvari glasovanje -poll#:#poll_anonymous_warning#:#Vaše ime ne bo prikazano v rezultatih glasovanja. -poll#:#poll_answer#:#Odgovor -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Možni odgovori -poll#:#poll_barchart#:#Palični diagram -poll#:#poll_block_message_already_voted#:#Svoj glas ste že oddali. -poll#:#poll_block_message_no_answers#:#To glasovanje ni popolno. -poll#:#poll_block_results_available_on#:#Rezultati bodo objavljeni dne %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Komentarji -poll#:#poll_copy#:#Kopiraj glasovanje -poll#:#poll_delete_votes#:#Izbriši vse glasove -poll#:#poll_delete_votes_sure#:#Ali ste prepričani, da želite izbrisati vse glasove? -poll#:#poll_edit#:#Uredi glasovanje -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Slika -poll#:#poll_import#:#Uvozi glasovanje -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maksimalno število odgovorov na udeleženca -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Način -poll#:#poll_mode_anonymous#:#Anonimno -poll#:#poll_mode_anonymous_info#:#Oddanih glasov ni mogoče dodeliti udeležencem. -poll#:#poll_mode_personal#:#Personalizirano -poll#:#poll_mode_personal_info#:#Seznam udeležencev z oddanimi glasovi je na voljo z ustreznimi pravicami. -poll#:#poll_new#:#Ustvari novo glasovanje -poll#:#poll_non_anonymous_warning#:#Vaše ime in glas sta v rezultatih glasovanja vidna administratorjem. -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Aktiviraj obveščanje -poll#:#poll_notification_unsubscribe#:#Onemogoči obveščanje -poll#:#poll_percentage#:#Aktualni delež -poll#:#poll_population#:#%s udeleženec -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Vprašanje -poll#:#poll_result#:#Rezultati glasovanja -poll#:#poll_result_answers#:#Glasovi -poll#:#poll_result_sorting#:#Razvrstitev -poll#:#poll_result_sorting_answers#:#Po vrstnem redu odgovorov -poll#:#poll_result_sorting_votes#:#Po številu glasov (padajoče) -poll#:#poll_result_users#:#Udeleženec -poll#:#poll_show_results_as#:#Prikaži rezultate kot -poll#:#poll_sortorder#:#Razvrstitev -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Prikaži rezultate -poll#:#poll_view_results_after_period#:#Po obdobju glasovanja -poll#:#poll_view_results_after_period_impossible#:#Obdobje glasovanja ni časovno omejeno. -poll#:#poll_view_results_after_vote#:#Po oddanem glasu -poll#:#poll_view_results_always#:#Vedno -poll#:#poll_view_results_never#:#Nikoli -poll#:#poll_vote#:#Glasuj -poll#:#poll_vote_error_multi#:#Izberite maksimalno %s odgovorov. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Izberite en odgovor. -poll#:#poll_vote_notification_body#:#naslednje glasovanje je prejelo en glas -poll#:#poll_vote_notification_link#:#Povezava do glasovanja -poll#:#poll_vote_notification_reason#:#To sporočilo ste prejeli, ker ste aktivirali obveščanje za zgoraj navedeno glasovanje. -poll#:#poll_vote_notification_subject#:#Glasovanje "%s": nov glas -poll#:#poll_votes_no_edit#:#To glasovanje že vsebuje glasove. Glasovanja ne morete urejati, dokler ne izbrišete teh glasov. -poll#:#poll_voting_period_and_results#:#Obdobje glasovanja in rezultati -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Glasovanje od %s do %s -poll#:#poll_voting_period_info#:#Konec glasovanja: %s -poll#:#poll_voting_period_limited#:#Časovno omejeno obdobje glasovanja prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source diff --git a/lang/ilias_sq.lang b/lang/ilias_sq.lang index 05f71afe9851..9863a1951ec1 100644 --- a/lang/ilias_sq.lang +++ b/lang/ilias_sq.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'.###15 09 2008 new variable pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_sr.lang b/lang/ilias_sr.lang index f311fa37e2b8..6314243a8841 100644 --- a/lang/ilias_sr.lang +++ b/lang/ilias_sr.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'.###15 09 2008 new variable pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_sv.lang b/lang/ilias_sv.lang index a5de15914ce7..7683df9b5ab7 100644 --- a/lang/ilias_sv.lang +++ b/lang/ilias_sv.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Föregående pd#:#pd_view_select_at_least_one#:#Välj minst en vy, antingen "Utvalda erbjudanden" och/eller "Kurser och grupper". pdesk#:#bookmark_moved_ok#:#Bookmark har flyttats. pdesk#:#bookmark_select_target#:#Vänligen välj destination. -poll#:#poll_absolute#:#Aktuella röster -poll#:#poll_activation_online_info#:#Välj denna inställning för att göra röstning tillgänglig för användare. -poll#:#poll_add#:#Skapa omröstning -poll#:#poll_anonymous_warning#:#Ditt namn visas inte i röstningsresultaten. -poll#:#poll_answer#:#Svar -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Möjliga svar -poll#:#poll_barchart#:#Balkdiagram -poll#:#poll_block_message_already_voted#:#Du har redan röstat. -poll#:#poll_block_message_no_answers#:#Denna omröstning är inte slutförd. -poll#:#poll_block_results_available_on#:#Resultaten kommer att publiceras på %s. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###21 11 2023 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Kommentarer -poll#:#poll_copy#:#Kopiera omröstning -poll#:#poll_delete_votes#:#Radera alla röster -poll#:#poll_delete_votes_sure#:#Är du säker på att du vill radera alla röster? -poll#:#poll_edit#:#Redigera omröstning -poll#:#poll_edit_question#:#Edit Question###21 11 2023 new variable -poll#:#poll_image#:#Image -poll#:#poll_import#:#Import omröstning -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###21 11 2023 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###21 11 2023 new variable -poll#:#poll_max_number_of_answers#:#Maximalt antal svar per deltagare -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###21 11 2023 new variable -poll#:#poll_mode#:#Mode -poll#:#poll_mode_anonymous#:#Anonym -poll#:#poll_mode_anonymous_info#:#Avgivna Votes kan inte tilldelas deltagare. -poll#:#poll_mode_personal#:#Personligt anpassad -poll#:#poll_mode_personal_info#:#En lista över deltagare med avgivna röster är tillgänglig med tillräckliga rättigheter. -poll#:#poll_new#:#Skapa ny omröstning -poll#:#poll_non_anonymous_warning#:#Ditt namn och din röst syns i omröstningsresultaten för administratörer. -poll#:#poll_notification_activated#:#Notification Activated###21 11 2023 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###21 11 2023 new variable -poll#:#poll_notification_subscribe#:# Aktivera avisering -poll#:#poll_notification_unsubscribe#:#Avaktivera avisering -poll#:#poll_percentage#:#Aktuell andel -poll#:#poll_population#:#%s deltagare -poll#:#poll_population_singular#:#1 Participant###21 11 2023 new variable -poll#:#poll_question#:#Fråga -poll#:#poll_result#:#Röstningsresultat -poll#:#poll_result_answers#:#Voices -poll#:#poll_result_sorting#:#Sortering -poll#:#poll_result_sorting_answers#:#I den ordning som svaren kommer -poll#:#poll_result_sorting_votes#:#Efter antal röster (fallande) -poll#:#poll_result_users#:#Deltagare -poll#:#poll_show_results_as#:#Visa resultat som -poll#:#poll_sortorder#:#Sortering -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Visa resultat -poll#:#poll_view_results_after_period#:#Efter omröstningsperiod -poll#:#poll_view_results_after_period_impossible#:#Omröstningsperioden är inte tidsbegränsad. -poll#:#poll_view_results_after_vote#:#Efter omröstning -poll#:#poll_view_results_always#:#Alltid -poll#:#poll_view_results_never#:#Aldrig -poll#:#poll_vote#:#Rösta -poll#:#poll_vote_error_multi#:# Välj högst %s svar. -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###21 11 2023 new variable -poll#:#poll_vote_error_single#:#Välj ett svar. -poll#:#poll_vote_notification_body#:#Följande omröstning har fått en röst -poll#:#poll_vote_notification_link#:#Länk till omröstningen -poll#:#poll_vote_notification_reason#:#Du får detta meddelande eftersom du har aktiverat meddelandefunktionen för ovanstående omröstning. -poll#:#poll_vote_notification_subject#:#Röstning "%s" -poll#:#poll_votes_no_edit#:#Denna omröstning innehåller redan röster. Den kan inte redigeras så länge du inte tar bort rösterna. -poll#:#poll_voting_period_and_results#:#Röstningsperiod och resultat -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###21 11 2023 new variable -poll#:#poll_voting_period_full_info#:#Röstning från %s till %s -poll#:#poll_voting_period_info#:#Slut på omröstningen -poll#:#poll_voting_period_limited#:#Tidsbegränsad omröstningsperiod prg#:#access_ctr_by_orgu_position#:#Autorisationer baserade på positioner i organisatoriska enheter prg#:#active_only#:#Endast aktiv prg#:#add_automembership_source#:#Lägg till automatik diff --git a/lang/ilias_tr.lang b/lang/ilias_tr.lang index 2d4b1acb1d2b..8e804b72194f 100644 --- a/lang/ilias_tr.lang +++ b/lang/ilias_tr.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#En az bir görünümü seçmek zorunda, ya 'Seçili Öğeler' ve / veya 'Üyelikler'. pdesk#:#bookmark_moved_ok#:#Yer imi taşındı. pdesk#:#bookmark_select_target#:#Hedef seçiniz. -poll#:#poll_absolute#:#Geçerli Oy -poll#:#poll_activation_online_info#:#kullanıcıların anket erişilebilir yapmak için bu ayarı etkinleştirin. -poll#:#poll_add#:#Anket Ekle -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Yanıt -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Olası Yanıtlar -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#Zaten bu Ankette oy kullandı. -poll#:#poll_block_message_no_answers#:#Bu anket eksiktir. -poll#:#poll_block_results_available_on#:#sonuçları% s mevcut olacaktır. -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Kopya Anket -poll#:#poll_delete_votes#:#Tüm Oyları sil -poll#:#poll_delete_votes_sure#:#tüm oy silmek istediğinizden emin misiniz? -poll#:#poll_edit#:#Düzenleme Anket -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Görüntü -poll#:#poll_import#:#Anket İçe Aktar -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Ekle Yeni Anket -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Geçerli Yüzde -poll#:#poll_population#:#% s Oy -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Soru -poll#:#poll_result#:#Oylama Sonucu -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Sıra -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Ekran Sonuçlar -poll#:#poll_view_results_after_period#:#Oy Dönemi Sonrası -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#Oy sonra -poll#:#poll_view_results_always#:#Her Zaman -poll#:#poll_view_results_never#:#Asla -poll#:#poll_vote#:#Oy -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#Bu anket oy içerir. Bu oy silmek kadar anketi düzenleyemezsiniz. -poll#:#poll_voting_period_and_results#:#Oy Dönemi ve Sonuçlar -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Sınırlı prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_uk.lang b/lang/ilias_uk.lang index a50af3e16b28..b1d7b7f2c821 100644 --- a/lang/ilias_uk.lang +++ b/lang/ilias_uk.lang @@ -13281,75 +13281,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'.###15 09 2008 new variable pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_vi.lang b/lang/ilias_vi.lang index a4a24116e2b6..ed84e205f2d2 100644 --- a/lang/ilias_vi.lang +++ b/lang/ilias_vi.lang @@ -13283,75 +13283,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#You have to select at least one view, either 'Selected Items' and/or 'My Memberships'.###15 09 2008 new variable pdesk#:#bookmark_moved_ok#:#Bookmark has been moved.###28 08 2012 new variable pdesk#:#bookmark_select_target#:#Please select target.###28 08 2012 new variable -poll#:#poll_absolute#:#Current Votes###02 09 2012 new variable -poll#:#poll_activation_online_info#:#If offline, only administrators can see and edit the poll.###28 08 2012 new variable -poll#:#poll_add#:#Add Poll###28 08 2012 new variable -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#Answer###28 08 2012 new variable -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#Possible Answers###28 08 2012 new variable -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#You already voted in this poll.###28 08 2012 new variable -poll#:#poll_block_message_no_answers#:#This poll is incomplete.###28 08 2012 new variable -poll#:#poll_block_results_available_on#:#The results will be available on %s.###04 09 2012 new variable -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#Copy Poll###28 08 2012 new variable -poll#:#poll_delete_votes#:#Delete All Votes###28 08 2012 new variable -poll#:#poll_delete_votes_sure#:#Are you sure you want to delete all votes?###28 08 2012 new variable -poll#:#poll_edit#:#Edit Poll###28 08 2012 new variable -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#Image###28 08 2012 new variable -poll#:#poll_import#:#Import Poll###28 09 2012 new variable -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#Add New Poll###28 08 2012 new variable -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#Current Percentage###28 08 2012 new variable -poll#:#poll_population#:#%s Votes###28 08 2012 new variable -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#Question###28 08 2012 new variable -poll#:#poll_result#:#Voting Result###28 11 2012 new variable -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#Order###28 08 2012 new variable -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#Display Results###28 08 2012 new variable -poll#:#poll_view_results_after_period#:#After Voting Period ###28 08 2012 new variable -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#After Vote###28 08 2012 new variable -poll#:#poll_view_results_always#:#Always###28 08 2012 new variable -poll#:#poll_view_results_never#:#Never###28 08 2012 new variable -poll#:#poll_vote#:#Vote###28 08 2012 new variable -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#This poll cannot be edited because of existing votes.###28 08 2012 new variable -poll#:#poll_voting_period_and_results#:#Voting Period and Results###28 08 2012 new variable -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#Limited###28 08 2012 new variable prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable diff --git a/lang/ilias_zh.lang b/lang/ilias_zh.lang index fd24d790a8d8..c43b6b6ad416 100644 --- a/lang/ilias_zh.lang +++ b/lang/ilias_zh.lang @@ -13280,75 +13280,6 @@ pd#:#pd_upcoming#:#Upcoming###31 08 2017 new variable pd#:#pd_view_select_at_least_one#:#您必须至少选择一个视图,“已选项目”和/或“我的会员资格”。 pdesk#:#bookmark_moved_ok#:#书签已被移动 pdesk#:#bookmark_select_target#:#请选择目标 -poll#:#poll_absolute#:#当前投票 -poll#:#poll_activation_online_info#:#如果离线,仅管理员能看和编辑投票。 -poll#:#poll_add#:#添加投票 -poll#:#poll_anonymous_warning#:#Your name is not visible in the poll results.###26 09 2014 new variable -poll#:#poll_answer#:#答案 -poll#:#poll_answer_selected_alt_text#:#Selected###26 08 2024 new variable -poll#:#poll_answers#:#可能的答案 -poll#:#poll_barchart#:#Bar Chart###26 09 2014 new variable -poll#:#poll_block_message_already_voted#:#您已经在这个调查中投票。 -poll#:#poll_block_message_no_answers#:#这个投票是不完整的。 -poll#:#poll_block_results_available_on#:#这个结果将在 %s 可用。 -poll#:#poll_cannot_set_online_no_answers#:#The status cannot be changed to "online" because this poll has no question!###26 08 2024 new variable -poll#:#poll_chart_votes#:#Votes###28 10 2024 new variable -poll#:#poll_comments#:#Comments###26 09 2014 new variable -poll#:#poll_copy#:#复制投票 -poll#:#poll_delete_votes#:#删除所有投票 -poll#:#poll_delete_votes_sure#:#您确定想删除所有投票吗? -poll#:#poll_edit#:#编辑投票 -poll#:#poll_edit_question#:#Edit Question###26 08 2024 new variable -poll#:#poll_image#:#图像 -poll#:#poll_import#:#导入调查投票 -poll#:#poll_limit_not_below_answer_count#:#The answer limit must be below the number of possible answers.###26 08 2024 new variable -poll#:#poll_limit_number_of_answers#:#Limit Number of Answers per Participant###26 08 2024 new variable -poll#:#poll_max_number_of_answers#:#Maximum Number Of Answers per Participant###26 09 2014 new variable -poll#:#poll_max_number_of_answers_info#:#You may choose up to %s answers.###26 08 2024 new variable -poll#:#poll_mode#:#Mode###26 09 2014 new variable -poll#:#poll_mode_anonymous#:#Anonymous###26 09 2014 new variable -poll#:#poll_mode_anonymous_info#:#Votes cannot be traced back to participants.###26 09 2014 new variable -poll#:#poll_mode_personal#:#Personal###26 09 2014 new variable -poll#:#poll_mode_personal_info#:#A list of participants with their respective votes is available when having sufficient permission.###26 09 2014 new variable -poll#:#poll_new#:#添加新投票 -poll#:#poll_non_anonymous_warning#:#Your name and vote are visible for administrators in the results.###26 09 2014 new variable -poll#:#poll_notification_activated#:#Notification Activated###26 08 2024 new variable -poll#:#poll_notification_deactivated#:#Notification Deactivated###26 08 2024 new variable -poll#:#poll_notification_subscribe#:#Activate Notification###26 09 2014 new variable -poll#:#poll_notification_unsubscribe#:#Deactivate Notification###26 09 2014 new variable -poll#:#poll_percentage#:#当前百分比 -poll#:#poll_population#:#%s 投票 -poll#:#poll_population_singular#:#1 Participant###26 08 2024 new variable -poll#:#poll_question#:#题目 -poll#:#poll_result#:#投票结果 -poll#:#poll_result_answers#:#Votes###26 09 2014 new variable -poll#:#poll_result_sorting#:#Sorting###26 09 2014 new variable -poll#:#poll_result_sorting_answers#:#In the order of the answers###26 09 2014 new variable -poll#:#poll_result_sorting_votes#:#By number of votes (descending)###26 09 2014 new variable -poll#:#poll_result_users#:#Participants###26 09 2014 new variable -poll#:#poll_show_results_as#:#Show Results as###26 09 2014 new variable -poll#:#poll_sortorder#:#排序 -poll#:#poll_stacked_chart#:#Stacked Chart###28 10 2024 new variable -poll#:#poll_view_results#:#显示结果 -poll#:#poll_view_results_after_period#:#投票时间段之后 -poll#:#poll_view_results_after_period_impossible#:#The voting period is not limited.###27 01 2015 new variable -poll#:#poll_view_results_after_vote#:#投票后 -poll#:#poll_view_results_always#:#总是 -poll#:#poll_view_results_never#:#从不 -poll#:#poll_vote#:#投票 -poll#:#poll_vote_error_multi#:#Please select no more than %s answers.###26 09 2014 new variable -poll#:#poll_vote_error_multi_no_answer#:#Please select at least 1 answer.###26 08 2024 new variable -poll#:#poll_vote_error_single#:#Please select 1 answer.###26 09 2014 new variable -poll#:#poll_vote_notification_body#:#the following poll has received a vote.###26 09 2014 new variable -poll#:#poll_vote_notification_link#:#Link to Poll###26 09 2014 new variable -poll#:#poll_vote_notification_reason#:#You are receiving this email because you activated notifications for the poll mentioned above.###26 09 2014 new variable -poll#:#poll_vote_notification_subject#:#Poll "%s": new vote###26 09 2014 new variable -poll#:#poll_votes_no_edit#:#这个调查投票不能被编辑,因为已经有投票了。 -poll#:#poll_voting_period_and_results#:#投票期和结果 -poll#:#poll_voting_period_ended_info#:#The voting period ended %s.###26 08 2024 new variable -poll#:#poll_voting_period_full_info#:#Voting period from %s to %s###10 11 2018 new variable -poll#:#poll_voting_period_info#:#Deadline for Voting: %s###24 10 2013 new variable -poll#:#poll_voting_period_limited#:#已限制的 prg#:#access_ctr_by_orgu_position#:#Access control according to Organisational Unit Positions###07 02 2020 new variable prg#:#active_only#:#active users only###26 08 2024 new variable prg#:#add_automembership_source#:#Add Source###07 02 2020 new variable From f1df422a6733ad57e82d5abf628d75f2c15dbd61 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 12:46:16 +0200 Subject: [PATCH 048/103] Refinery: Accept a `LanguageIdentifier` in the `Language` test doubles The `txt()` of `ILIAS\Language\Language` is widened to `string|LanguageIdentifier`. The test doubles implementing the interface follow and return the value of an identifier. --- .../Refinery/tests/Logical/Constraint/LogicalOrTest.php | 4 ++-- .../tests/Password/Constraint/PasswordContraintsTest.php | 4 ++-- components/ILIAS/Refinery/tests/TestCase.php | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php b/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php index 08180ebff73a..85125371e659 100755 --- a/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php +++ b/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php @@ -96,9 +96,9 @@ public function testWithProblemBuilder(LogicalOr $constraint, mixed $okValue, mi public static function constraintsProvider(): array { $language = new class () implements \ILIAS\Language\Language { - public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|\ILIAS\Language\LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string { - return $a_topic; + return is_string($a_topic) ? $a_topic : (string) $a_topic->value; } public function loadLanguageModule(string $a_module): void { diff --git a/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php b/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php index 6e04e851311f..8904e846dbc1 100755 --- a/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php +++ b/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php @@ -32,9 +32,9 @@ class PasswordContraintsTest extends TestCase public static function constraintsProvider(): array { $lng = new class () implements \ILIAS\Language\Language { - public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|\ILIAS\Language\LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string { - return $a_topic; + return is_string($a_topic) ? $a_topic : (string) $a_topic->value; } public function loadLanguageModule(string $a_module): void { diff --git a/components/ILIAS/Refinery/tests/TestCase.php b/components/ILIAS/Refinery/tests/TestCase.php index cf8beef6fd5c..7d7c0eb13058 100755 --- a/components/ILIAS/Refinery/tests/TestCase.php +++ b/components/ILIAS/Refinery/tests/TestCase.php @@ -22,6 +22,7 @@ use ilGlobalTemplateInterface; use ILIAS\Language\Language; +use ILIAS\Language\LanguageIdentifier; use PHPUnit\Framework\TestCase as PHPUnitTestCase; class ilLanguageMock implements Language @@ -35,10 +36,10 @@ public function __construct() { } - public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''): string + public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ''): string { $this->requested[] = $a_topic; - return $a_topic; + return is_string($a_topic) ? $a_topic : (string) $a_topic->value; } public function toJS($a_lang_key, ?ilGlobalTemplateInterface $a_tpl = null): void From 5ce4e4f41766116559c9e91105c2874fc9a5e285 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 12:46:25 +0200 Subject: [PATCH 049/103] Language: Accept a `LanguageIdentifier` in `txt()` and `ntxt()` Replace the separate `translate()` by widening `txt()` (also in the interface `Language`) and `ntxt()` of `ilLanguage`, `ilSetupLanguage` and `LanguageLegacyInitialisationAdapter` to `string|LanguageIdentifier`. One method stays instead of a second way and a later deprecation; an identifier is read from its own module, a string as before. BREAKING: implementations of `ILIAS\Language\Language` and subclasses of `ilLanguage` that declare `txt(string ...)` or `ntxt(string ...)` must widen the parameter (in the core only the Refinery test doubles, adapted separately). --- components/ILIAS/Language/README.md | 39 +- .../classes/Setup/class.ilSetupLanguage.php | 15 +- .../Language/classes/class.ilLanguage.php | 93 +-- components/ILIAS/Language/src/Language.php | 5 +- .../ILIAS/Language/src/LanguageIdentifier.php | 4 +- .../LanguageLegacyInitialisationAdapter.php | 20 +- .../IdentifierTxtSetupAndAdapterTest.php | 93 +++ .../Language/tests/IdentifierTxtTest.php | 537 ++++++++++++++ .../tests/TranslateSetupAndAdapterTest.php | 123 --- .../ILIAS/Language/tests/TranslateTest.php | 698 ------------------ .../tools/language-identifier-enum/README.md | 6 +- .../generate_language_identifier_enum.php | 2 +- 12 files changed, 691 insertions(+), 944 deletions(-) create mode 100644 components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php create mode 100644 components/ILIAS/Language/tests/IdentifierTxtTest.php delete mode 100644 components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php delete mode 100644 components/ILIAS/Language/tests/TranslateTest.php diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 31471251df76..55af334830e2 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -22,14 +22,12 @@ 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")); -### `translate()` and Language Identifier Enums -`ilLanguage::translate(string|LanguageIdentifier $key, ?int $n = null, ?string $lang = null)` is the successor of `txt()`, -`ntxt()` and `txtlng()`: +### Language Identifier Enums +`txt()` (`ILIAS\Language\Language`) and `ilLanguage::ntxt()` accept, besides a string, a `LanguageIdentifier`: - $language->translate(PollLanguageIdentifier::POLL_ANSWERS); // text of the current language - $language->translate(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count - $language->translate(PollLanguageIdentifier::POLL_ANSWERS, null, 'de'); // text in another language - $language->translate('poll_answers'); // exactly like txt('poll_answers') + $language->txt(PollLanguageIdentifier::POLL_ANSWERS); // text of the current language + $language->ntxt(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count + $language->txt('poll_answers'); // unchanged - `ILIAS\Language\LanguageIdentifier` is implemented by a string backed enum per module (`LanguageIdentifier`): the case value is the identifier, `module()` the module. The enum is generated from the module's `.pot` with @@ -38,20 +36,17 @@ session, or container state. It must not be used as the runtime language service - With a `LanguageIdentifier` the identifier is read from exactly its module - no `loadLanguageModule()` beforehand, no dependency on which module was loaded last (no collision of identifiers that exist in several modules). A migrated module is looked up directly in its build and overlay; of a module that is not migrated the values for the current - language are read (cache of `lng_modules`, else `lng_modules`) and kept for the request, for another language its - entry is read from `lng_data`. No side effect on `txt()`: the module is not loaded, so the order "the module loaded - last wins" of string calls stays as it is. Without a text in the language the one of the - default language is served, otherwise `-identifier-`. -- With a string and without `$lang` it is exactly `txt($key)` or `ntxt($key, $n)`. With another `$lang` the module - `txt()` serves the identifier from (the migrated module it is assigned to, else the module loaded last that contains - it) is read in that language - one lookup; no fallback to the default language, as with `txtlng()`. -- A `$lang` that is no language key (two lower case letters) is treated like a language without texts. An empty - identifier (as for `txt()`, also `"0"`) gives `""`. + language are read (cache of `lng_modules`, else `lng_modules`) and kept for the request, for the default language its + entry is read from `lng_data`. No side effect on string calls: the module is not loaded, so the order "the module + loaded last wins" stays as it is. Without a text in the current language the one of the default language is served, + otherwise `-identifier-`. The fallback module argument is ignored, the module is known. +- With a string `txt()` and `ntxt()` behave exactly as before. +- An empty identifier (as for a string, also `"0"`) gives `""`. - `$n` only selects a plural form of a plural message of a migrated module; the quantity is not inserted into the text. -- `translate()` is not (yet) part of the interface `ILIAS\Language\Language`, so existing implementations of it do not - break: it is available on `ilLanguage` (`$DIC->language()`) and `LanguageLegacyInitialisationAdapter`. - Consumers typed against the interface can use it only once it is part of it (announced for the Jour Fixe). - `ilSetupLanguage::translate()` is exactly its `txt()` - Setup needs English at most. +- `txtlng()` and `exists()` take a string only. +- Breaking change (announced for the Jour Fixe): implementations of `ILIAS\Language\Language` and subclasses of + `ilLanguage` that declare the parameter type of `txt()`/`ntxt()` must widen it to `string|LanguageIdentifier`. + `ilSetupLanguage` reads only the identifier of a `LanguageIdentifier` - Setup needs English at most. ## Installing and Managing Languages Which languages are installed/available (`ILIAS\Language\Setup\InstalledLanguageRepository`) and installing, flushing @@ -150,8 +145,8 @@ module that is not migrated). * Behaviour change: an identifier that the migrated module loaded last does not translate gives `-identifier-`, not the value of a module loaded before. The fallback module passed to `txt()` still applies. -* `translate()` with a `LanguageIdentifier` reads from exactly its module, see - [above](#translate-and-language-identifier-enums). +* `txt()`/`ntxt()` with a `LanguageIdentifier` read from exactly its module, see + [above](#language-identifier-enums). * Apart from these lookups, `.po`/`.mo` files are read and written with the library `gettext/gettext`, only through the adapter in `src/ComponentTranslation/Catalog/` (`TranslationCatalog`, diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index f2d529b75e92..d230a8f23169 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -173,13 +173,16 @@ public function getLanguageFileDirectoryManager(): LanguageFileDirectoryManager * * if the topic is not in the list, the topic itself with "-" will be returned * - * $a_topic topic + * $a_topic topic; the module of a LanguageIdentifier has no effect in Setup */ - public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''): string + public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ''): string { /** @var ilLogger $log */ global $log; + if ($a_topic instanceof LanguageIdentifier) { + $a_topic = (string) $a_topic->value; + } if (empty($a_topic)) { return ""; } @@ -203,11 +206,13 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''): } /** - * Same as txt(); $n, $lang and the module of a LanguageIdentifier have no effect in Setup. + * Same as txt() for a LanguageIdentifier: ilLanguage would read its module from the database. */ - public function translate(string|LanguageIdentifier $key, ?int $n = null, ?string $lang = null): string + public function ntxt(string|LanguageIdentifier $a_topic, int $a_n, string $a_default_lang_fallback_mod = ""): string { - return $this->txt($key instanceof LanguageIdentifier ? (string) $key->value : $key); + return $a_topic instanceof LanguageIdentifier + ? $this->txt($a_topic) + : parent::ntxt($a_topic, $a_n, $a_default_lang_fallback_mod); } /** diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index 39087e61cfc3..b45dfea798d4 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -80,8 +80,7 @@ class ilLanguage implements \ILIAS\Language\Language private static array $shipped_without_build = []; /** - * Identifier => value per module that is not migrated, so translate() serves the module a LanguageIdentifier - * names, not the one loaded last ($text). Shares the arrays of cached_modules/$text (copy-on-write). + * Identifier => value per non-migrated module, cached by loadLanguageModule() and txt() with a LanguageIdentifier. * * @var array> */ @@ -200,9 +199,15 @@ public function txtlng(string $a_module, string $a_topic, string $a_language): s /** * gets the text for a given topic * if the topic is not in the list, the topic itself with "-" will be returned + * + * A LanguageIdentifier is read from exactly its module without loadLanguageModule() and without side effect, + * falling back to the default language; $a_default_lang_fallback_mod is then ignored. */ - public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string { + if ($a_topic instanceof LanguageIdentifier) { + return $this->translateInModule($a_topic->module(), (string) $a_topic->value, null); + } if (empty($a_topic)) { return ""; } @@ -265,14 +270,17 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): * Plural form selected by the "Plural-Forms" rule (native gettext) for a plural message of a migrated * module, otherwise exactly txt(). The quantity is not inserted; callers do that, e.g. with sprintf(). * - * A fallback module is read like translate() reads a LanguageIdentifier, language of this instance first, - * then the default language (#13467). + * A LanguageIdentifier is read as in txt(), $a_default_lang_fallback_mod is then ignored. A fallback module is + * read the same way, language of this instance first, then the default language (#13467). */ - public function ntxt(string $a_topic, int $a_n, string $a_default_lang_fallback_mod = ""): string + public function ntxt(string|LanguageIdentifier $a_topic, int $a_n, string $a_default_lang_fallback_mod = ""): string { + if ($a_topic instanceof LanguageIdentifier) { + return $this->translateInModule($a_topic->module(), (string) $a_topic->value, $a_n); + } $module = $a_topic === '' ? null : ($this->migrated_key_modules[$a_topic] ?? null); if ($module === null && $a_default_lang_fallback_mod !== '' && ($this->text[$a_topic] ?? '') === '') { - return $this->translateInModule($a_default_lang_fallback_mod, $a_topic, $a_n, $this->migratedLangKey()); + return $this->translateInModule($a_default_lang_fallback_mod, $a_topic, $a_n); } $value = $module === null ? null @@ -301,36 +309,13 @@ public function exists(string $a_topic): bool return isset($this->text[$a_topic]); } - /** - * Successor of txt(), ntxt() and txtlng(). - * - * - A LanguageIdentifier is read from exactly its module, without loadLanguageModule() and without side - * effect on txt() & Co. (nothing goes into $text or the loaded modules). Falls back to the default - * language like txt() with a fallback module (#13467), otherwise "-identifier-". - * - A string without $lang behaves like txt($key) or ntxt($key, $n); the module loaded last wins. With another - * $lang exactly one lookup in the module responsible for $key, without default-language fallback (as txtlng()). - * - * A $lang that is no language key gives no text. An empty identifier (as for txt(): also "0") gives "". - * $n only selects a plural form of a migrated module. The quantity is not inserted; callers do that. - */ - public function translate(string|LanguageIdentifier $key, ?int $n = null, ?string $lang = null): string - { - if ($key instanceof LanguageIdentifier) { - return $this->translateInModule($key->module(), (string) $key->value, $n, $lang ?? $this->migratedLangKey()); - } - if ($lang === null || $lang === $this->migratedLangKey()) { - return $n === null ? $this->txt($key) : $this->ntxt($key, $n); - } - - return $this->translateInLanguage($key, $n, $lang); - } - - private function translateInModule(string $module, string $a_topic, ?int $n, string $lang_key): string + private function translateInModule(string $module, string $a_topic, ?int $n): string { // like txt(""): never the header of a catalog if (empty($a_topic)) { return ''; } + $lang_key = $this->migratedLangKey(); self::$used_topics[$a_topic] = $a_topic; $translation = $this->moduleText($module, $a_topic, $n, $lang_key); @@ -339,35 +324,14 @@ private function translateInModule(string $module, string $a_topic, ?int $n, str $translation = $this->moduleText($module, $a_topic, $n, $default_lang_key); } - return $translation ?? $this->missingText($a_topic, $lang_key); - } - - private function translateInLanguage(string $a_topic, ?int $n, string $lang_key): string - { - if (empty($a_topic)) { - return ''; - } - self::$used_topics[$a_topic] = $a_topic; - - $module = $this->responsibleModule($a_topic); - - return ($module === null ? null : $this->moduleText($module, $a_topic, $n, $lang_key)) - ?? $this->missingText($a_topic, $lang_key); - } - - private function responsibleModule(string $a_topic): ?string - { - $module = $this->migrated_key_modules[$a_topic] ?? null; - if ($module !== null) { - return $module; + if ($translation !== null) { + return $translation; } - foreach (array_reverse($this->loaded_modules) as $loaded_module) { - if (array_key_exists($a_topic, $this->module_texts[$loaded_module] ?? [])) { - return (string) $loaded_module; - } + if (ILIAS_LOG_ENABLED && is_object($this->log)) { + $this->log->debug("Language (" . $lang_key . "): topic -" . $a_topic . "- not present"); } - return null; + return "-" . $a_topic . "-"; } private function moduleText(string $module, string $a_topic, ?int $n, string $lang_key): ?string @@ -400,7 +364,7 @@ private function moduleText(string $module, string $a_topic, ?int $n, string $la } /** - * Without loading the module (see translate()). + * Without loading the module (see txt() with a LanguageIdentifier). * * @return array */ @@ -435,16 +399,6 @@ private static function readModuleTexts(string $module, string $lang_key): ?arra return is_array($texts) ? $texts : null; } - private function missingText(string $a_topic, string $lang_key): string - { - if (ILIAS_LOG_ENABLED && is_object($this->log)) { - $lang_key = MigratedLanguageFilePaths::isValidLanguageKey($lang_key) ? $lang_key : 'invalid language key'; - $this->log->debug("Language (" . $lang_key . "): topic -" . $a_topic . "- not present"); - } - - return "-" . $a_topic . "-"; - } - /** * Load language module */ @@ -475,7 +429,6 @@ public function loadLanguageModule(string $a_module): void } if (isset($this->cached_modules[$a_module]) && is_array($this->cached_modules[$a_module])) { - $this->module_texts[$a_module] = $this->cached_modules[$a_module]; $this->text = array_merge($this->text, $this->cached_modules[$a_module]); $this->forgetMigratedKeys($this->cached_modules[$a_module]); diff --git a/components/ILIAS/Language/src/Language.php b/components/ILIAS/Language/src/Language.php index 36453945d5e6..93f7dfd44372 100644 --- a/components/ILIAS/Language/src/Language.php +++ b/components/ILIAS/Language/src/Language.php @@ -22,7 +22,10 @@ interface Language { - public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): string; + /** + * $a_default_lang_fallback_mod is ignored for a LanguageIdentifier. + */ + public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string; public function loadLanguageModule(string $a_module): void; diff --git a/components/ILIAS/Language/src/LanguageIdentifier.php b/components/ILIAS/Language/src/LanguageIdentifier.php index d4bb0e600ae2..58081009010c 100644 --- a/components/ILIAS/Language/src/LanguageIdentifier.php +++ b/components/ILIAS/Language/src/LanguageIdentifier.php @@ -22,8 +22,8 @@ /** * A language identifier as an enum case (backing value = identifier), generated per module from its - * `.pot` (see components/ILIAS/Language/tools/language-identifier-enum/). \ilLanguage::translate() - * reads it from exactly module(), regardless of which module was loaded last. + * `.pot` (see components/ILIAS/Language/tools/language-identifier-enum/). Language::txt() and + * \ilLanguage::ntxt() read it from exactly module(), regardless of which module was loaded last. */ interface LanguageIdentifier extends \BackedEnum { diff --git a/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php b/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php index 04b90c5afa30..31856706c826 100644 --- a/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php +++ b/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php @@ -25,29 +25,11 @@ */ class LanguageLegacyInitialisationAdapter implements Language { - public function txt(string $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string { return $this->getLegacyLanguageInstance()->txt($a_topic, $a_default_lang_fallback_mod); } - /** - * Not part of the interface Language, so other implementations do not break; without translate() - * the identifier is looked up via txt() and $n/$lang have no effect. - */ - public function translate(string|LanguageIdentifier $key, ?int $n = null, ?string $lang = null): string - { - $language = $this->getLegacyLanguageInstance(); - if ($language instanceof \ilLanguage) { - return $language->translate($key, $n, $lang); - } - if ($key instanceof LanguageIdentifier) { - $language->loadLanguageModule($key->module()); - return $language->txt((string) $key->value); - } - - return $language->txt($key); - } - public function loadLanguageModule(string $a_module): void { $this->getLegacyLanguageInstance()->loadLanguageModule($a_module); diff --git a/components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php b/components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php new file mode 100644 index 000000000000..6ba3695e0616 --- /dev/null +++ b/components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php @@ -0,0 +1,93 @@ + */ + public static function setupLookups(): iterable + { + yield 'string' => ['known', 'Bekannt']; + yield 'enum' => [IdentifierTxtSetupTestIdentifier::KNOWN, 'Bekannt']; + yield 'unknown string' => ['unknown', '-unknown-']; + yield 'unknown enum' => [IdentifierTxtSetupTestIdentifier::UNKNOWN, '-unknown-']; + yield 'empty string' => ['', '']; + yield 'empty enum' => [IdentifierTxtSetupTestIdentifier::EMPTY, '']; + } + + #[DataProvider('setupLookups')] + public function testSetupLanguageTxtServesTheValueOfAnEnum(string|LanguageIdentifier $key, string $expected): void + { + $GLOBALS['log'] = $this->createStub(ilLogger::class); + $language = new ilSetupLanguage('de'); + $language->text = ['known' => 'Bekannt']; + + $this->assertSame($expected, $language->txt($key)); + } + + public function testSetupLanguageNtxtOfAnEnumIsTxtOfItsValue(): void + { + $GLOBALS['log'] = $this->createStub(ilLogger::class); + $language = new ilSetupLanguage('de'); + $language->text = ['known' => 'Bekannt']; + + foreach ([0, 1, 5] as $n) { + $this->assertSame('Bekannt', $language->ntxt(IdentifierTxtSetupTestIdentifier::KNOWN, $n)); + $this->assertSame('-unknown-', $language->ntxt(IdentifierTxtSetupTestIdentifier::UNKNOWN, $n)); + } + $this->assertSame('', $language->ntxt(IdentifierTxtSetupTestIdentifier::EMPTY, 2)); + } + + public function testAdapterPassesEnumAndFallbackOnUnchanged(): void + { + $key = IdentifierTxtSetupTestIdentifier::KNOWN; + $inner = $this->createMock(Language::class); + $inner->expects($this->once())->method('txt')->with($this->identicalTo($key), 'fallback_mod')->willReturn('Bekannt'); + $inner->expects($this->never())->method('loadLanguageModule'); + $adapter = new class ($inner) extends LanguageLegacyInitialisationAdapter { + public function __construct(private readonly Language $language) + { + } + + protected function getLegacyLanguageInstance(): Language + { + return $this->language; + } + }; + + $this->assertSame('Bekannt', $adapter->txt($key, 'fallback_mod')); + } +} diff --git a/components/ILIAS/Language/tests/IdentifierTxtTest.php b/components/ILIAS/Language/tests/IdentifierTxtTest.php new file mode 100644 index 000000000000..a3c8f5f1a333 --- /dev/null +++ b/components/ILIAS/Language/tests/IdentifierTxtTest.php @@ -0,0 +1,537 @@ + the SQL every query of the ilDB stub received */ + private array $queries = []; + + /** @var list|null kind (modules|data), lang_key, module[, identifier] of the last query */ + private ?array $last_query = null; + + protected function setUp(): void + { + parent::setUp(); + + if (!defined('ILIAS_ABSOLUTE_PATH')) { + define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../')); + } + if (!defined('ILIAS_LOG_ENABLED')) { + define('ILIAS_LOG_ENABLED', true); // a missing text is logged + } + MigratedPoFixture::resetRuntime(); + MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); + + $this->fixture_directory = __DIR__ . '/tmp-identifier-txt-' . bin2hex(random_bytes(4)); + $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; + MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'de', self::catalog('mig_a', ['shared' => 'Mig A', 'only_a' => 'A'], ['Eintrag', 'Einträge'])); + MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'en', self::catalog('mig_a', ['shared' => 'Mig A en', 'only_en' => 'Only en'], ['Item', 'Items'])); + MigratedPoFixture::writeShippedPo($relative_path, 'mig_b', 'de', self::catalog('mig_b', ['shared' => 'Mig B'])); + + $this->setGlobalVariable( + LanguageFileDirectoryManager::class, + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + MigratedPoFixture::directory('mig_a', $relative_path), + MigratedPoFixture::directory('mig_b', $relative_path) + ) + ); + $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); + $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); + $factory = $this->createStub(ilLoggerFactory::class); + $factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); + $this->setGlobalVariable('ilLoggerFactory', $factory); + } + + protected function tearDown(): void + { + if ($this->fixture_directory !== null) { + MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . basename($this->fixture_directory)); + } + if (defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { + MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); + } + + parent::tearDown(); + } + + /** + * @param array $values + * @param list|null $item_forms the two forms of the plural message "item" (none: without) + */ + private static function catalog(string $module, array $values, ?array $item_forms = null): TranslationCatalog + { + $catalog = MigratedPoFixture::catalog($module, $values); + if ($item_forms !== null) { + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $entry = new TranslationEntry($module, 'item'); + $entry->setPlural('items', $item_forms); + $catalog->add($entry); + } + + return $catalog; + } + + /** @param array> $cached lng_modules of the language by module */ + private function language(array $cached = [], string $lang_key = 'de', string $default = 'en'): ilLanguage + { + global $DIC; + MigratedPoFixture::build($DIC[LanguageFileDirectoryManager::class], (string) constant('ILIAS_ABSOLUTE_PATH')); + $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); + $reflected = new ReflectionObject($language); + foreach (['lang_key', 'lang_user'] as $property) { + $reflected->getProperty($property)->setValue($language, $lang_key); + } + $reflected->getProperty('lang_default')->setValue($language, $default); + $reflected->getProperty('log')->setValue($language, $this->createStub(ilLogger::class)); + $reflected->getProperty('cached_modules')->setValue($language, $cached); + + return $language; + } + + /** + * lng_modules ("modules": "|" => values, a missing one has no row) and lng_data + * ("data": "||" => value) of the database stub; $this->queries records every query. + * + * @param array> $modules + * @param array $data + */ + private function stubDatabase(array $modules = [], array $data = []): void + { + $statement = $this->createStub(ilDBStatement::class); + $statement->method('fetchRow')->willReturnCallback(function () use ($modules) { + $key = $this->last_query[1] . '|' . $this->last_query[2]; + + return isset($modules[$key]) ? ['lang_array' => serialize($modules[$key])] : false; + }); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . (string) $value . "'"); + $db->method('query')->willReturnCallback(function (string $sql) use ($statement) { + $this->queries[] = $sql; + if (preg_match("/FROM lng_modules WHERE lang_key = '([^']*)' AND module = '([^']*)'/", $sql, $m) === 1) { + $this->last_query = ['modules', $m[1], $m[2]]; + } elseif (preg_match("/FROM lng_data WHERE module = '([^']*)' AND lang_key = '([^']*)' AND identifier = '([^']*)'/", $sql, $m) === 1) { + $this->last_query = ['data', $m[2], $m[1], $m[3]]; + } else { + $this->fail('unexpected query: ' . $sql); + } + + return $statement; + }); + $db->method('fetchAssoc')->willReturnCallback(function () use ($data) { + $value = $data[$this->last_query[1] . '|' . $this->last_query[2] . '|' . $this->last_query[3]] ?? null; + + return $value === null ? null : ['value' => $value]; + }); + $this->setGlobalVariable('ilDB', $db); + } + + /** @return array{loaded_modules: list, text: array, migrated_key_modules: mixed} */ + private static function state(ilLanguage $language): array + { + return [ + 'loaded_modules' => $language->loaded_modules, + 'text' => $language->text, + 'migrated_key_modules' => (new ReflectionProperty(ilLanguage::class, 'migrated_key_modules'))->getValue($language), + ]; + } + + /** @return array */ + private static function usageLog(): array + { + return (new ReflectionProperty(ilLanguage::class, 'lng_log'))->getValue(); + } + + private static function enableUsageLog(ilLanguage $language, bool $enabled = true): void + { + (new ReflectionProperty(ilLanguage::class, 'usage_log_enabled'))->setValue($language, $enabled); + } + + /** @return iterable */ + public static function migratedTexts(): iterable + { + yield 'own module, not mig_b' => [IdentifierTxtTestMigAIdentifier::SHARED, null, 'de', 'en', 'Mig A']; + yield 'same identifier, other module' => [IdentifierTxtTestMigBIdentifier::SHARED, null, 'de', 'en', 'Mig B']; + yield 'identifier only in mig_a' => [IdentifierTxtTestMigAIdentifier::ONLY_A, null, 'de', 'en', 'A']; + yield 'plural n=1' => [IdentifierTxtTestMigAIdentifier::ITEM, 1, 'de', 'en', 'Eintrag']; + yield 'plural n=0' => [IdentifierTxtTestMigAIdentifier::ITEM, 0, 'de', 'en', 'Einträge']; + yield 'plural n=5' => [IdentifierTxtTestMigAIdentifier::ITEM, 5, 'de', 'en', 'Einträge']; + yield 'plain identifier with n' => [IdentifierTxtTestMigAIdentifier::SHARED, 5, 'de', 'en', 'Mig A']; + yield 'other current language (build, not lng_data)' => [IdentifierTxtTestMigAIdentifier::SHARED, null, 'en', 'de', 'Mig A en']; + yield 'other current language, plural n=1' => [IdentifierTxtTestMigAIdentifier::ITEM, 1, 'en', 'de', 'Item']; + yield 'other current language, plural n=2' => [IdentifierTxtTestMigAIdentifier::ITEM, 2, 'en', 'de', 'Items']; + yield 'default language fallback' => [IdentifierTxtTestMigAIdentifier::ONLY_EN, null, 'de', 'en', 'Only en']; + yield 'default language fallback, other direction' => [IdentifierTxtTestMigAIdentifier::ONLY_A, null, 'en', 'de', 'A']; + yield 'missing everywhere' => [IdentifierTxtTestMigAIdentifier::MISSING, null, 'de', 'en', '-missing-']; + yield 'missing, plural' => [IdentifierTxtTestMigAIdentifier::MISSING, 3, 'en', 'de', '-missing-']; + } + + #[DataProvider('migratedTexts')] + public function testMigratedModuleIsReadDirectlyWithoutLoadingAnything(LanguageIdentifier $key, ?int $n, string $lang, string $default, string $expected): void + { + $this->forbidDatabaseQueries(); + $language = $this->language([], $lang, $default); + $before = self::state($language); + + $this->assertSame($expected, $n === null ? $language->txt($key) : $language->ntxt($key, $n)); + + $this->assertSame($before, self::state($language), 'no module is loaded, nothing is assigned to a module'); + } + + public function testTheFallbackModuleIsIgnoredForAnIdentifier(): void + { + $this->forbidDatabaseQueries(); + $language = $this->language(); + + $this->assertSame('-missing-', $language->txt(IdentifierTxtTestMigAIdentifier::MISSING, 'legacy')); + $this->assertSame('-missing-', $language->ntxt(IdentifierTxtTestMigAIdentifier::MISSING, 2, 'legacy')); + $this->assertSame('Mig A', $language->txt(IdentifierTxtTestMigAIdentifier::SHARED, 'mig_b'), 'its own module, not the named one'); + } + + private function forbidDatabaseQueries(): void + { + $db = $this->createMock(ilDBInterface::class); + $db->expects($this->never())->method('query'); + $this->setGlobalVariable('ilDB', $db); + } + + public function testTheOverlayWinsOverTheBuildForTextAndPluralForm(): void + { + $overlay = self::catalog('mig_a', ['shared' => 'Overlay A', 'only_a' => 'A'], ['Overlay-Eintrag', 'Overlay-Einträge']); + MigratedPoFixture::writePair(rtrim(CLIENT_DATA_DIR, '/') . '/lang/mig_a/de/mig_a_de', $overlay); + $language = $this->language(); + + $this->assertSame('Overlay A', $language->txt(IdentifierTxtTestMigAIdentifier::SHARED)); + $this->assertSame('Overlay-Eintrag', $language->ntxt(IdentifierTxtTestMigAIdentifier::ITEM, 1)); + $this->assertSame('Overlay-Einträge', $language->ntxt(IdentifierTxtTestMigAIdentifier::ITEM, 2)); + $this->assertSame('Mig B', $language->txt(IdentifierTxtTestMigBIdentifier::SHARED), 'another module is not affected'); + } + + public function testLegacyModuleServesItsOwnValueWhateverModuleWasLoadedLast(): void + { + $language = $this->language([ + 'legacy' => ['shared' => 'Legacy', 'zero' => '0'], + 'legacy2' => ['shared' => 'Legacy 2'], + ]); + $language->loadLanguageModule('mig_a'); + $language->loadLanguageModule('legacy'); + $language->loadLanguageModule('legacy2'); + $language->loadLanguageModule('mig_b'); + + $this->assertSame('Legacy', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + $this->assertSame('Legacy 2', $language->txt(IdentifierTxtTestLegacy2Identifier::SHARED)); + $this->assertSame('0', $language->txt(IdentifierTxtTestLegacyIdentifier::ZERO), '"0" is a value'); + $this->assertSame('Mig B', $language->txt('shared'), 'txt() still serves the module loaded last'); + $this->assertSame('Mig A', $language->txt(IdentifierTxtTestMigAIdentifier::SHARED)); + } + + public function testLegacyModuleWithoutAnyLoadedModule(): void + { + $language = $this->language(['legacy' => ['shared' => 'Legacy', 'blank' => '']]); + + $this->assertSame('Legacy', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + $this->assertSame('-missing-', $language->txt(IdentifierTxtTestLegacyIdentifier::MISSING)); + $this->assertSame('-blank-', $language->txt(IdentifierTxtTestLegacyIdentifier::BLANK), 'an empty value is no text'); + } + + public function testLegacyModuleHasNoSideEffectOnTxtAndTheModuleAssignment(): void + { + $language = $this->language(['legacy' => ['shared' => 'Legacy']]); + $language->loadLanguageModule('mig_a'); + $before = self::state($language); + $this->assertSame('mig_a', $before['migrated_key_modules']['shared'] ?? null); + + $this->assertSame('Legacy', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + + $this->assertSame($before, self::state($language), 'loaded_modules, text and the module assignment are unchanged'); + $this->assertSame('Mig A', $language->txt('shared'), 'loadLanguageModule("legacy") would have taken the identifier over'); + $this->assertSame(['mig_a'], $language->loaded_modules); + } + + public function testLegacyModuleReadsLngModulesOncePerModuleAndRequest(): void + { + $this->stubDatabase([ + 'de|legacy' => ['shared' => 'Legacy', 'zero' => '0'], + 'de|legacy2' => ['shared' => 'Legacy 2'], + ]); + $language = $this->language([], 'de', 'de'); + + $this->assertSame('Legacy', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + $this->assertSame('0', $language->txt(IdentifierTxtTestLegacyIdentifier::ZERO)); + $this->assertSame('-missing-', $language->txt(IdentifierTxtTestLegacyIdentifier::MISSING)); + $this->assertCount(1, $this->queries, 'one query for the module, whatever the identifier'); + $this->assertSame('Legacy 2', $language->txt(IdentifierTxtTestLegacy2Identifier::SHARED)); + $this->assertCount(2, $this->queries); + $this->assertSame('Legacy', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + $this->assertCount(2, $this->queries); + } + + public function testAModuleWithoutRowIsNotQueriedAgain(): void + { + $this->stubDatabase(); + $language = $this->language(); + + $this->assertSame('-shared-', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + $this->assertSame('-missing-', $language->txt(IdentifierTxtTestLegacyIdentifier::MISSING)); + + $modules_queries = array_filter($this->queries, static fn(string $sql): bool => str_contains($sql, 'lng_modules')); + $this->assertCount(1, $modules_queries); + } + + public function testLoadLanguageModuleAfterIdentifierLookupStillLoadsTheModule(): void + { + $this->stubDatabase(['de|legacy' => ['shared' => 'Legacy']]); + $language = $this->language(); + $this->assertSame('Legacy', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + + $language->loadLanguageModule('legacy'); + + $this->assertSame(['legacy'], $language->loaded_modules); + $this->assertSame('Legacy', $language->txt('shared')); + $this->assertSame('Legacy', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + } + + public function testLoadLanguageModuleReadsTheDatabaseOnlyForAModuleThatIsNotMigrated(): void + { + $this->stubDatabase(['de|legacy' => ['only_legacy' => 'L']]); + $language = $this->language(); + + $language->loadLanguageModule('mig_a'); + $this->assertSame([], $this->queries, 'a migrated module is never read from lng_modules'); + $language->loadLanguageModule('legacy'); + + $this->assertCount(1, $this->queries); + $this->assertSame('L', $language->txt('only_legacy')); + } + + public function testLegacyModuleFallsBackToTheDefaultLanguageFromLngData(): void + { + $this->stubDatabase(['fr|legacy' => ['shared' => 'Legacy fr', 'blank' => '']], [ + 'en|legacy|zero' => '0', + 'en|legacy|blank' => 'Blank en', + ]); + $language = $this->language([], 'fr', 'en'); + + $this->assertSame('Legacy fr', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED), 'the current language comes from lng_modules'); + $this->assertSame('0', $language->txt(IdentifierTxtTestLegacyIdentifier::ZERO), 'default language, value "0"'); + $this->assertSame('Blank en', $language->txt(IdentifierTxtTestLegacyIdentifier::BLANK), 'an empty value is no text'); + $this->assertSame('-missing-', $language->txt(IdentifierTxtTestLegacyIdentifier::MISSING)); + } + + public function testAMigratedModuleWithoutTheCurrentLanguageFallsBackToTheDefaultLanguageBuild(): void + { + $this->stubDatabase(); + $language = $this->language([], 'en', 'de'); + + $this->assertSame('Mig B', $language->txt(IdentifierTxtTestMigBIdentifier::SHARED), 'mig_b has only de'); + } + + public function testAMigratedModuleWithoutABuildInTheDefaultLanguageFallsBackToLngData(): void + { + $this->stubDatabase([], ['en|mig_b|only_en' => 'Only en from the database']); + $language = $this->language([], 'de', 'en'); + + $this->assertSame('Only en from the database', $language->txt(IdentifierTxtTestMigBIdentifier::ONLY_EN)); + $data_queries = array_filter($this->queries, static fn(string $sql): bool => str_contains($sql, 'lng_data')); + $this->assertCount(1, $data_queries); + } + + public function testAnInvalidDefaultLanguageIsNeverQueried(): void + { + $this->stubDatabase(); + $language = $this->language([], 'de', 'deu'); + + $this->assertSame('-only_en-', $language->txt(IdentifierTxtTestMigBIdentifier::ONLY_EN)); + foreach ($this->queries as $sql) { + $this->assertStringNotContainsString("'deu'", $sql); + } + } + + public function testPluralIdentifierWithoutNIsTheFormTxtServes(): void + { + $language = $this->language(); + $language->loadLanguageModule('mig_a'); + + $this->assertSame($language->txt('item'), $language->txt(IdentifierTxtTestMigAIdentifier::ITEM)); + } + + public function testNoFallbackQueryWhenTheLanguageIsTheDefaultLanguage(): void + { + $this->stubDatabase(); + $language = $this->language([], 'en', 'en'); + + $this->assertSame('-shared-', $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED)); + $this->assertCount(1, $this->queries, 'lng_modules once, no lng_data'); + } + + public function testAnEmptyIdentifierIsEmptyAndNotRecorded(): void + { + $this->forbidDatabaseQueries(); + $language = $this->language(); + self::enableUsageLog($language); + + $this->assertSame('', $language->txt(IdentifierTxtTestMigAIdentifier::EMPTY)); + $this->assertSame('', $language->ntxt(IdentifierTxtTestMigAIdentifier::EMPTY, 2)); + $this->assertSame('', $language->txt(IdentifierTxtTestMigAIdentifier::NUL)); + $this->assertSame('', $language->ntxt(IdentifierTxtTestMigAIdentifier::NUL, 1)); + + $this->assertSame([], $language->getUsedTopics()); + $this->assertSame([], $language->getUsedModules()); + $this->assertSame([], self::usageLog()); + self::enableUsageLog($language, false); + } + + public function testUsageIsRecordedForTopicsAlwaysAndForModulesAndLogOnlyOnAHit(): void + { + $language = $this->language(['legacy' => ['shared' => 'Legacy']]); + self::enableUsageLog($language); + + $language->txt(IdentifierTxtTestMigAIdentifier::MISSING); + $this->assertSame(['missing' => 'missing'], $language->getUsedTopics()); + $this->assertSame([], $language->getUsedModules(), 'no hit, no module'); + $this->assertSame([], self::usageLog()); + + $language->txt(IdentifierTxtTestMigAIdentifier::SHARED); + $language->txt(IdentifierTxtTestLegacyIdentifier::SHARED); + + $this->assertEqualsCanonicalizing(['mig_a' => 'mig_a', 'legacy' => 'legacy'], $language->getUsedModules()); + $this->assertSame(['shared' => 'legacy'], self::usageLog(), 'the identifier is logged for the module read last'); + $this->assertSame(['missing' => 'missing', 'shared' => 'shared'], $language->getUsedTopics()); + self::enableUsageLog($language, false); + } + + public function testUsageLogIsOffWhenDisabledAndRecordsTheModuleOfAFallbackHit(): void + { + $language = $this->language(); + + $language->txt(IdentifierTxtTestMigAIdentifier::ONLY_EN); + $this->assertSame([], self::usageLog(), 'usage log disabled'); + $this->assertSame(['mig_a' => 'mig_a'], $language->getUsedModules()); + + self::enableUsageLog($language); + $language->txt(IdentifierTxtTestMigAIdentifier::MISSING); + $this->assertSame([], self::usageLog(), 'a miss is not logged'); + $language->txt(IdentifierTxtTestMigAIdentifier::ONLY_EN); + $this->assertSame(['only_en' => 'mig_a'], self::usageLog()); + self::enableUsageLog($language, false); + } + + /** @return iterable */ + public static function lookupEntryValues(): iterable + { + yield 'value' => ['Wert', 'Wert']; + yield 'zero' => ['0', '0']; + yield 'empty' => ['', '-id-']; + yield 'no row' => [null, '-id-']; + } + + #[DataProvider('lookupEntryValues')] + public function testLookupEntryReadsLngData(?string $stored, string $expected): void + { + $this->stubDatabase([], $stored === null ? [] : ['fr|legacy|id' => $stored]); + $this->language(); + + $this->assertSame($expected, ilLanguage::_lookupEntry('fr', 'legacy', 'id')); + $this->assertCount(1, $this->queries); + } + + public function testLookupEntryRecordsTheUsageOnlyForAHit(): void + { + $this->stubDatabase([], ['fr|legacy|id' => 'Wert']); + $language = $this->language(); + + ilLanguage::_lookupEntry('fr', 'legacy', 'nothing'); + $this->assertSame([], $language->getUsedModules()); + ilLanguage::_lookupEntry('fr', 'legacy', 'id'); + $this->assertSame(['legacy' => 'legacy'], $language->getUsedModules()); + $this->assertSame(['id' => 'id'], $language->getUsedTopics()); + } +} diff --git a/components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php b/components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php deleted file mode 100644 index aa4d1761e881..000000000000 --- a/components/ILIAS/Language/tests/TranslateSetupAndAdapterTest.php +++ /dev/null @@ -1,123 +0,0 @@ - */ - public static function setupLookups(): iterable - { - yield 'string' => ['known', null, null, 'Bekannt']; - yield 'string, n and language have no effect' => ['known', 3, 'fr', 'Bekannt']; - yield 'enum' => [TranslateSetupTestIdentifier::KNOWN, null, null, 'Bekannt']; - yield 'enum, n and language have no effect' => [TranslateSetupTestIdentifier::KNOWN, 0, 'fr', 'Bekannt']; - yield 'unknown string' => ['unknown', null, null, '-unknown-']; - yield 'unknown enum' => [TranslateSetupTestIdentifier::UNKNOWN, 1, null, '-unknown-']; - yield 'empty string' => ['', null, null, '']; - yield 'empty enum' => [TranslateSetupTestIdentifier::EMPTY, null, null, '']; - } - - #[DataProvider('setupLookups')] - public function testSetupLanguageTranslateIsTxt(string|LanguageIdentifier $key, ?int $n, ?string $lang, string $expected): void - { - $GLOBALS['log'] = $this->createStub(ilLogger::class); - $language = new ilSetupLanguage('de'); - $language->text = ['known' => 'Bekannt']; - - $this->assertSame($expected, $language->translate($key, $n, $lang)); - if (is_string($key)) { - $this->assertSame($language->txt($key), $language->translate($key)); - } - } - - /** $DIC->language() only returns an ilLanguage, so another Language implementation is handed in by overriding the lookup. */ - private static function adapterOf(Language $language): LanguageLegacyInitialisationAdapter - { - return new class ($language) extends LanguageLegacyInitialisationAdapter { - public function __construct(private readonly Language $language) - { - } - - protected function getLegacyLanguageInstance(): Language - { - return $this->language; - } - }; - } - - public function testAdapterPassesEverythingOnToAnIlLanguage(): void - { - $key = TranslateSetupTestIdentifier::KNOWN; - $inner = $this->createMock(ilLanguage::class); - $inner->expects($this->exactly(2))->method('translate') - ->willReturnCallback(static fn(string|LanguageIdentifier $k, ?int $n, ?string $l): string => 'got ' . (is_string($k) ? $k : $k->value) . ' ' . var_export($n, true) . ' ' . var_export($l, true)); - $inner->expects($this->never())->method('loadLanguageModule'); - $this->setGlobalVariable('lng', $inner); - - $adapter = new LanguageLegacyInitialisationAdapter(); - - $this->assertSame("got known 2 'fr'", $adapter->translate($key, 2, 'fr')); - $this->assertSame('got plain NULL NULL', $adapter->translate('plain')); - } - - public function testAdapterWithoutIlLanguageLoadsTheModuleOfAnEnumAndUsesTxt(): void - { - $inner = $this->createMock(Language::class); - $calls = []; - $inner->expects($this->once())->method('loadLanguageModule') - ->willReturnCallback(function (string $module) use (&$calls): void { - $calls[] = 'load ' . $module; - }); - $inner->expects($this->once())->method('txt')->with('known') - ->willReturnCallback(function () use (&$calls): string { - $calls[] = 'txt'; - - return 'Bekannt'; - }); - - $this->assertSame('Bekannt', self::adapterOf($inner)->translate(TranslateSetupTestIdentifier::KNOWN, 4, 'fr')); - $this->assertSame(['load setup_module', 'txt'], $calls, 'the module is loaded before the lookup'); - } - - public function testAdapterWithoutIlLanguageLooksUpAStringWithoutLoadingAModule(): void - { - $inner = $this->createMock(Language::class); - $inner->expects($this->never())->method('loadLanguageModule'); - $inner->expects($this->once())->method('txt')->with('known')->willReturn('Bekannt'); - - $this->assertSame('Bekannt', self::adapterOf($inner)->translate('known')); - } -} diff --git a/components/ILIAS/Language/tests/TranslateTest.php b/components/ILIAS/Language/tests/TranslateTest.php deleted file mode 100644 index 052798a5610c..000000000000 --- a/components/ILIAS/Language/tests/TranslateTest.php +++ /dev/null @@ -1,698 +0,0 @@ - the SQL every query of the ilDB stub received */ - private array $queries = []; - - /** @var list|null kind (modules|data), lang_key, module[, identifier] of the last query */ - private ?array $last_query = null; - - protected function setUp(): void - { - parent::setUp(); - - if (!defined('ILIAS_ABSOLUTE_PATH')) { - define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../')); - } - if (!defined('ILIAS_LOG_ENABLED')) { - define('ILIAS_LOG_ENABLED', true); // translate() logs a missing text - } - MigratedPoFixture::resetRuntime(); - MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); - - $this->fixture_directory = __DIR__ . '/tmp-translate-' . bin2hex(random_bytes(4)); - $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; - MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'de', self::catalog('mig_a', ['shared' => 'Mig A', 'only_a' => 'A'], ['Eintrag', 'Einträge'])); - MigratedPoFixture::writeShippedPo($relative_path, 'mig_a', 'en', self::catalog('mig_a', ['shared' => 'Mig A en', 'only_en' => 'Only en'], ['Item', 'Items'])); - MigratedPoFixture::writeShippedPo($relative_path, 'mig_b', 'de', self::catalog('mig_b', ['shared' => 'Mig B'])); - - $this->setGlobalVariable( - LanguageFileDirectoryManager::class, - new LanguageFileDirectoryManager( - new CustomizingLanguageFileDirectory(), - MigratedPoFixture::directory('mig_a', $relative_path), - MigratedPoFixture::directory('mig_b', $relative_path) - ) - ); - $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); - $this->setGlobalVariable('ilDB', $this->createStub(ilDBInterface::class)); - $factory = $this->createStub(ilLoggerFactory::class); - $factory->method('getComponentLogger')->willReturn($this->createStub(ilLogger::class)); - $this->setGlobalVariable('ilLoggerFactory', $factory); - } - - protected function tearDown(): void - { - if ($this->fixture_directory !== null) { - MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . basename($this->fixture_directory)); - } - if (defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) { - MigratedPoFixture::removeDirectory(CLIENT_DATA_DIR); - } - - parent::tearDown(); - } - - /** - * @param array $values - * @param list|null $item_forms the two forms of the plural message "item" (none: without) - */ - private static function catalog(string $module, array $values, ?array $item_forms = null): TranslationCatalog - { - $catalog = MigratedPoFixture::catalog($module, $values); - if ($item_forms !== null) { - $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); - $entry = new TranslationEntry($module, 'item'); - $entry->setPlural('items', $item_forms); - $catalog->add($entry); - } - - return $catalog; - } - - /** @param array> $cached lng_modules of the language by module (the cache the constructor reads: cached_modules) */ - private function language(array $cached = [], string $lang_key = 'de', string $default = 'en'): ilLanguage - { - global $DIC; - MigratedPoFixture::build($DIC[LanguageFileDirectoryManager::class], (string) constant('ILIAS_ABSOLUTE_PATH')); - $language = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor(); - $reflected = new ReflectionObject($language); - foreach (['lang_key', 'lang_user'] as $property) { - $reflected->getProperty($property)->setValue($language, $lang_key); - } - $reflected->getProperty('lang_default')->setValue($language, $default); - $reflected->getProperty('log')->setValue($language, $this->createStub(ilLogger::class)); - $reflected->getProperty('cached_modules')->setValue($language, $cached); - - return $language; - } - - /** - * lng_modules ("modules": "|" => values, a missing one has no row) and lng_data - * ("data": "||" => value) of the database stub; $this->queries records every query. - * - * @param array> $modules - * @param array $data - */ - private function stubDatabase(array $modules = [], array $data = []): void - { - $statement = $this->createStub(ilDBStatement::class); - $statement->method('fetchRow')->willReturnCallback(function () use ($modules) { - $key = $this->last_query[1] . '|' . $this->last_query[2]; - - return isset($modules[$key]) ? ['lang_array' => serialize($modules[$key])] : false; - }); - $db = $this->createStub(ilDBInterface::class); - $db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . (string) $value . "'"); - $db->method('query')->willReturnCallback(function (string $sql) use ($statement) { - $this->queries[] = $sql; - if (preg_match("/FROM lng_modules WHERE lang_key = '([^']*)' AND module = '([^']*)'/", $sql, $m) === 1) { - $this->last_query = ['modules', $m[1], $m[2]]; - } elseif (preg_match("/FROM lng_data WHERE module = '([^']*)' AND lang_key = '([^']*)' AND identifier = '([^']*)'/", $sql, $m) === 1) { - $this->last_query = ['data', $m[2], $m[1], $m[3]]; - } else { - $this->fail('unexpected query: ' . $sql); - } - - return $statement; - }); - $db->method('fetchAssoc')->willReturnCallback(function () use ($data) { - $value = $data[$this->last_query[1] . '|' . $this->last_query[2] . '|' . $this->last_query[3]] ?? null; - - return $value === null ? null : ['value' => $value]; - }); - $this->setGlobalVariable('ilDB', $db); - } - - /** @return array{loaded_modules: list, text: array, migrated_key_modules: mixed} */ - private static function state(ilLanguage $language): array - { - return [ - 'loaded_modules' => $language->loaded_modules, - 'text' => $language->text, - 'migrated_key_modules' => (new ReflectionProperty(ilLanguage::class, 'migrated_key_modules'))->getValue($language), - ]; - } - - /** @return array */ - private static function usageLog(): array - { - return (new ReflectionProperty(ilLanguage::class, 'lng_log'))->getValue(); - } - - private static function enableUsageLog(ilLanguage $language, bool $enabled = true): void - { - (new ReflectionProperty(ilLanguage::class, 'usage_log_enabled'))->setValue($language, $enabled); - } - - /** @return iterable */ - public static function migratedTexts(): iterable - { - yield 'own module, not mig_b' => [TranslateTestMigAIdentifier::SHARED, null, null, 'Mig A']; - yield 'same identifier, other module' => [TranslateTestMigBIdentifier::SHARED, null, null, 'Mig B']; - yield 'identifier only in mig_a' => [TranslateTestMigAIdentifier::ONLY_A, null, null, 'A']; - yield 'plural n=1' => [TranslateTestMigAIdentifier::ITEM, 1, null, 'Eintrag']; - yield 'plural n=0' => [TranslateTestMigAIdentifier::ITEM, 0, null, 'Einträge']; - yield 'plural n=5' => [TranslateTestMigAIdentifier::ITEM, 5, null, 'Einträge']; - yield 'plain identifier with n' => [TranslateTestMigAIdentifier::SHARED, 5, null, 'Mig A']; - yield 'other language (build, not lng_data)' => [TranslateTestMigAIdentifier::SHARED, null, 'en', 'Mig A en']; - yield 'other language, plural n=1' => [TranslateTestMigAIdentifier::ITEM, 1, 'en', 'Item']; - yield 'other language, plural n=2' => [TranslateTestMigAIdentifier::ITEM, 2, 'en', 'Items']; - yield 'current language given explicitly' => [TranslateTestMigAIdentifier::SHARED, null, 'de', 'Mig A']; - yield 'default language fallback' => [TranslateTestMigAIdentifier::ONLY_EN, null, null, 'Only en']; - yield 'missing everywhere' => [TranslateTestMigAIdentifier::MISSING, null, null, '-missing-']; - yield 'missing, other language' => [TranslateTestMigAIdentifier::MISSING, 3, 'en', '-missing-']; - } - - #[DataProvider('migratedTexts')] - public function testMigratedModuleIsReadDirectlyWithoutLoadingAnything(LanguageIdentifier $key, ?int $n, ?string $lang, string $expected): void - { - $this->forbidDatabaseQueries(); - $language = $this->language(); - $before = self::state($language); - - $this->assertSame($expected, $language->translate($key, $n, $lang)); - - $this->assertSame($before, self::state($language), 'no module is loaded, nothing is assigned to a module'); - } - - private function forbidDatabaseQueries(): void - { - $db = $this->createMock(ilDBInterface::class); - $db->expects($this->never())->method('query'); - $this->setGlobalVariable('ilDB', $db); - } - - public function testTheOverlayWinsOverTheBuildForTextAndPluralForm(): void - { - $overlay = self::catalog('mig_a', ['shared' => 'Overlay A', 'only_a' => 'A'], ['Overlay-Eintrag', 'Overlay-Einträge']); - MigratedPoFixture::writePair(rtrim(CLIENT_DATA_DIR, '/') . '/lang/mig_a/de/mig_a_de', $overlay); - $language = $this->language(); - - $this->assertSame('Overlay A', $language->translate(TranslateTestMigAIdentifier::SHARED)); - $this->assertSame('Overlay-Eintrag', $language->translate(TranslateTestMigAIdentifier::ITEM, 1)); - $this->assertSame('Overlay-Einträge', $language->translate(TranslateTestMigAIdentifier::ITEM, 2)); - $this->assertSame('Mig B', $language->translate(TranslateTestMigBIdentifier::SHARED), 'another module is not affected'); - } - - public function testLegacyModuleServesItsOwnValueWhateverModuleWasLoadedLast(): void - { - $language = $this->language([ - 'legacy' => ['shared' => 'Legacy', 'zero' => '0'], - 'legacy2' => ['shared' => 'Legacy 2'], - ]); - $language->loadLanguageModule('mig_a'); - $language->loadLanguageModule('legacy'); - $language->loadLanguageModule('legacy2'); - $language->loadLanguageModule('mig_b'); - - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - $this->assertSame('Legacy 2', $language->translate(TranslateTestLegacy2Identifier::SHARED)); - $this->assertSame('0', $language->translate(TranslateTestLegacyIdentifier::ZERO), '"0" is a value'); - $this->assertSame('Mig B', $language->txt('shared'), 'txt() still serves the module loaded last'); - $this->assertSame('Mig A', $language->translate(TranslateTestMigAIdentifier::SHARED)); - } - - public function testLegacyModuleWithoutAnyLoadedModule(): void - { - $language = $this->language(['legacy' => ['shared' => 'Legacy', 'blank' => '']]); - - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING)); - $this->assertSame('-blank-', $language->translate(TranslateTestLegacyIdentifier::BLANK), 'an empty value is no text'); - } - - public function testLegacyModuleHasNoSideEffectOnTxtAndTheModuleAssignment(): void - { - $language = $this->language(['legacy' => ['shared' => 'Legacy']]); - $language->loadLanguageModule('mig_a'); - $before = self::state($language); - $this->assertSame('mig_a', $before['migrated_key_modules']['shared'] ?? null); - - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - - $this->assertSame($before, self::state($language), 'loaded_modules, text and the module assignment are unchanged'); - $this->assertSame('Mig A', $language->txt('shared'), 'loadLanguageModule("legacy") would have taken the identifier over'); - $this->assertSame(['mig_a'], $language->loaded_modules); - } - - public function testLegacyModuleReadsLngModulesOncePerModuleAndRequest(): void - { - $this->stubDatabase([ - 'de|legacy' => ['shared' => 'Legacy', 'zero' => '0'], - 'de|legacy2' => ['shared' => 'Legacy 2'], - ]); - $language = $this->language([], 'de', 'de'); - - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - $this->assertSame('0', $language->translate(TranslateTestLegacyIdentifier::ZERO)); - $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING)); - $this->assertCount(1, $this->queries, 'one query for the module, whatever the identifier'); - $this->assertSame('Legacy 2', $language->translate(TranslateTestLegacy2Identifier::SHARED)); - $this->assertCount(2, $this->queries); - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - $this->assertCount(2, $this->queries); - } - - public function testAModuleWithoutRowIsNotQueriedAgain(): void - { - $this->stubDatabase(); - $language = $this->language(); - - $this->assertSame('-shared-', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING)); - - $modules_queries = array_filter($this->queries, static fn(string $sql): bool => str_contains($sql, 'lng_modules')); - $this->assertCount(1, $modules_queries); - } - - public function testLoadLanguageModuleAfterTranslateStillLoadsTheModule(): void - { - $this->stubDatabase(['de|legacy' => ['shared' => 'Legacy']]); - $language = $this->language(); - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - - $language->loadLanguageModule('legacy'); - - $this->assertSame(['legacy'], $language->loaded_modules); - $this->assertSame('Legacy', $language->txt('shared')); - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - } - - public function testLoadLanguageModuleReadsTheDatabaseOnlyForAModuleThatIsNotMigrated(): void - { - $this->stubDatabase(['de|legacy' => ['only_legacy' => 'L']]); - $language = $this->language(); - - $language->loadLanguageModule('mig_a'); - $this->assertSame([], $this->queries, 'a migrated module is never read from lng_modules'); - $language->loadLanguageModule('legacy'); - - $this->assertCount(1, $this->queries); - $this->assertSame('L', $language->txt('only_legacy')); - } - - public function testLegacyModuleInAnotherLanguageReadsLngDataAndFallsBackToTheDefaultLanguage(): void - { - $this->stubDatabase(['de|legacy' => ['shared' => 'Legacy de']], [ - 'fr|legacy|shared' => 'Legacy fr', - 'en|legacy|zero' => '0', - 'en|legacy|blank' => 'Blank en', - ]); - $language = $this->language(); - - $this->assertSame('Legacy fr', $language->translate(TranslateTestLegacyIdentifier::SHARED, null, 'fr')); - $this->assertSame('0', $language->translate(TranslateTestLegacyIdentifier::ZERO, null, 'fr'), 'default language, value "0"'); - $this->assertSame('Blank en', $language->translate(TranslateTestLegacyIdentifier::BLANK, null, 'fr')); - $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING, null, 'fr')); - $this->assertSame('Legacy de', $language->translate(TranslateTestLegacyIdentifier::SHARED), 'the current language still comes from lng_modules'); - } - - public function testAMigratedModuleWithoutThatLanguageIsReadFromLngData(): void - { - $this->stubDatabase([], ['en|mig_b|shared' => 'Mig B en']); - $language = $this->language(); - - $this->assertSame('Mig B en', $language->translate(TranslateTestMigBIdentifier::SHARED, null, 'en')); - $this->assertSame('Mig B', $language->translate(TranslateTestMigBIdentifier::SHARED), 'de is migrated: the build'); - } - - public function testPluralWithoutNIsTheFormTxtServes(): void - { - $language = $this->language(); - $language->loadLanguageModule('mig_a'); - - $this->assertSame($language->txt('item'), $language->translate(TranslateTestMigAIdentifier::ITEM)); - $this->assertSame($language->txt('item'), $language->translate(TranslateTestMigAIdentifier::ITEM, null, 'de')); - } - - public function testLegacyModuleOfTheCurrentLanguageFallsBackToTheDefaultLanguageFromLngData(): void - { - $this->stubDatabase(['de|legacy' => ['shared' => '']], ['en|legacy|shared' => 'Legacy en']); - $language = $this->language(); - - $this->assertSame('Legacy en', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - } - - public function testNoFallbackQueryWhenTheLanguageIsTheDefaultLanguage(): void - { - $this->stubDatabase(); - $language = $this->language([], 'en', 'en'); - - $this->assertSame('-shared-', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - $this->assertSame('-shared-', $language->translate(TranslateTestLegacyIdentifier::SHARED, null, 'en')); - $this->assertCount(1, $this->queries, 'lng_modules once, no lng_data'); - } - - /** @return iterable, string, ?int}> */ - public static function stringLookups(): iterable - { - foreach ([ - 'migrated then legacy' => ['mig_a', 'legacy'], - 'legacy then migrated' => ['legacy', 'mig_a'], - 'two migrated' => ['mig_a', 'mig_b'], - 'nothing loaded' => [], - ] as $label => $modules) { - foreach (['shared', 'only_a', 'item', 'zero', 'missing', 'direct'] as $identifier) { - foreach ([null, 1, 5] as $n) { - yield $label . ' / ' . $identifier . ' / ' . var_export($n, true) => [$modules, $identifier, $n]; - } - } - } - } - - /** @param list $modules */ - #[DataProvider('stringLookups')] - public function testAStringBehavesLikeTxtAndNtxt(array $modules, string $identifier, ?int $n): void - { - $language = $this->language(['legacy' => ['shared' => 'Legacy', 'zero' => '0']]); - $language->text['direct'] = 'Direct'; - foreach ($modules as $module) { - $language->loadLanguageModule($module); - } - $expected = $n === null ? $language->txt($identifier) : $language->ntxt($identifier, $n); - - $this->assertSame($expected, $language->translate($identifier, $n)); - $this->assertSame($expected, $language->translate($identifier, $n, 'de'), 'the current language given explicitly'); - } - - public function testAStringInAnotherLanguageUsesTheModuleResponsibleAndNeverAnEarlierOne(): void - { - $language = $this->language(); - $language->loadLanguageModule('mig_a'); - $language->loadLanguageModule('mig_b'); - - $this->assertSame('-shared-', $language->translate('shared', null, 'en')); - - $reverse = $this->language(); - $reverse->loadLanguageModule('mig_b'); - $reverse->loadLanguageModule('mig_a'); - $this->assertSame('Mig A en', $reverse->translate('shared', null, 'en')); - $this->assertSame('Items', $reverse->translate('item', 2, 'en')); - $this->assertSame('Item', $reverse->translate('item', 1, 'en')); - } - - /** Exactly one lookup, in the module txt() serves the identifier from (the one loaded last containing it), never the default language. */ - public function testAStringInAnotherLanguageReadsOnlyTheLegacyModuleTxtServesItFrom(): void - { - $this->stubDatabase([ - 'de|legacy' => ['shared' => 'x', 'only_first' => 'x', 'both' => 'x'], - 'de|legacy2' => ['shared' => 'x', 'both' => 'x'], - ], [ - 'fr|legacy|shared' => 'Legacy fr', - 'fr|legacy2|shared' => 'Legacy 2 fr', - 'fr|legacy|only_first' => 'First only fr', - 'fr|legacy|both' => 'Earlier module fr', - 'en|legacy|nowhere' => 'Default language', - ]); - $language = $this->language(); - $language->loadLanguageModule('legacy'); - $language->loadLanguageModule('legacy2'); - $lng_data_queries = function (): array { - return array_values(array_filter($this->queries, static fn(string $sql): bool => str_contains($sql, 'lng_data'))); - }; - - $this->queries = []; - $this->assertSame('Legacy 2 fr', $language->translate('shared', null, 'fr'), 'the module loaded last wins, as in txt()'); - $this->assertCount(1, $lng_data_queries(), 'one lookup'); - - $this->queries = []; - $this->assertSame('First only fr', $language->translate('only_first', null, 'fr'), 'the only module that contains it'); - $this->assertCount(1, $lng_data_queries()); - - $this->queries = []; - $this->assertSame('-both-', $language->translate('both', null, 'fr'), 'legacy2 is responsible and has no "fr" - never the earlier module'); - $this->assertCount(1, $lng_data_queries()); - - $this->queries = []; - $this->assertSame('-nowhere-', $language->translate('nowhere', null, 'fr'), 'no module responsible, no fallback to the default language'); - $this->assertSame([], $lng_data_queries(), 'no lookup without a responsible module'); - } - - public function testAStringInAnotherLanguageIgnoresTheLegacyModulesOfAMigratedIdentifier(): void - { - $this->stubDatabase([], ['fr|legacy|shared' => 'Legacy fr']); - $language = $this->language(['legacy' => ['shared' => 'Legacy']]); - $language->loadLanguageModule('legacy'); - $language->loadLanguageModule('mig_a'); - - $this->assertSame('Mig A', $language->txt('shared')); - $this->assertSame('-shared-', $language->translate('shared', null, 'fr'), 'mig_a is responsible, it has no "fr"'); - } - - public function testTheResponsibleModuleOfAStringFollowsTheLoadingOrderOfMigratedAndLegacyModules(): void - { - $this->stubDatabase([], ['en|legacy|shared' => 'Legacy en']); - - $migrated_last = $this->language(['legacy' => ['shared' => 'Legacy']]); - $migrated_last->loadLanguageModule('legacy'); - $migrated_last->loadLanguageModule('mig_a'); - $this->queries = []; - $this->assertSame('Mig A en', $migrated_last->translate('shared', null, 'en')); - $this->assertSame([], $this->queries, 'a migrated module is read from the build, not from lng_data'); - - $legacy_last = $this->language(['legacy' => ['shared' => 'Legacy']]); - $legacy_last->loadLanguageModule('mig_a'); - $legacy_last->loadLanguageModule('legacy'); - $this->assertSame('Legacy', $legacy_last->txt('shared')); - $this->assertSame('Legacy en', $legacy_last->translate('shared', null, 'en')); - $this->assertCount(1, $this->queries, 'exactly one lookup, in the module loaded last'); - $this->assertStringContainsString("module = 'legacy'", $this->queries[0]); - } - - public function testAModuleOnlyReadThroughAnIdentifierIsNotResponsibleForAString(): void - { - $this->stubDatabase([], ['fr|legacy|shared' => 'Legacy fr']); - $language = $this->language(['legacy' => ['shared' => 'Legacy']]); - - $this->assertSame('Legacy', $language->translate(TranslateTestLegacyIdentifier::SHARED)); - $this->queries = []; - - $this->assertSame('-shared-', $language->translate('shared', null, 'fr')); - $this->assertSame([], $this->queries, 'no module loaded, so none responsible: no lookup at all'); - } - - /** @return iterable */ - public static function invalidLanguageKeys(): iterable - { - foreach (['../x', 'DE', '', 'deu', 'd', 'de/', "fr' OR '1", 'x.'] as $lang) { - yield var_export($lang, true) => [$lang]; - } - } - - #[DataProvider('invalidLanguageKeys')] - public function testAnInvalidLanguageIsNeverLookedUp(string $lang): void - { - $this->stubDatabase(['de|legacy' => ['shared' => 'Legacy']], ['en|legacy|shared' => 'Legacy en']); - $language = $this->language(); - $language->loadLanguageModule('mig_a'); - $language->loadLanguageModule('legacy'); - $language->loadLanguageModule('mig_b'); - $this->queries = []; - - $this->assertSame('-shared-', $language->translate('shared', null, $lang), 'string: no text'); - $this->assertSame('Mig A en', $language->translate(TranslateTestMigAIdentifier::SHARED, null, $lang), 'migrated enum: default language'); - $this->assertSame('Mig A en', $language->translate(TranslateTestMigAIdentifier::SHARED, 1, $lang)); - $this->assertSame('Legacy en', $language->translate(TranslateTestLegacyIdentifier::SHARED, null, $lang), 'legacy enum: default language'); - $this->assertSame('-missing-', $language->translate(TranslateTestLegacyIdentifier::MISSING, null, $lang)); - - foreach ($this->queries as $sql) { - $this->assertStringNotContainsString("lang_key = '" . $lang . "'", $sql); - $this->assertStringContainsString("lang_key = 'en'", $sql, 'only the default language is queried'); - } - } - - public function testAnInvalidLanguageIsLoggedWithoutItsValue(): void - { - $logger = $this->createStub(ilLogger::class); - $messages = []; - $logger->method('debug')->willReturnCallback(static function (string $message) use (&$messages): void { - $messages[] = $message; - }); - $language = $this->language(); - (new ReflectionProperty(ilLanguage::class, 'log'))->setValue($language, $logger); - - $language->translate('nothing', null, "../x\nINJECTED"); - - $this->assertCount(1, $messages); - $this->assertStringNotContainsString('INJECTED', $messages[0]); - } - - public function testAnEmptyIdentifierIsEmptyAndNotRecorded(): void - { - $this->forbidDatabaseQueries(); - $language = $this->language(); - self::enableUsageLog($language); - - $this->assertSame('', $language->translate('')); - $this->assertSame('', $language->translate('', 3, 'fr')); - $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::EMPTY)); - $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::EMPTY, 2, 'en')); - $this->assertSame('', $language->translate('0'), 'empty() as in txt()'); - $this->assertSame('', $language->translate('0', 2, 'fr')); - $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::NUL)); - $this->assertSame('', $language->translate(TranslateTestMigAIdentifier::NUL, 1, 'en')); - - $this->assertSame([], $language->getUsedTopics()); - $this->assertSame([], $language->getUsedModules()); - $this->assertSame([], self::usageLog()); - self::enableUsageLog($language, false); - } - - public function testUsageIsRecordedForTopicsAlwaysAndForModulesAndLogOnlyOnAHit(): void - { - $language = $this->language(['legacy' => ['shared' => 'Legacy']]); - self::enableUsageLog($language); - - $language->translate(TranslateTestMigAIdentifier::MISSING); - $this->assertSame(['missing' => 'missing'], $language->getUsedTopics()); - $this->assertSame([], $language->getUsedModules(), 'no hit, no module'); - $this->assertSame([], self::usageLog()); - - $language->translate(TranslateTestMigAIdentifier::SHARED); - $language->translate(TranslateTestLegacyIdentifier::SHARED); - - $this->assertEqualsCanonicalizing(['mig_a' => 'mig_a', 'legacy' => 'legacy'], $language->getUsedModules()); - $this->assertSame(['shared' => 'legacy'], self::usageLog(), 'the identifier is logged for the module read last'); - $this->assertSame(['missing' => 'missing', 'shared' => 'shared'], $language->getUsedTopics()); - self::enableUsageLog($language, false); - } - - public function testUsageLogIsOffWhenDisabledAndRecordsTheModuleOfAFallbackHit(): void - { - $language = $this->language(); - - $language->translate(TranslateTestMigAIdentifier::ONLY_EN); - $this->assertSame([], self::usageLog(), 'usage log disabled'); - $this->assertSame(['mig_a' => 'mig_a'], $language->getUsedModules()); - - self::enableUsageLog($language); - $language->translate('only_a', null, 'en'); - $this->assertSame([], self::usageLog(), 'a miss is not logged'); - $language->loadLanguageModule('mig_a'); - $language->translate('only_a'); - $this->assertSame(['only_a' => 'mig_a'], self::usageLog()); - self::enableUsageLog($language, false); - } - - /** @return iterable */ - public static function lookupEntryValues(): iterable - { - yield 'value' => ['Wert', 'Wert']; - yield 'zero' => ['0', '0']; - yield 'empty' => ['', '-id-']; - yield 'no row' => [null, '-id-']; - } - - #[DataProvider('lookupEntryValues')] - public function testLookupEntryReadsLngData(?string $stored, string $expected): void - { - $this->stubDatabase([], $stored === null ? [] : ['fr|legacy|id' => $stored]); - $this->language(); - - $this->assertSame($expected, ilLanguage::_lookupEntry('fr', 'legacy', 'id')); - $this->assertCount(1, $this->queries); - } - - public function testLookupEntryRecordsTheUsageOnlyForAHit(): void - { - $this->stubDatabase([], ['fr|legacy|id' => 'Wert']); - $language = $this->language(); - - ilLanguage::_lookupEntry('fr', 'legacy', 'nothing'); - $this->assertSame([], $language->getUsedModules()); - ilLanguage::_lookupEntry('fr', 'legacy', 'id'); - $this->assertSame(['legacy' => 'legacy'], $language->getUsedModules()); - $this->assertSame(['id' => 'id'], $language->getUsedTopics()); - } -} diff --git a/components/ILIAS/Language/tools/language-identifier-enum/README.md b/components/ILIAS/Language/tools/language-identifier-enum/README.md index 24ac13cf0fe0..62fbc2876e05 100644 --- a/components/ILIAS/Language/tools/language-identifier-enum/README.md +++ b/components/ILIAS/Language/tools/language-identifier-enum/README.md @@ -2,12 +2,12 @@ `generate_language_identifier_enum.php` generates the enum of the language identifiers of one module from the module's `.pot` - an implementation of `ILIAS\Language\LanguageIdentifier`, to be passed to -`ilLanguage::translate()` (see the README of the Language component) - and checks an existing enum +`txt()` or `ntxt()` (see the README of the Language component) - and checks an existing enum against the `.pot` (`--check`, for CI). ```php -$this->lng->translate(PollLanguageIdentifier::POLL_ANSWERS); // no loadLanguageModule('poll') needed -$this->lng->translate(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count +$this->lng->txt(PollLanguageIdentifier::POLL_ANSWERS); // no loadLanguageModule('poll') needed +$this->lng->ntxt(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count ``` The enum is generated, not written by hand: it lists exactly the msgids of the `.pot`, so a key diff --git a/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php b/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php index 1ead62b46a62..133b2bfb8fa1 100644 --- a/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php +++ b/components/ILIAS/Language/tools/language-identifier-enum/generate_language_identifier_enum.php @@ -20,7 +20,7 @@ /** * Generates the enum of the language identifiers of one module (an implementation of - * ILIAS\Language\LanguageIdentifier, see \ilLanguage::translate()) from the module's `.pot`, or checks an + * ILIAS\Language\LanguageIdentifier, see Language::txt()) from the module's `.pot`, or checks an * existing one against it (--check, for CI). See README.md next to this file. * * Usage: php generate_language_identifier_enum.php [--check | --stdout] [--module=] From 6e33e0bad1439a36d28a1880cbc61c6ce518b276 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:17 +0200 Subject: [PATCH 050/103] Language: Remove the unused scope handling from `ilObjLanguage` Remove `install()`, `isLocal()` and `refreshAll()`, which have no callers since the installation runs through `LanguageInstallationManager`, drop the ineffective `$scope` of `insert()` and replace `check(string $scope)` by `check(bool $shipped_only)`. BREAKING: external callers of these `ilObjLanguage` methods must switch to the language activities. --- .../Language/classes/class.ilObjLanguage.php | 100 ++---------------- .../Language/tests/ilObjLanguageTest.php | 13 +++ 2 files changed, 21 insertions(+), 92 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguage.php b/components/ILIAS/Language/classes/class.ilObjLanguage.php index 6bcf1dbea556..a01da0ace854 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -205,59 +205,6 @@ public function isInstalled(): bool } } - /** - * Check language object status, and return true if a local language file - * is installed. - * - * Return true if local language is installed - */ - public function isLocal(): bool - { - if (substr($this->getStatus(), 10) === "local") { - return true; - } else { - return false; - } - } - - /** - * install current language - * - * $scope empty (global) or "local" - * Return installed language key - */ - public function install(string $scope = ""): string - { - if ($scope === "global") { - $scope = ""; - } - - if (!$this->isInstalled() || (!$this->isLocal() && !empty($scope))) { - if ($this->check($scope)) { - // lang-file is ok. Flush data in db and... - if (empty($scope)) { - $this->flush("keep_local"); - } - - // ...re-insert data from lang-file - $this->insert($scope); - - // update information in db-table about available/installed languages - $newDesc = ''; - if (empty($scope)) { - $newDesc = "installed"; - } elseif ($scope === "local") { - $newDesc = "installed_local"; - } - $this->setDescription($newDesc); - $this->update(); - return $this->getKey(); - } - } - return ""; - } - - /** * uninstall current language * @@ -341,29 +288,6 @@ public function refresh(): bool return false; } - /** - * 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"); - $refreshed = array(); - - foreach ($languages as $lang) { - $langObj = new ilObjLanguage($lang["obj_id"], false); - if ($langObj->refresh()) { - $refreshed[] = $langObj->getKey(); - } - unset($langObj); - } - - self::refreshPlugins($refreshed); - } - - /** * Refresh languages of activated plugins * $a_lang_keys keys of languages to be refreshed (not yet supported, all available will be refreshed) @@ -572,19 +496,16 @@ public static function _getLocalChangesByModule(string $a_key, string $a_module) /** - * insert language data from file into database - * - * @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. + * insert language data from file into database, customizing included */ - public function insert(string $scope = ""): void + public function insert(): void { $this->modules_with_unwritten_overlay = $this->manager->insertLanguageForInstallation($this->key); } /** - * Modules whose PO/MO overlay could not be written by the last insert()/install()/refresh()/removeLocalChanges() - * (the database was written regardless) or removed by the last uninstall(). + * Modules whose PO/MO overlay could not be written or removed by the last operation (the database was written + * regardless). * * @return list */ @@ -602,7 +523,7 @@ public function getModulesWithUnwrittenOverlay(): array public function removeLocalChanges(): bool { // The customizing file is not applied here - an invalid one must not block the reset - if (!$this->isInstalled() || !$this->check("shipped")) { + if (!$this->isInstalled() || !$this->check(true)) { return false; } @@ -916,18 +837,13 @@ public function optimizeData(): bool * header, and each lang-entry consists of exactly three elements * (module, identifier, value). * - * $scope empty/"global" (all managed directories), "local" - * (customizing directory only) or "shipped" (all but the customizing directory, see - * removeLocalChanges(); like "global" for another repository implementation) + * $shipped_only all but the customizing directory (see removeLocalChanges()) * * Delegates to InstalledLanguageRepository; callers (e.g. ilObjLanguageFolderGUI) turn a `false` into user feedback. */ - public function check(string $scope = ""): bool + public function check(bool $shipped_only = false): bool { - if ($scope === "local") { - return $this->repository->checkLocalLanguageFile($this->key); - } - if ($scope === "shipped" && $this->repository instanceof InstalledLanguageDatabaseRepository) { + if ($shipped_only && $this->repository instanceof InstalledLanguageDatabaseRepository) { return $this->repository->checkShippedLanguage($this->key); } diff --git a/components/ILIAS/Language/tests/ilObjLanguageTest.php b/components/ILIAS/Language/tests/ilObjLanguageTest.php index 329c09fe57c1..d50394ae7793 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageTest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageTest.php @@ -48,4 +48,17 @@ public function testAbsolutePathExpressionResolvesToTheIliasRoot(): void 'language as invalid.' ); } + + public function testCheckOfTheShippedFileOnlyAsksTheRepositoryForTheShippedLanguage(): void + { + $repository = $this->createMock(\ILIAS\Language\Setup\InstalledLanguageDatabaseRepository::class); + $repository->expects($this->once())->method('checkShippedLanguage')->with('de')->willReturn(true); + $repository->expects($this->never())->method('checkLanguage'); + $language = (new ReflectionClass(ilObjLanguage::class))->newInstanceWithoutConstructor(); + $reflected = new ReflectionObject($language); + $reflected->getProperty('repository')->setValue($language, $repository); + $reflected->getProperty('key')->setValue($language, 'de'); + + $this->assertTrue($language->check(true)); + } } From 116a3de97550c37447fe4580573da0b8425c5b8b Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:18 +0200 Subject: [PATCH 051/103] Exercise: Drop the fallback module argument of `txt()` The values were no module names and had no effect; `exc` is loaded by `ilObjExerciseGUI`. Prepares the removal of the parameter from `ilLanguage::txt()`. --- .../Types/GUI/classes/class.ilExAssTypePortfolioGUI.php | 4 ++-- .../Exercise/Submission/class.ilExSubmissionObjectGUI.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php b/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php index 4e244bcc508d..b703a317907f 100755 --- a/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php +++ b/components/ILIAS/Exercise/Assignment/Types/GUI/classes/class.ilExAssTypePortfolioGUI.php @@ -43,8 +43,8 @@ public function addEditFormCustomProperties(ilPropertyFormGUI $form): void $rd_template = new ilRadioGroupInputGUI($lng->txt("exc_template"), "template"); $rd_template->setRequired(true); - $radio_no_template = new ilRadioOption($lng->txt("exc_without_template"), "0", $lng->txt("exc_without_template_info", "without_template_info")); - $radio_with_template = new ilRadioOption($lng->txt("exc_with_template"), "1", $lng->txt("exc_with_template_info", "with_template_info")); + $radio_no_template = new ilRadioOption($lng->txt("exc_without_template"), "0", $lng->txt("exc_without_template_info")); + $radio_with_template = new ilRadioOption($lng->txt("exc_with_template"), "1", $lng->txt("exc_with_template_info")); $repo = new ilRepositorySelector2InputGUI($lng->txt("exc_portfolio_template"), "template_id"); $repo->setRequired(true); diff --git a/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php b/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php index b6ebd64873ef..607a434e0d4e 100755 --- a/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php +++ b/components/ILIAS/Exercise/Submission/class.ilExSubmissionObjectGUI.php @@ -627,7 +627,7 @@ protected function askUnlinkPortfolioObject(): void $conf = new ilConfirmationGUI(); $conf->setFormAction($this->ctrl->getFormAction($this, "unlinkPortfolio")); - $conf->setHeaderText($this->lng->txt("exc_sure_unlink_portfolio", "sure_unlink_portfolio")); + $conf->setHeaderText($this->lng->txt("exc_sure_unlink_portfolio")); $conf->setConfirm($this->lng->txt("confirm"), "unlinkPortfolio"); $conf->setCancel($this->lng->txt("cancel"), "returnToParent"); From 0b252da42f8c075df2496450be619f1cb6d6bc6d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:18 +0200 Subject: [PATCH 052/103] Survey: Drop the fallback module argument of `txt()` The value "1" was no module name and had no effect. Prepares the removal of the parameter from `ilLanguage::txt()`. --- .../ILIAS/Survey/Evaluation/class.ilSurveyEvaluationGUI.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/Survey/Evaluation/class.ilSurveyEvaluationGUI.php b/components/ILIAS/Survey/Evaluation/class.ilSurveyEvaluationGUI.php index b4e7dd5c2c81..8539a844dafa 100755 --- a/components/ILIAS/Survey/Evaluation/class.ilSurveyEvaluationGUI.php +++ b/components/ILIAS/Survey/Evaluation/class.ilSurveyEvaluationGUI.php @@ -264,7 +264,7 @@ public function checkEvaluationAccess(): void $this->evaluation_manager->setAnonEvaluationAccess($this->request->getRefId()); $this->evaluation(); } else { - $this->tpl->setOnScreenMessage('failure', $this->lng->txt("svy_check_evaluation_wrong_key", "1")); + $this->tpl->setOnScreenMessage('failure', $this->lng->txt("svy_check_evaluation_wrong_key")); $this->cancelEvaluationAccess(); } } From 3ec5a84e451cb80e44f6dc03228bd8f464dec685 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:18 +0200 Subject: [PATCH 053/103] WebServices: Drop the fallback module argument of `txt()` The value "1" was no module name and had no effect. Prepares the removal of the parameter from `ilLanguage::txt()`. --- .../ECS/classes/Mapping/class.ilECSMappingSettingsGUI.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/WebServices/ECS/classes/Mapping/class.ilECSMappingSettingsGUI.php b/components/ILIAS/WebServices/ECS/classes/Mapping/class.ilECSMappingSettingsGUI.php index 7b0641369721..44cda80d2ef2 100755 --- a/components/ILIAS/WebServices/ECS/classes/Mapping/class.ilECSMappingSettingsGUI.php +++ b/components/ILIAS/WebServices/ECS/classes/Mapping/class.ilECSMappingSettingsGUI.php @@ -898,7 +898,7 @@ protected function dUpdateTreeSettings(): bool $assignment->enablePositionUpdate($form->getInput('position')); $assignment->update(); - $this->tpl->setOnScreenMessage('success', $this->lng->txt('settings_saved', "1")); + $this->tpl->setOnScreenMessage('success', $this->lng->txt('settings_saved')); $this->ctrl->redirect($this, 'dEditTree'); } From f9734f63120eb04662142f8a33ac18b1058a146d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:18 +0200 Subject: [PATCH 054/103] OrgUnit: Drop the fallback module argument of `txt()` The empty value had no effect. Prepares the removal of the parameter from `ilLanguage::txt()`. --- .../ILIAS/OrgUnit/classes/Provider/OrgUnitMainBarProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/OrgUnit/classes/Provider/OrgUnitMainBarProvider.php b/components/ILIAS/OrgUnit/classes/Provider/OrgUnitMainBarProvider.php index 9ca0da18a11d..9785d5826908 100755 --- a/components/ILIAS/OrgUnit/classes/Provider/OrgUnitMainBarProvider.php +++ b/components/ILIAS/OrgUnit/classes/Provider/OrgUnitMainBarProvider.php @@ -84,7 +84,7 @@ public function getStaticSubItems(): array }) ); - $title = $this->dic->language()->txt("mm_talk_template", ""); + $title = $this->dic->language()->txt("mm_talk_template"); $action = "ilias.php?baseClass=ilAdministrationGUI&ref_id=" . ilObjTalkTemplateAdministration::getRootRefId() . "&cmd=jump"; $icon = $this->dic->ui()->factory()->symbol()->icon()->standard('tala', $title); $linkEmployeeTalkTemplates = $this->mainmenu->link($this->employeeTalkTemplateIdentifier) From 3f8cf7c04d01c510e864bac310068dc0e1af667b Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:18 +0200 Subject: [PATCH 055/103] Forum: Drop the fallback module argument of `txt()` The empty value had no effect. Prepares the removal of the parameter from `ilLanguage::txt()`. --- components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php b/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php index 2198eb2b7fbb..f69d6113d76f 100755 --- a/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php +++ b/components/ILIAS/Forum/classes/class.ilForumTopicTableGUI.php @@ -94,7 +94,7 @@ public function initTopicsOverviewTable(): void $this->addColumn($this->lng->txt('visits'), 'num_visit'); if ($this->is_post_draft_allowed) { - $this->addColumn($this->lng->txt('drafts', '')); + $this->addColumn($this->lng->txt('drafts')); } $this->addColumn($this->lng->txt('forums_last_post'), 'post_date'); @@ -135,7 +135,7 @@ public function initMergeThreadsTable(): void $this->addColumn($this->lng->txt('forums_articles'), 'num_posts'); $this->addColumn($this->lng->txt('visits'), 'num_visit'); if ($this->is_post_draft_allowed) { - $this->addColumn($this->lng->txt('drafts', '')); + $this->addColumn($this->lng->txt('drafts')); } $this->addColumn($this->lng->txt('forums_last_post'), 'lp_date'); From 089fcd45e4413a33e7ecc262725a0e087889dd50 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 13:55:51 +0200 Subject: [PATCH 056/103] Component: Serve the translations from PO files Component contributes its lang directory and ships the `cmps` module as .pot/.po files, converted with convert_module_to_po.php --remove-from-lang: it no longer exists in the global language files. --- components/ILIAS/Component/Component.php | 7 + components/ILIAS/Component/lang/cmps.pot | 228 +++++++++++++ components/ILIAS/Component/lang/cmps_ar.po | 254 +++++++++++++++ components/ILIAS/Component/lang/cmps_bg.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_cs.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_da.po | 256 +++++++++++++++ components/ILIAS/Component/lang/cmps_de.po | 231 +++++++++++++ components/ILIAS/Component/lang/cmps_el.po | 259 +++++++++++++++ components/ILIAS/Component/lang/cmps_en.po | 231 +++++++++++++ components/ILIAS/Component/lang/cmps_es.po | 232 +++++++++++++ components/ILIAS/Component/lang/cmps_et.po | 251 ++++++++++++++ components/ILIAS/Component/lang/cmps_fa.po | 254 +++++++++++++++ components/ILIAS/Component/lang/cmps_fr.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_hr.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_hu.po | 232 +++++++++++++ components/ILIAS/Component/lang/cmps_it.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_ja.po | 232 +++++++++++++ components/ILIAS/Component/lang/cmps_ka.po | 254 +++++++++++++++ components/ILIAS/Component/lang/cmps_lt.po | 261 +++++++++++++++ components/ILIAS/Component/lang/cmps_nl.po | 251 ++++++++++++++ components/ILIAS/Component/lang/cmps_pl.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_pt.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_ro.po | 305 ++++++++++++++++++ components/ILIAS/Component/lang/cmps_ru.po | 251 ++++++++++++++ components/ILIAS/Component/lang/cmps_sk.po | 254 +++++++++++++++ components/ILIAS/Component/lang/cmps_sl.po | 245 ++++++++++++++ components/ILIAS/Component/lang/cmps_sq.po | 305 ++++++++++++++++++ components/ILIAS/Component/lang/cmps_sr.po | 305 ++++++++++++++++++ components/ILIAS/Component/lang/cmps_sv.po | 232 +++++++++++++ components/ILIAS/Component/lang/cmps_tr.po | 254 +++++++++++++++ components/ILIAS/Component/lang/cmps_uk.po | 305 ++++++++++++++++++ components/ILIAS/Component/lang/cmps_vi.po | 305 ++++++++++++++++++ components/ILIAS/Component/lang/cmps_zh.po | 254 +++++++++++++++ ...lComponentBuildPluginInfoObjectiveTest.php | 2 +- lang/ilias_ar.lang | 73 ----- lang/ilias_bg.lang | 73 ----- lang/ilias_cs.lang | 73 ----- lang/ilias_da.lang | 73 ----- lang/ilias_de.lang | 74 ----- lang/ilias_el.lang | 73 ----- lang/ilias_en.lang | 74 ----- lang/ilias_es.lang | 73 ----- lang/ilias_et.lang | 73 ----- lang/ilias_fa.lang | 73 ----- lang/ilias_fr.lang | 73 ----- lang/ilias_hr.lang | 73 ----- lang/ilias_hu.lang | 73 ----- lang/ilias_it.lang | 73 ----- lang/ilias_ja.lang | 73 ----- lang/ilias_ka.lang | 73 ----- lang/ilias_lt.lang | 73 ----- lang/ilias_nl.lang | 73 ----- lang/ilias_pl.lang | 73 ----- lang/ilias_pt.lang | 73 ----- lang/ilias_ro.lang | 73 ----- lang/ilias_ru.lang | 73 ----- lang/ilias_sk.lang | 73 ----- lang/ilias_sl.lang | 73 ----- lang/ilias_sq.lang | 73 ----- lang/ilias_sr.lang | 73 ----- lang/ilias_sv.lang | 73 ----- lang/ilias_tr.lang | 73 ----- lang/ilias_uk.lang | 73 ----- lang/ilias_vi.lang | 73 ----- lang/ilias_zh.lang | 73 ----- 65 files changed, 8164 insertions(+), 2266 deletions(-) create mode 100644 components/ILIAS/Component/lang/cmps.pot create mode 100644 components/ILIAS/Component/lang/cmps_ar.po create mode 100644 components/ILIAS/Component/lang/cmps_bg.po create mode 100644 components/ILIAS/Component/lang/cmps_cs.po create mode 100644 components/ILIAS/Component/lang/cmps_da.po create mode 100644 components/ILIAS/Component/lang/cmps_de.po create mode 100644 components/ILIAS/Component/lang/cmps_el.po create mode 100644 components/ILIAS/Component/lang/cmps_en.po create mode 100644 components/ILIAS/Component/lang/cmps_es.po create mode 100644 components/ILIAS/Component/lang/cmps_et.po create mode 100644 components/ILIAS/Component/lang/cmps_fa.po create mode 100644 components/ILIAS/Component/lang/cmps_fr.po create mode 100644 components/ILIAS/Component/lang/cmps_hr.po create mode 100644 components/ILIAS/Component/lang/cmps_hu.po create mode 100644 components/ILIAS/Component/lang/cmps_it.po create mode 100644 components/ILIAS/Component/lang/cmps_ja.po create mode 100644 components/ILIAS/Component/lang/cmps_ka.po create mode 100644 components/ILIAS/Component/lang/cmps_lt.po create mode 100644 components/ILIAS/Component/lang/cmps_nl.po create mode 100644 components/ILIAS/Component/lang/cmps_pl.po create mode 100644 components/ILIAS/Component/lang/cmps_pt.po create mode 100644 components/ILIAS/Component/lang/cmps_ro.po create mode 100644 components/ILIAS/Component/lang/cmps_ru.po create mode 100644 components/ILIAS/Component/lang/cmps_sk.po create mode 100644 components/ILIAS/Component/lang/cmps_sl.po create mode 100644 components/ILIAS/Component/lang/cmps_sq.po create mode 100644 components/ILIAS/Component/lang/cmps_sr.po create mode 100644 components/ILIAS/Component/lang/cmps_sv.po create mode 100644 components/ILIAS/Component/lang/cmps_tr.po create mode 100644 components/ILIAS/Component/lang/cmps_uk.po create mode 100644 components/ILIAS/Component/lang/cmps_vi.po create mode 100644 components/ILIAS/Component/lang/cmps_zh.po diff --git a/components/ILIAS/Component/Component.php b/components/ILIAS/Component/Component.php index c0cea15a0749..606515ca945b 100644 --- a/components/ILIAS/Component/Component.php +++ b/components/ILIAS/Component/Component.php @@ -20,6 +20,9 @@ namespace ILIAS; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; + class Component implements Component\Component { public function init( @@ -53,5 +56,9 @@ public function init( $internal[Component\Resource\PublicAssetManager::class] = static fn() => new Component\Resource\PublicAssetManager(); + $contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory( + $this, + 'cmps' + ); } } diff --git a/components/ILIAS/Component/lang/cmps.pot b/components/ILIAS/Component/lang/cmps.pot new file mode 100644 index 000000000000..a95f41569c52 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps.pot @@ -0,0 +1,228 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"MIME-Version: 1.0\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "" + +msgid "cmps_active" +msgstr "" + +msgid "cmps_add_new_rank" +msgstr "" + +msgid "cmps_available" +msgstr "" + +msgid "cmps_available_version" +msgstr "" + +msgid "cmps_basic_files" +msgstr "" + +msgid "cmps_class_file" +msgstr "" + +msgid "cmps_component" +msgstr "" + +msgid "cmps_configure" +msgstr "" + +msgid "cmps_current_db_version" +msgstr "" + +msgid "cmps_current_version" +msgstr "" + +msgid "cmps_database" +msgstr "" + +msgid "cmps_db_update" +msgstr "" + +msgid "cmps_deactivate" +msgstr "" + +msgid "cmps_detailed_information" +msgstr "" + +msgid "cmps_dir" +msgstr "" + +msgid "cmps_disable_creation" +msgstr "" + +msgid "cmps_enable_creation" +msgstr "" + +msgid "cmps_file_version" +msgstr "" + +msgid "cmps_group" +msgstr "" + +msgid "cmps_id" +msgstr "" + +msgid "cmps_ilias_max_version" +msgstr "" + +msgid "cmps_ilias_min_version" +msgstr "" + +msgid "cmps_inactive" +msgstr "" + +msgid "cmps_install" +msgstr "" + +msgid "cmps_is_active" +msgstr "" + +msgid "cmps_is_installed" +msgstr "" + +msgid "cmps_lang_files" +msgstr "" + +msgid "cmps_lang_prefix" +msgstr "" + +msgid "cmps_languages" +msgstr "" + +msgid "cmps_main_dir" +msgstr "" + +msgid "cmps_missing" +msgstr "" + +msgid "cmps_module" +msgstr "" + +msgid "cmps_must_installed" +msgstr "" + +msgid "cmps_name" +msgstr "" + +msgid "cmps_needs_matching_ilias_version" +msgstr "" + +msgid "cmps_needs_newer_ilias_version" +msgstr "" + +msgid "cmps_needs_newer_plugin_version" +msgstr "" + +msgid "cmps_needs_update" +msgstr "" + +msgid "cmps_needs_upgrade" +msgstr "" + +msgid "cmps_no_db_update_file_available" +msgstr "" + +msgid "cmps_no_language_file_available" +msgstr "" + +msgid "cmps_plugin" +msgstr "" + +msgid "cmps_plugin_activated" +msgstr "" + +msgid "cmps_plugin_db_prefixes" +msgstr "" + +msgid "cmps_plugin_deactivated" +msgstr "" + +msgid "cmps_plugin_deinstalled" +msgstr "" + +msgid "cmps_plugin_file" +msgstr "" + +msgid "cmps_plugin_lang_prefixes" +msgstr "" + +msgid "cmps_plugin_slot" +msgstr "" + +msgid "cmps_plugin_uninstalled" +msgstr "" + +msgid "cmps_plugin_updated" +msgstr "" + +msgid "cmps_plugins" +msgstr "" + +msgid "cmps_refresh" +msgstr "" + +msgid "cmps_refresh_lng" +msgstr "" + +msgid "cmps_rep_object" +msgstr "" + +msgid "cmps_repository_object_types" +msgstr "" + +msgid "cmps_responsible" +msgstr "" + +msgid "cmps_responsible_mail" +msgstr "" + +msgid "cmps_save_options" +msgstr "" + +msgid "cmps_service" +msgstr "" + +msgid "cmps_show_details" +msgstr "" + +msgid "cmps_slots" +msgstr "" + +msgid "cmps_status" +msgstr "" + +msgid "cmps_supports_cli_setup" +msgstr "" + +msgid "cmps_supports_export" +msgstr "" + +msgid "cmps_supports_learning_progress" +msgstr "" + +msgid "cmps_uninstall" +msgstr "" + +msgid "cmps_uninstall_confirm" +msgstr "" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "" + +msgid "cmps_update" +msgstr "" + +msgid "cmps_version" +msgstr "" + +msgid "database_is_uptodate" +msgstr "" + +msgid "no_changes" +msgstr "" diff --git a/components/ILIAS/Component/lang/cmps_ar.po b/components/ILIAS/Component/lang/cmps_ar.po new file mode 100644 index 000000000000..e389b24c2e9e --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_ar.po @@ -0,0 +1,254 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ar\n" +"Language-Team: Arabic\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "تفعيل" + +msgid "cmps_active" +msgstr "فعال" + +msgid "cmps_add_new_rank" +msgstr "الوضعية في قائمة اضافة-عنصر-جديد" + +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Basic Files" + +msgid "cmps_class_file" +msgstr "Class File" + +msgid "cmps_component" +msgstr "Component" + +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Current Version" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "DB Update Script" + +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "الدليل" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Update File Version" + +msgid "cmps_group" +msgstr "مجموعة" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Language Files" + +msgid "cmps_lang_prefix" +msgstr "بادئة متغيرات اللغة" + +msgid "cmps_languages" +msgstr "Languages" + +msgid "cmps_main_dir" +msgstr "Main Directory" + +msgid "cmps_missing" +msgstr "Missing" + +msgid "cmps_module" +msgstr "وحدة" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +msgid "cmps_needs_update" +msgstr "Needs update." + +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +msgid "cmps_no_language_file_available" +msgstr "لا يوجد ملف لغة" + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin File" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +msgid "cmps_plugin_slot" +msgstr "قائمة الاضافات" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "الاضافات" + +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "نوع عنصر المكتبة" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "حفظ الخيارات" + +msgid "cmps_service" +msgstr "خدمة" + +msgid "cmps_show_details" +msgstr "عرض التفاصيل" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Update" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "The database is up to date." + +msgid "no_changes" +msgstr "No changes" diff --git a/components/ILIAS/Component/lang/cmps_bg.po b/components/ILIAS/Component/lang/cmps_bg.po new file mode 100644 index 000000000000..f47e61318f8c --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_bg.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: bg\n" +"Language-Team: Bulgarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Активиране" + +msgid "cmps_active" +msgstr "Активиран" + +msgid "cmps_add_new_rank" +msgstr "Позиция в списъка за добавяне на нови обекти" + +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Основни файлове" + +msgid "cmps_class_file" +msgstr "Файл на класа" + +msgid "cmps_component" +msgstr "Component" + +msgid "cmps_configure" +msgstr "Конфигуриране" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr " Текуща версия" + +msgid "cmps_database" +msgstr "База данни" + +msgid "cmps_db_update" +msgstr "Скрипт за обновяване на базата данни" + +msgid "cmps_deactivate" +msgstr "Деактивиране" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Директория" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Активиране на създаването" + +msgid "cmps_file_version" +msgstr "Актуализиране на версията на файла" + +msgid "cmps_group" +msgstr "Група" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. Версия на ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Min. Версия на ILIAS" + +msgid "cmps_inactive" +msgstr "Inactive" + +msgid "cmps_install" +msgstr "Инсталиране" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Езикови файлове" + +msgid "cmps_lang_prefix" +msgstr " Префикс на езиковите променливи" + +msgid "cmps_languages" +msgstr "Езици" + +msgid "cmps_main_dir" +msgstr "Основна директория" + +msgid "cmps_missing" +msgstr "Пропускане" + +msgid "cmps_module" +msgstr "Module" + +msgid "cmps_must_installed" +msgstr "Компонентът трябва да е инсталиран." + +msgid "cmps_name" +msgstr "Име" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Тази версия на плъгина работи само с по-нови версии на ILIAS. Моля, надградете ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Тази версия на ILIAS се нуждае от по-нова версия на плъгина. Моля, актуализирайте плъгина." + +msgid "cmps_needs_update" +msgstr "Необходима е актуализация." + +msgid "cmps_needs_upgrade" +msgstr "Инсталираната в момента версия на плъгина е по-стара от предишната. Моля, актуализирайте плъгина." + +msgid "cmps_no_db_update_file_available" +msgstr "Няма наличен скрипт за актуализация на БД." + +msgid "cmps_no_language_file_available" +msgstr "Няма наличен езиков файл." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "Плъгинът е активиран." + +msgid "cmps_plugin_db_prefixes" +msgstr " Префикси на таблици за БД" + +msgid "cmps_plugin_deactivated" +msgstr "Плъгинът е деактивиран." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Файл на приставка" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Префикси на езика на приставката" + +msgid "cmps_plugin_slot" +msgstr "Плъгин слот" + +msgid "cmps_plugin_uninstalled" +msgstr "Плъгинът е деинсталиран." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr " Тип на обекта на хранилището" + +msgid "cmps_repository_object_types" +msgstr "Типове обекти на хранилището" + +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Запазване на опции" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Покажи подробности" + +msgid "cmps_slots" +msgstr "Места" + +msgid "cmps_status" +msgstr "Статус" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Деинсталиране" + +msgid "cmps_uninstall_confirm" +msgstr "Със сигурност ли искате да деинсталирате приставката \"%s\"?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Плъгинът \"%1$s\" понастоящем не може да бъде активиран (\"%2$s\"). В това състояние плъгините не могат да бъдат деинсталирани напълно. Тъй като се възстановява само интеграцията на ядрото, потребителските данни на плъгина могат да останат. Искате ли да продължите с частичното деинсталиране?" + +msgid "cmps_update" +msgstr "Актуализация" + +msgid "cmps_version" +msgstr "Версия" + +msgid "database_is_uptodate" +msgstr "Базата данни е актуална" + +msgid "no_changes" +msgstr "Няма промени" diff --git a/components/ILIAS/Component/lang/cmps_cs.po b/components/ILIAS/Component/lang/cmps_cs.po new file mode 100644 index 000000000000..c4ee2133f834 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_cs.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: cs\n" +"Language-Team: Czech\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktivovat" + +msgid "cmps_active" +msgstr "Aktivní" + +msgid "cmps_add_new_rank" +msgstr "Pozice v seznamu \"Přidat nový objekt\"" + +msgid "cmps_available" +msgstr "Dostupné" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Základní soubory" + +msgid "cmps_class_file" +msgstr "Soubor třídy" + +msgid "cmps_component" +msgstr "Komponenta" + +msgid "cmps_configure" +msgstr "Konfigurovat" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Aktuální verze" + +msgid "cmps_database" +msgstr "Databáze" + +msgid "cmps_db_update" +msgstr "Skript aktualizace DB" + +msgid "cmps_deactivate" +msgstr "Deaktivovat" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Adresář" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Zapnout tvorbu" + +msgid "cmps_file_version" +msgstr "Verze akualizačního souboru" + +msgid "cmps_group" +msgstr "Skupina" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. verze ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Min. verze ILIAS" + +msgid "cmps_inactive" +msgstr "Neaktivní" + +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Soubory jazyků" + +msgid "cmps_lang_prefix" +msgstr "Označení jazykových proměnných" + +msgid "cmps_languages" +msgstr "Jazyky" + +msgid "cmps_main_dir" +msgstr "Hlavní adresář" + +msgid "cmps_missing" +msgstr "Chybí" + +msgid "cmps_module" +msgstr "Modul" + +msgid "cmps_must_installed" +msgstr "Komponenta musí být instalována." + +msgid "cmps_name" +msgstr "Název" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Tato verze pluginu běží jen na novější verzi ILIAS. Aktualizujte prosím ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Tato verze ILIAS vyžaduje novější verzi zásuvného souboru. Aktualizujte prosím plugin." + +msgid "cmps_needs_update" +msgstr "Vyžaduje aktualizaci." + +msgid "cmps_needs_upgrade" +msgstr "Verze pluginu, kterou instalujete, je starší než nainstalovaná verze. Aktualizujte prosím plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "Není dostupný žádný skript aktualizace DB." + +msgid "cmps_no_language_file_available" +msgstr "Žádný jazykový soubor není dostupný." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "Plugin byl aktivován." + +msgid "cmps_plugin_db_prefixes" +msgstr "Označení tabulek DB" + +msgid "cmps_plugin_deactivated" +msgstr "Plugin byl deaktivován." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Soubor pluginu" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Označení jazykových pluginů" + +msgid "cmps_plugin_slot" +msgstr "Slot pluginu" + +msgid "cmps_plugin_uninstalled" +msgstr "Plugin byl odinstalován." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Pluginy" + +msgid "cmps_refresh" +msgstr "Obnovení" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Typ objektu úložiště" + +msgid "cmps_repository_object_types" +msgstr "Typy objektů úložiště" + +msgid "cmps_responsible" +msgstr "Zodpovědný" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Uložit nastavení" + +msgid "cmps_service" +msgstr "Služba" + +msgid "cmps_show_details" +msgstr "Zobrazit detaily" + +msgid "cmps_slots" +msgstr "Sloty" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Odinstalovat" + +msgid "cmps_uninstall_confirm" +msgstr "Opravdu chcete odinstalovat plugin „%s“?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Plugin „%1$s“ nelze v současné době aktivovat („%2$s“). V tomto stavu nelze pluginy zcela odinstalovat. Vzhledem k tomu, že je vrácena pouze integrace jádra, mohou zůstat zachována vlastní data pluginu. Chcete pokračovat v částečném odinstalování?" + +msgid "cmps_update" +msgstr "Akualizovat" + +msgid "cmps_version" +msgstr "Verze" + +msgid "database_is_uptodate" +msgstr "Tato databáze je aktuální." + +msgid "no_changes" +msgstr "Žádné změny" diff --git a/components/ILIAS/Component/lang/cmps_da.po b/components/ILIAS/Component/lang/cmps_da.po new file mode 100644 index 000000000000..4820de0280a5 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_da.po @@ -0,0 +1,256 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: da\n" +"Language-Team: Danish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktiver" + +msgid "cmps_active" +msgstr "Aktiv" + +msgid "cmps_add_new_rank" +msgstr "Position i opret rullemenu" + +msgid "cmps_available" +msgstr "Tilgængelig" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Basisfiler" + +msgid "cmps_class_file" +msgstr "Klassefiler" + +msgid "cmps_component" +msgstr "Komponent" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Nuværende version" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "Database opdaterings script" + +msgid "cmps_deactivate" +msgstr "Deaktiver" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Mappe" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Opdater filversion" + +msgid "cmps_group" +msgstr "Gruppe" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maks ILIAS version" + +msgid "cmps_ilias_min_version" +msgstr "Min ILIAS version" + +msgid "cmps_inactive" +msgstr "Inaktiv" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Sprogfiler" + +msgid "cmps_lang_prefix" +msgstr "Prefix for sprogvariable" + +msgid "cmps_languages" +msgstr "Sprog" + +msgid "cmps_main_dir" +msgstr "Rod folder" + +msgid "cmps_missing" +msgstr "Mangler" + +msgid "cmps_module" +msgstr "Modul" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Navn" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Denne version af pluginet kører kun på en nyerere version af ILIAS. Du skal opgadere ILIAS før du kan anvende dette." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Denne ILIAS version kræver en nyerere plugin. Opgrader venligst pluginet." + +msgid "cmps_needs_update" +msgstr "Kræver opdatering" + +#, fuzzy +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "Intet database script tilgængeligt" + +msgid "cmps_no_language_file_available" +msgstr "Ingen sprogfil tilgængelig" + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "Plugin blev aktiveret." + +msgid "cmps_plugin_db_prefixes" +msgstr "Database tabel prefix" + +msgid "cmps_plugin_deactivated" +msgstr "Plugin er deaktiveret." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin fil" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin sprog prefixer" + +msgid "cmps_plugin_slot" +msgstr "Plugin plads" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Opfrisk" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Oversigts objekttype" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Ansvarlig" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Gem indstillinger" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Vis detaljer" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Opdater" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "Databasen er opdateret" + +msgid "no_changes" +msgstr "Ingen ændringer" diff --git a/components/ILIAS/Component/lang/cmps_de.po b/components/ILIAS/Component/lang/cmps_de.po new file mode 100644 index 000000000000..83829b78f52d --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_de.po @@ -0,0 +1,231 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: de\n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktivieren" + +msgid "cmps_active" +msgstr "Aktiv" + +msgid "cmps_add_new_rank" +msgstr "Position in der Auswahlliste für hinzuzufügende Objekte" + +msgid "cmps_available" +msgstr "Verfügbar" + +msgid "cmps_available_version" +msgstr "Verfügbare Version" + +msgid "cmps_basic_files" +msgstr "Basis-Dateien" + +msgid "cmps_class_file" +msgstr "Klassen-Datei" + +msgid "cmps_component" +msgstr "Komponente" + +msgid "cmps_configure" +msgstr "Konfigurieren" + +msgid "cmps_current_db_version" +msgstr "Aktuelle DB-Version" + +msgid "cmps_current_version" +msgstr "Aktuelle Version" + +msgid "cmps_database" +msgstr "Datenbank" + +msgid "cmps_db_update" +msgstr "Skript für Datenbankaktualisierung" + +msgid "cmps_deactivate" +msgstr "Deaktivieren" + +msgid "cmps_detailed_information" +msgstr "Detaillierte Informationen" + +msgid "cmps_dir" +msgstr "Verzeichnis" + +msgid "cmps_disable_creation" +msgstr "Erstellung ausschalten" + +msgid "cmps_enable_creation" +msgstr "Erstellung einschalten" + +msgid "cmps_file_version" +msgstr "Dateiversion aktualisieren" + +msgid "cmps_group" +msgstr "Gruppe" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maximale ILIAS-Version" + +msgid "cmps_ilias_min_version" +msgstr "Minimale ILIAS-Version" + +msgid "cmps_inactive" +msgstr "Inaktiv" + +msgid "cmps_install" +msgstr "Installieren" + +msgid "cmps_is_active" +msgstr "Aktiviert" + +msgid "cmps_is_installed" +msgstr "Installiert" + +msgid "cmps_lang_files" +msgstr "Sprachdatei" + +msgid "cmps_lang_prefix" +msgstr "Präfix Sprachvariable" + +msgid "cmps_languages" +msgstr "Sprachen" + +msgid "cmps_main_dir" +msgstr "Hauptverzeichnis" + +msgid "cmps_missing" +msgstr "Fehlt" + +msgid "cmps_module" +msgstr "Modul" + +msgid "cmps_must_installed" +msgstr "Die Komponente muss installiert werden." + +msgid "cmps_name" +msgstr "Name" + +msgid "cmps_needs_matching_ilias_version" +msgstr "Dieses Plugin kann nicht mit der aktuellen Version dieser ILIAS-Installation verwendet werden." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Dieses Plugin kann nur mit einer neueren ILIAS-Version verwendet werden. Bitte aktualisieren Sie Ihre ILIAS-Version." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Diese ILIAS-Version benötigt eine neuere Plugin-Version. Bitte aktualisieren Sie das Plugin." + +msgid "cmps_needs_update" +msgstr "Aktualisierung notwendig" + +msgid "cmps_needs_upgrade" +msgstr "Die aktuell installierte Plugin-Version ist älter als die zuvor installierte. Bitte installieren Sie das aktuelle Plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "Kein Datenbank-Update-Skript verfügbar" + +msgid "cmps_no_language_file_available" +msgstr "Keine Sprachdatei verfügbar" + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "Das Plugin wurde aktiviert." + +msgid "cmps_plugin_db_prefixes" +msgstr "Präfixe Datenbanktabelle" + +msgid "cmps_plugin_deactivated" +msgstr "Das Plugin wurde deaktiviert." + +msgid "cmps_plugin_deinstalled" +msgstr "Das Plugin wurde deinstalliert." + +msgid "cmps_plugin_file" +msgstr "Plugin-Datei" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin-Sprachpräfixe" + +msgid "cmps_plugin_slot" +msgstr "Plugin-Slot" + +msgid "cmps_plugin_uninstalled" +msgstr "Alle Sprach- und Datenbankeinträge, die sich auf das Plugin beziehen, wurden gelöscht und das Plugin deaktiviert. Sie können jetzt das Verzeichnis und den Quellcode des Plugins aus dem ‘Customizing’-Verzeichnis entfernen, um das Plugin endgültig zu deinstallieren." + +msgid "cmps_plugin_updated" +msgstr "Das Plugin wurde aktualisiert." + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Sprachen neu laden" + +msgid "cmps_refresh_lng" +msgstr "Sprache aktualisiert" + +msgid "cmps_rep_object" +msgstr "Objekttyp im Magazin" + +msgid "cmps_repository_object_types" +msgstr "Objekttypen im Magazin" + +msgid "cmps_responsible" +msgstr "Verantwortlich" + +msgid "cmps_responsible_mail" +msgstr "Mail (Verantwortlicher)" + +msgid "cmps_save_options" +msgstr "Speichern" + +msgid "cmps_service" +msgstr "Dienst" + +msgid "cmps_show_details" +msgstr "Details anzeigen" + +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +msgid "cmps_supports_cli_setup" +msgstr "Unterstützt CLI-Setup" + +msgid "cmps_supports_export" +msgstr "Unterstützt Export" + +msgid "cmps_supports_learning_progress" +msgstr "Unterstützt Lernfortschritt" + +msgid "cmps_uninstall" +msgstr "Deinstallieren" + +msgid "cmps_uninstall_confirm" +msgstr "Wollen Sie wirklich das Plugin „%s“ deinstallieren und alle zugehörigen Datenbankeinträge löschen?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Das Plugin \"%1$s\" ist aktuell nicht aktivierbar (\"%2$s\"). In diesem Status können Plugins nicht komplett deinstalliert werden. Da nur die Systemintegration entfernt wird, könnten Plugin-eigene Daten zurückbleiben. Möchten Sie mit der teilweisen Deinstallation fortfahren?" + +msgid "cmps_update" +msgstr "Aktualisieren" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_el.po b/components/ILIAS/Component/lang/cmps_el.po new file mode 100644 index 000000000000..20f17f2cfbb0 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_el.po @@ -0,0 +1,259 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: el\n" +"Language-Team: Greek\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Activate" + +msgid "cmps_active" +msgstr "Active" + +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Basic Files" + +msgid "cmps_class_file" +msgstr "Class File" + +msgid "cmps_component" +msgstr "Component" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Current Version" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "DB Update Script" + +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Update File Version" + +msgid "cmps_group" +msgstr "Group" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Language Files" + +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +msgid "cmps_languages" +msgstr "Languages" + +msgid "cmps_main_dir" +msgstr "Main Directory" + +msgid "cmps_missing" +msgstr "Missing" + +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +msgid "cmps_needs_update" +msgstr "Needs update." + +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +msgid "cmps_plugin" +msgstr "Plugin" + +#, fuzzy +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +#, fuzzy +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin File" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Save Options" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Show Details" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Update" + +msgid "cmps_version" +msgstr "Version" + +#, fuzzy +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +#, fuzzy +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_en.po b/components/ILIAS/Component/lang/cmps_en.po new file mode 100644 index 000000000000..c7bced4c6785 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_en.po @@ -0,0 +1,231 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" +"Language-Team: English\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Activate" + +msgid "cmps_active" +msgstr "Active" + +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +msgid "cmps_available" +msgstr "Available" + +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Basic Files" + +msgid "cmps_class_file" +msgstr "Class File" + +msgid "cmps_component" +msgstr "Component" + +msgid "cmps_configure" +msgstr "Configure" + +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Current Version" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "DB Update Script" + +msgid "cmps_deactivate" +msgstr "Deactivate" + +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Directory" + +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Update File Version" + +msgid "cmps_group" +msgstr "Group" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +msgid "cmps_inactive" +msgstr "Inactive" + +msgid "cmps_install" +msgstr "Install" + +msgid "cmps_is_active" +msgstr "Activated" + +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Language Files" + +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +msgid "cmps_languages" +msgstr "Languages" + +msgid "cmps_main_dir" +msgstr "Main Directory" + +msgid "cmps_missing" +msgstr "Missing" + +msgid "cmps_module" +msgstr "Module" + +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Name" + +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +msgid "cmps_needs_update" +msgstr "Needs update." + +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin File" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +msgid "cmps_plugin_uninstalled" +msgstr "All language and database related entries have been deleted and the plugin has been set inactive. Now you can remove the plugin's directory and source code in the ‘Customizing’ directory to uninstall the plugin definitely." + +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Refresh Languages" + +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Responsible" + +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Save" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Show Details" + +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +msgid "cmps_supports_export" +msgstr "Supports export" + +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Uninstall" + +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\" and remove all related database entries?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Update" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "The database is up to date." + +msgid "no_changes" +msgstr "No changes" diff --git a/components/ILIAS/Component/lang/cmps_es.po b/components/ILIAS/Component/lang/cmps_es.po new file mode 100644 index 000000000000..58d439e1e9c7 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_es.po @@ -0,0 +1,232 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: es\n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Activar" + +msgid "cmps_active" +msgstr "Activo" + +msgid "cmps_add_new_rank" +msgstr "Posición en la lista de Añadir-Nuevo-Objeto" + +msgid "cmps_available" +msgstr "Disponible" + +msgid "cmps_available_version" +msgstr "Versión disponible" + +msgid "cmps_basic_files" +msgstr "Archivos básicos" + +msgid "cmps_class_file" +msgstr "Archivo de clase" + +msgid "cmps_component" +msgstr "Componente" + +msgid "cmps_configure" +msgstr "Configurar" + +msgid "cmps_current_db_version" +msgstr "Versión actual de BD" + +msgid "cmps_current_version" +msgstr "Versión actual" + +msgid "cmps_database" +msgstr "Base de datos" + +msgid "cmps_db_update" +msgstr "Script de actualización de BD" + +msgid "cmps_deactivate" +msgstr "Desactivar" + +msgid "cmps_detailed_information" +msgstr "Información detallada" + +msgid "cmps_dir" +msgstr "Directorio" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Habilitar creación" + +msgid "cmps_file_version" +msgstr "Actualizar versión de archivo" + +msgid "cmps_group" +msgstr "Grupo" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Versión máx. de ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Versión mín. de ILIAS" + +msgid "cmps_inactive" +msgstr "Inactivo" + +msgid "cmps_install" +msgstr "Instalar" + +msgid "cmps_is_active" +msgstr "Activado" + +msgid "cmps_is_installed" +msgstr "Instalado" + +msgid "cmps_lang_files" +msgstr "Archivos de idioma" + +msgid "cmps_lang_prefix" +msgstr "Prefijo de variables de idioma" + +msgid "cmps_languages" +msgstr "Idiomas" + +msgid "cmps_main_dir" +msgstr "Directorio principal" + +msgid "cmps_missing" +msgstr "Faltante" + +msgid "cmps_module" +msgstr "Módulo" + +msgid "cmps_must_installed" +msgstr "El componente debe estar instalado." + +msgid "cmps_name" +msgstr "Nombre" + +msgid "cmps_needs_matching_ilias_version" +msgstr "Este complemento no funciona con su versión actual de ILIAS." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Esta versión del complemento solo funciona en versiones más recientes de ILIAS. Por favor, actualice ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Esta versión de ILIAS necesita una versión más reciente del complemento. Por favor, actualice el complemento." + +msgid "cmps_needs_update" +msgstr "Necesita actualización." + +msgid "cmps_needs_upgrade" +msgstr "La versión del complemento instalada actualmente es más antigua que una versión instalada previamente. Por favor, actualice el complemento." + +msgid "cmps_no_db_update_file_available" +msgstr "No hay ningún script de actualización de base de datos disponible." + +msgid "cmps_no_language_file_available" +msgstr "No hay ningún archivo de idioma disponible." + +msgid "cmps_plugin" +msgstr "Complemento (Plugin)" + +msgid "cmps_plugin_activated" +msgstr "El complemento ha sido activado." + +msgid "cmps_plugin_db_prefixes" +msgstr "Prefijos de tablas de BD" + +msgid "cmps_plugin_deactivated" +msgstr "El complemento ha sido desactivado." + +msgid "cmps_plugin_deinstalled" +msgstr "El complemento ha sido desinstalado" + +msgid "cmps_plugin_file" +msgstr "Archivo del complemento" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Prefijos de idioma del complemento" + +msgid "cmps_plugin_slot" +msgstr "Ubicación (Slot) del complemento" + +msgid "cmps_plugin_uninstalled" +msgstr "Todas las entradas relacionadas con el idioma y la base de datos han sido eliminadas y el complemento se ha configurado como inactivo. Ahora puede eliminar el directorio y el código fuente del complemento en el directorio 'Customizing' para desinstalar el complemento definitivamente." + +msgid "cmps_plugin_updated" +msgstr "El complemento ha sido actualizado" + +msgid "cmps_plugins" +msgstr "Complementos (Plugins)" + +msgid "cmps_refresh" +msgstr "Actualizar idiomas" + +msgid "cmps_refresh_lng" +msgstr "Actualizar idioma" + +msgid "cmps_rep_object" +msgstr "Tipo de objeto de repositorio" + +msgid "cmps_repository_object_types" +msgstr "Tipos de objetos de repositorio" + +msgid "cmps_responsible" +msgstr "Responsable" + +msgid "cmps_responsible_mail" +msgstr "Correo electrónico (Responsable)" + +msgid "cmps_save_options" +msgstr "Guardar" + +msgid "cmps_service" +msgstr "Servicio" + +msgid "cmps_show_details" +msgstr "Mostrar detalles" + +msgid "cmps_slots" +msgstr "Ubicaciones (Slots)" + +msgid "cmps_status" +msgstr "Estado" + +msgid "cmps_supports_cli_setup" +msgstr "Soporta configuración por CLI" + +msgid "cmps_supports_export" +msgstr "Soporta exportación" + +msgid "cmps_supports_learning_progress" +msgstr "Soporta progreso de aprendizaje" + +msgid "cmps_uninstall" +msgstr "Desinstalar" + +msgid "cmps_uninstall_confirm" +msgstr "¿Está seguro de que desea desinstalar el complemento \"%s\" y eliminar todas las entradas de la base de datos relacionadas?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "El complemento \"%1$s\" actualmente no puede activarse (\"%2$s\"). En este estado, los complementos no pueden desinstalarse completamente. Dado que solo se revierte la integración del núcleo, los datos personalizados del complemento pueden permanecer. ¿Desea continuar con la desinstalación parcial?" + +msgid "cmps_update" +msgstr "Actualizar" + +msgid "cmps_version" +msgstr "Versión" + +msgid "database_is_uptodate" +msgstr "La base de datos está actualizada." + +msgid "no_changes" +msgstr "Sin cambios" diff --git a/components/ILIAS/Component/lang/cmps_et.po b/components/ILIAS/Component/lang/cmps_et.po new file mode 100644 index 000000000000..f59bcf6b4695 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_et.po @@ -0,0 +1,251 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: et\n" +"Language-Team: Estonian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktiveeri" + +msgid "cmps_active" +msgstr "Aktiivne" + +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +msgid "cmps_available" +msgstr "Kättesaadav" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Põhifailid" + +msgid "cmps_class_file" +msgstr "Class File" + +msgid "cmps_component" +msgstr "Component" + +msgid "cmps_configure" +msgstr "Konfigureeri" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Hetkeversioon" + +msgid "cmps_database" +msgstr "Andmebaas" + +msgid "cmps_db_update" +msgstr "DB Update Script" + +msgid "cmps_deactivate" +msgstr "Deaktiveeri" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Kataloog" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Uuenda faili versiooni" + +msgid "cmps_group" +msgstr "Grupp" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +msgid "cmps_inactive" +msgstr "Passiivne" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Keelefailid" + +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +msgid "cmps_languages" +msgstr "Keeled" + +msgid "cmps_main_dir" +msgstr "Juurkataloog" + +msgid "cmps_missing" +msgstr "Puudub" + +msgid "cmps_module" +msgstr "Moodul" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Nimi" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Antud lisamoodul töötab ainult ILIASe uuema versiooniga. Uuenda ILIASe versiooni." + +msgid "cmps_needs_newer_plugin_version" +msgstr "ILIASe antud versioon vajab lisamooduli uuemat versiooni. Uuenda lisamoodulit." + +msgid "cmps_needs_update" +msgstr "Vajab uuendamist." + +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +msgid "cmps_no_language_file_available" +msgstr "Keelefaili pole." + +msgid "cmps_plugin" +msgstr "Lisamoodul" + +msgid "cmps_plugin_activated" +msgstr "Lisamoodul on aktiveeritud." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +msgid "cmps_plugin_deactivated" +msgstr "Lisamoodul on deaktiveeritud." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Lisamooduli fail" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Lisamoodulid" + +msgid "cmps_refresh" +msgstr "Värskenda" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Varamu objekti tüüp" + +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Salvestamise valikud" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Näita detaile" + +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Staatus" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Uuenda" + +msgid "cmps_version" +msgstr "Versioon" + +msgid "database_is_uptodate" +msgstr "The database is up to date." + +msgid "no_changes" +msgstr "Muudatused puuduvad" diff --git a/components/ILIAS/Component/lang/cmps_fa.po b/components/ILIAS/Component/lang/cmps_fa.po new file mode 100644 index 000000000000..8f5c9a0c6794 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_fa.po @@ -0,0 +1,254 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fa\n" +"Language-Team: Persian (Farsi)\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Activate" + +msgid "cmps_active" +msgstr "Active" + +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Basic Files" + +msgid "cmps_class_file" +msgstr "Class File" + +msgid "cmps_component" +msgstr "Component" + +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Current Version" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "DB Update Script" + +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "بروز رسانی نسخه فایل" + +msgid "cmps_group" +msgstr "Group" + +msgid "cmps_id" +msgstr "شناسه" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Language Files" + +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +msgid "cmps_languages" +msgstr "زبانها" + +msgid "cmps_main_dir" +msgstr "Main Directory" + +msgid "cmps_missing" +msgstr "Missing" + +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +msgid "cmps_needs_update" +msgstr "Needs update." + +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin File" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Save Options" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Show Details" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "بروز رسانی" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "The database is up to date." + +msgid "no_changes" +msgstr "No changes" diff --git a/components/ILIAS/Component/lang/cmps_fr.po b/components/ILIAS/Component/lang/cmps_fr.po new file mode 100644 index 000000000000..44a9c3ed8512 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_fr.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fr\n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Activer" + +msgid "cmps_active" +msgstr "Actif" + +msgid "cmps_add_new_rank" +msgstr "Position dans la liste Ajouter-un-nouvel-objet" + +msgid "cmps_available" +msgstr "Disponible" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Fichiers basiques" + +msgid "cmps_class_file" +msgstr "Fichier de Classe" + +msgid "cmps_component" +msgstr "Composant" + +msgid "cmps_configure" +msgstr "Configurer" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Version actuelle" + +msgid "cmps_database" +msgstr "Base de données" + +msgid "cmps_db_update" +msgstr "Script de mise à jour de la BbD" + +msgid "cmps_deactivate" +msgstr "Désactiver" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Dossier" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Autoriser Création" + +msgid "cmps_file_version" +msgstr "Mettre à jour la version du fichier" + +msgid "cmps_group" +msgstr "Groupe" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Version Max. ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Version Min. ILIAS" + +msgid "cmps_inactive" +msgstr "Inactif" + +msgid "cmps_install" +msgstr "Installer" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Fichier de langues" + +msgid "cmps_lang_prefix" +msgstr "Préfixe des variables de langue" + +msgid "cmps_languages" +msgstr "Langues" + +msgid "cmps_main_dir" +msgstr "Dossier Principal" + +msgid "cmps_missing" +msgstr "Manquant" + +msgid "cmps_module" +msgstr "Module" + +msgid "cmps_must_installed" +msgstr "Le composant doit être installé." + +msgid "cmps_name" +msgstr "Nom" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Cette version de plugin fonctionne uniquement sur les nouvelles versions d'ILIAS. Veuillez mettre à jour ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Cette version d'ILIAS nécessite une nouvelle version de plugin. Veuillez mettre à jour le plugin." + +msgid "cmps_needs_update" +msgstr "Nécessite un update." + +msgid "cmps_needs_upgrade" +msgstr "La version de plugin actuellement installée est plus ancienne que la précédente installée. Veuillez mettre à jour le plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "Pas de script de mise à jour de la BdD disponible." + +msgid "cmps_no_language_file_available" +msgstr "Pas de fichier de langue disponible." + +msgid "cmps_plugin" +msgstr "Plug-in" + +msgid "cmps_plugin_activated" +msgstr "Le plugin a été activé." + +msgid "cmps_plugin_db_prefixes" +msgstr "Préfixes de tables de BdD" + +msgid "cmps_plugin_deactivated" +msgstr "Le plugin a été désactivé." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Fichier Plugin" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Préfixes de langage de plugin" + +msgid "cmps_plugin_slot" +msgstr "Slot du Plugin" + +msgid "cmps_plugin_uninstalled" +msgstr "Le plugin a été désinstallé." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plug-ins" + +msgid "cmps_refresh" +msgstr "Rafraà®chir" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Type d'Objet" + +msgid "cmps_repository_object_types" +msgstr "Types d'Objets" + +msgid "cmps_responsible" +msgstr "Responsable" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Enregistrer Options" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Afficher détails" + +msgid "cmps_slots" +msgstr "Emplacements" + +msgid "cmps_status" +msgstr "Statut" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Désinstaller" + +msgid "cmps_uninstall_confirm" +msgstr "Etes-vous sûr de vouloir désinstaller le plugin \"%s\"?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Le plug-in «%1$s» ne peut pas être activé pour le moment («%2$s»). Dans cet état, les plug-ins ne peuvent pas être installés complètement. Puisque seule l’intégration principale est inversée, les données personnalisées des plug-ins peuvent rester. Voulez-vous continuer la désinstallation partielle?" + +msgid "cmps_update" +msgstr "Mettre à jour" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "La base de donnée est à jour." + +msgid "no_changes" +msgstr "Aucun changement" diff --git a/components/ILIAS/Component/lang/cmps_hr.po b/components/ILIAS/Component/lang/cmps_hr.po new file mode 100644 index 000000000000..643c41b9b6a1 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_hr.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hr\n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktiviraj" + +msgid "cmps_active" +msgstr "Aktivan" + +msgid "cmps_add_new_rank" +msgstr "Pozicija na popisu dodavanja novih objekata" + +msgid "cmps_available" +msgstr "Dostupno" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Osnovne datoteke" + +msgid "cmps_class_file" +msgstr "Datoteka razreda" + +msgid "cmps_component" +msgstr "Komponenta" + +msgid "cmps_configure" +msgstr "Konfiguriraj" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Trenutačna verzija" + +msgid "cmps_database" +msgstr "Baza podataka" + +msgid "cmps_db_update" +msgstr "DB Skripta za ažuriranje" + +msgid "cmps_deactivate" +msgstr "Deaktiviraj" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Direktorij" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Omogući kreiranje" + +msgid "cmps_file_version" +msgstr "Ažuriranje verzije datoteke" + +msgid "cmps_group" +msgstr "Skupina" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maks. verzija ILIAS-a" + +msgid "cmps_ilias_min_version" +msgstr "Min. verzija ILIAS-a" + +msgid "cmps_inactive" +msgstr "Neaktivan" + +msgid "cmps_install" +msgstr "Instaliraj" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Jezične datoteke" + +msgid "cmps_lang_prefix" +msgstr "Prefiks jezičnih varijabli" + +msgid "cmps_languages" +msgstr "Jezici" + +msgid "cmps_main_dir" +msgstr "Glavni direktorij" + +msgid "cmps_missing" +msgstr "Nedostaje" + +msgid "cmps_module" +msgstr "Modul" + +msgid "cmps_must_installed" +msgstr "Komponenta se mora instalirati." + +msgid "cmps_name" +msgstr "Ime" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Ova verzija dodatka radi samo na novijim verzijama ILIAS-a. Molimo nadogradite ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +msgid "cmps_needs_update" +msgstr "Potrebno je ažuriranje." + +msgid "cmps_needs_upgrade" +msgstr "Trenutno instalirana verzija dodatka starija je od prethodno instalirane verzije. Molimo nadogradite dodatak." + +msgid "cmps_no_db_update_file_available" +msgstr "Nije dostupna skripta za ažuriranje baze podataka" + +msgid "cmps_no_language_file_available" +msgstr "Nije dostupna jezična datoteka." + +msgid "cmps_plugin" +msgstr "Dodatak" + +msgid "cmps_plugin_activated" +msgstr "Dodatak je aktiviran." + +msgid "cmps_plugin_db_prefixes" +msgstr "Prefiksi tablice baze podataka" + +msgid "cmps_plugin_deactivated" +msgstr "Dodatak je deaktiviran." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Datoteka dodatka" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Dodatak jezičnih prefiksa" + +msgid "cmps_plugin_slot" +msgstr "Slot dodatka" + +msgid "cmps_plugin_uninstalled" +msgstr "Svi unosi povezani s jezikom i bazom podataka izbrisani su, a dodatak je neaktivan. Sada možete ukloniti direktorij i izvorni kod dodatka u direktoriju ‘Prilagođavanje’ kako biste dodatak definitivno deinstalirali." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Dodaci" + +msgid "cmps_refresh" +msgstr "Osvježi jezike" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Tip objekta u repozitoriju" + +msgid "cmps_repository_object_types" +msgstr "Tipovi objekata u repozitoriju" + +msgid "cmps_responsible" +msgstr "Odgovoran" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Spremi" + +msgid "cmps_service" +msgstr "Servis" + +msgid "cmps_show_details" +msgstr "Pokaži detalje" + +msgid "cmps_slots" +msgstr "Slotovi" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Deinstaliraj" + +msgid "cmps_uninstall_confirm" +msgstr "Jeste li sigurni da želite deinstalirati dodatak \"%s\" i ukloniti sve povezane unose u bazu podataka?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Dodatak \"%1$s\" trenutačno se ne može aktivirati (\"%2$s\"). U ovom stanju dodaci se ne mogu potpuno deinstalirati. Kako se briše samo integracija u sustav, vlastite datoteke dodataka mogu ostati. Želite li nastaviti s djelomičnom deinstalacijom?" + +msgid "cmps_update" +msgstr "Ažuriraj" + +msgid "cmps_version" +msgstr "Verzija" + +msgid "database_is_uptodate" +msgstr "Baza podataka je ažurirana." + +msgid "no_changes" +msgstr "Nema promjena" diff --git a/components/ILIAS/Component/lang/cmps_hu.po b/components/ILIAS/Component/lang/cmps_hu.po new file mode 100644 index 000000000000..1d69b181880c --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_hu.po @@ -0,0 +1,232 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hu\n" +"Language-Team: Hungarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktiválás" + +msgid "cmps_active" +msgstr "Aktív" + +msgid "cmps_add_new_rank" +msgstr "Pozíció az Új objektum létrehozása listában" + +msgid "cmps_available" +msgstr "Elérhető" + +msgid "cmps_available_version" +msgstr "Elérhető verzió" + +msgid "cmps_basic_files" +msgstr "Alapfájlok" + +msgid "cmps_class_file" +msgstr "Osztályfájl" + +msgid "cmps_component" +msgstr "Komponens" + +msgid "cmps_configure" +msgstr "Konfigurálás" + +msgid "cmps_current_db_version" +msgstr "Jelenlegi adatbázis-verzió" + +msgid "cmps_current_version" +msgstr "Jelenlegi verzió" + +msgid "cmps_database" +msgstr "Adatbázis" + +msgid "cmps_db_update" +msgstr "Adatbázis-frissítési szkript" + +msgid "cmps_deactivate" +msgstr "Deaktiválás" + +msgid "cmps_detailed_information" +msgstr "Részletes információ" + +msgid "cmps_dir" +msgstr "Mappa" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Létrehozás engedélyezése" + +msgid "cmps_file_version" +msgstr "Frissítési fájlverzió" + +msgid "cmps_group" +msgstr "Csoport" + +msgid "cmps_id" +msgstr "Azonosító" + +msgid "cmps_ilias_max_version" +msgstr "Maximum ILIAS-verzió" + +msgid "cmps_ilias_min_version" +msgstr "Minimum ILIAS-verzió" + +msgid "cmps_inactive" +msgstr "Inaktív" + +msgid "cmps_install" +msgstr "Telepítés" + +msgid "cmps_is_active" +msgstr "Aktivált" + +msgid "cmps_is_installed" +msgstr "Telepített" + +msgid "cmps_lang_files" +msgstr "Nyelvi fájlok" + +msgid "cmps_lang_prefix" +msgstr "Nyelvi változó előtag" + +msgid "cmps_languages" +msgstr "Nyelvek" + +msgid "cmps_main_dir" +msgstr "Főmappa" + +msgid "cmps_missing" +msgstr "Hiányzik" + +msgid "cmps_module" +msgstr "Modul" + +msgid "cmps_must_installed" +msgstr "A komponenst telepíteni kell." + +msgid "cmps_name" +msgstr "Név" + +msgid "cmps_needs_matching_ilias_version" +msgstr "Ez a bővítmény nem működik ezen az ILIAS verzión." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Ez a bővítményverzió csak újabb ILIAS-verziókon fut. Frissítse az ILIAS-t!" + +msgid "cmps_needs_newer_plugin_version" +msgstr "Ehhez az ILIAS-verzióhoz újabb bővítményverzió szükséges. Frissítse a bővítményt!" + +msgid "cmps_needs_update" +msgstr "Frissítés szükséges." + +msgid "cmps_needs_upgrade" +msgstr "A jelenleg telepített bővítmény verziója régebbi, mint egy korábban telepítetté. Frissítse a bővítményt!" + +msgid "cmps_no_db_update_file_available" +msgstr "Nem érhető el az adatbázis-frissítő szkript." + +msgid "cmps_no_language_file_available" +msgstr "Nem érhető el a nyelvi fájl." + +msgid "cmps_plugin" +msgstr "Bővítmény" + +msgid "cmps_plugin_activated" +msgstr "A bővítményt bekapcsolta." + +msgid "cmps_plugin_db_prefixes" +msgstr "Adatbázistábla előtag" + +msgid "cmps_plugin_deactivated" +msgstr "A bővítményt kikapcsolta." + +msgid "cmps_plugin_deinstalled" +msgstr "A bővítményt eltávolította." + +msgid "cmps_plugin_file" +msgstr "Bővítmény fájl" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Bővítmény nyelvi előtagok" + +msgid "cmps_plugin_slot" +msgstr "Bővítmény csatlakozás" + +msgid "cmps_plugin_uninstalled" +msgstr "Az összes nyelvi- és adatbázis-bejegyzést sikeresen eltávolította, és a bővítményt inaktiválta. Most már biztonságosan letávolíthatja a bővítmény - ‘Customizing’ mappa alatt található - fájljait/mappáit." + +msgid "cmps_plugin_updated" +msgstr "A bővítményt frissítette" + +msgid "cmps_plugins" +msgstr "Bővítmények" + +msgid "cmps_refresh" +msgstr "Frissítés" + +msgid "cmps_refresh_lng" +msgstr "Nyelv frissítés" + +msgid "cmps_rep_object" +msgstr "Tartalomtárban lévő objektumtípus" + +msgid "cmps_repository_object_types" +msgstr "ILIAS-objektum típusok" + +msgid "cmps_responsible" +msgstr "Felelős" + +msgid "cmps_responsible_mail" +msgstr "E-mail cím (Felelős)" + +msgid "cmps_save_options" +msgstr "Mentés" + +msgid "cmps_service" +msgstr "Szolgáltatás" + +msgid "cmps_show_details" +msgstr "Részletek megjelenítése" + +msgid "cmps_slots" +msgstr "Foglalatok" + +msgid "cmps_status" +msgstr "Állapot" + +msgid "cmps_supports_cli_setup" +msgstr "Parancssori telepítés támogatása" + +msgid "cmps_supports_export" +msgstr "Exportálás támogatása" + +msgid "cmps_supports_learning_progress" +msgstr "Tanulási haladás támogatása" + +msgid "cmps_uninstall" +msgstr "Eltávolítás" + +msgid "cmps_uninstall_confirm" +msgstr "Biztos, hogy eltávolítja a következő ‘%s’ bővítményt annak összes adatbázis-bejegyzésével együtt?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "‘%1$s’ bővítmény jelenleg nem aktiválható (‘%2$s’), így nem távolítható el teljesen, néhány adata a rendszerben marad. Biztos, hogy folytatja a részleges eltávolítást?" + +msgid "cmps_update" +msgstr "Frissítés" + +msgid "cmps_version" +msgstr "Verzió" + +msgid "database_is_uptodate" +msgstr "Az adatbázis naprakész." + +msgid "no_changes" +msgstr "Nincsenek változások" diff --git a/components/ILIAS/Component/lang/cmps_it.po b/components/ILIAS/Component/lang/cmps_it.po new file mode 100644 index 000000000000..575d6137c231 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_it.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: it\n" +"Language-Team: Italian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Attivare" + +msgid "cmps_active" +msgstr "Attivo" + +msgid "cmps_add_new_rank" +msgstr "Posizione nel menù per Aggiungi-Nuovo-Oggetto" + +msgid "cmps_available" +msgstr "Disponibile" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "File di base" + +msgid "cmps_class_file" +msgstr "File classe" + +msgid "cmps_component" +msgstr "Componenti" + +msgid "cmps_configure" +msgstr "Configura" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Versione attuale" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "Script per l'attualizzazione della banca dati" + +msgid "cmps_deactivate" +msgstr "Disattivato" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Consenti creazione" + +msgid "cmps_file_version" +msgstr "Aggiornamento del file" + +msgid "cmps_group" +msgstr "Gruppo" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Versione max. di ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Versione min. di ILIAS" + +msgid "cmps_inactive" +msgstr "Inattivo" + +msgid "cmps_install" +msgstr "Installa" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "File di lingua" + +msgid "cmps_lang_prefix" +msgstr "Prefisso della variabile di testo" + +msgid "cmps_languages" +msgstr "Lingue" + +msgid "cmps_main_dir" +msgstr "Directory principale" + +msgid "cmps_missing" +msgstr "Mancante" + +msgid "cmps_module" +msgstr "Modulo" + +msgid "cmps_must_installed" +msgstr "Il componente deve essere installato." + +msgid "cmps_name" +msgstr "Nome" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Questa versione del plugin funziona solo su una versione recente di ILIAS. Aggiornalo per favore." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Questa versione di ILIAS necessita di un'aggiornamento del plugin. Procedi per favore." + +msgid "cmps_needs_update" +msgstr "Aggiornamento necessario" + +msgid "cmps_needs_upgrade" +msgstr "Il plugin che hai installato è di una versione precedente. Scarica la versione recente." + +msgid "cmps_no_db_update_file_available" +msgstr "Non è disponibile nessuno script per il DB" + +msgid "cmps_no_language_file_available" +msgstr "Nessun file di lingua disponibile" + +msgid "cmps_plugin" +msgstr "Plug-in" + +msgid "cmps_plugin_activated" +msgstr "Il plugin è stato attivato." + +msgid "cmps_plugin_db_prefixes" +msgstr "Prefissi delle tabelle del DB" + +msgid "cmps_plugin_deactivated" +msgstr "Il plugin è stato disattivato." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "File Plugin" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin dei prefissi di lingua" + +msgid "cmps_plugin_slot" +msgstr "Plug-in fascia oraria" + +msgid "cmps_plugin_uninstalled" +msgstr "Il plugin è stato disinstallato." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plug-in" + +msgid "cmps_refresh" +msgstr "Aggiorna" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Oggetto del repository" + +msgid "cmps_repository_object_types" +msgstr "Tipi di oggetto repository" + +msgid "cmps_responsible" +msgstr "Responsabile" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Salva le opzioni" + +msgid "cmps_service" +msgstr "Servizio" + +msgid "cmps_show_details" +msgstr "Mostra i dettagli" + +msgid "cmps_slots" +msgstr "Fasce orarie" + +msgid "cmps_status" +msgstr "Stato" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Disinstalla" + +msgid "cmps_uninstall_confirm" +msgstr "Sei sicuro di voler disinstallare il plugin \"%s\"?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Il plug-in \"%1$s\" attualmente non può essere attivato (\"%2$s\"). In questo stato i plug-in non possono essere disinstallati completamente. Poiché solo l'integrazione di base viene ripristinata, i dati personalizzati del plug-in possono rimanere. Vuoi continuare con la disinstallazione parziale?" + +msgid "cmps_update" +msgstr "Aggiornamento" + +msgid "cmps_version" +msgstr "Versione" + +msgid "database_is_uptodate" +msgstr "Il database è aggiornato." + +msgid "no_changes" +msgstr "Nessuna modifica" diff --git a/components/ILIAS/Component/lang/cmps_ja.po b/components/ILIAS/Component/lang/cmps_ja.po new file mode 100644 index 000000000000..677a60918580 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_ja.po @@ -0,0 +1,232 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ja\n" +"Language-Team: Japanese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "有効にする" + +msgid "cmps_active" +msgstr "アクティブ" + +msgid "cmps_add_new_rank" +msgstr "新規追加オブジェクトラインの位置" + +msgid "cmps_available" +msgstr "利用可能" + +msgid "cmps_available_version" +msgstr "利用可能なバージョン" + +msgid "cmps_basic_files" +msgstr "基本ファイル" + +msgid "cmps_class_file" +msgstr "クラスファイル" + +msgid "cmps_component" +msgstr "構成要素" + +msgid "cmps_configure" +msgstr "構成" + +msgid "cmps_current_db_version" +msgstr "現在のDB-Version" + +msgid "cmps_current_version" +msgstr "現在のバージョン" + +msgid "cmps_database" +msgstr "データベース" + +msgid "cmps_db_update" +msgstr "DB更新スクリプト" + +msgid "cmps_deactivate" +msgstr "アクティブ解除" + +msgid "cmps_detailed_information" +msgstr "詳細を表示情報" + +msgid "cmps_dir" +msgstr "ディレクトリ" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "作成を有効にする" + +msgid "cmps_file_version" +msgstr "更新ファイルバージョン" + +msgid "cmps_group" +msgstr "グループ" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +msgid "cmps_inactive" +msgstr "非アクティブ" + +msgid "cmps_install" +msgstr "インストール" + +msgid "cmps_is_active" +msgstr "アクティブ化" + +msgid "cmps_is_installed" +msgstr "インストール済" + +msgid "cmps_lang_files" +msgstr "言語ファイル" + +msgid "cmps_lang_prefix" +msgstr "言語変数プレフィックス" + +msgid "cmps_languages" +msgstr "言語" + +msgid "cmps_main_dir" +msgstr "メインディレクトリ" + +msgid "cmps_missing" +msgstr "不明" + +msgid "cmps_module" +msgstr "モジュール" + +msgid "cmps_must_installed" +msgstr "コンポーネントをインストールする必要があります。" + +msgid "cmps_name" +msgstr "名前" + +msgid "cmps_needs_matching_ilias_version" +msgstr "このプラグインは現在のILIASバージョンでは動作しません。" + +msgid "cmps_needs_newer_ilias_version" +msgstr "このプラグインバージョンは新しいILIASバージョンでのみ動作します。 ILIASをバージョンアップしてください。" + +msgid "cmps_needs_newer_plugin_version" +msgstr "このILIASのバージョンには新しいプラグインバージョンが必要です。プラグインを更新してください。" + +msgid "cmps_needs_update" +msgstr "アップグレードが必要" + +msgid "cmps_needs_upgrade" +msgstr "現在インストールされているプラグインバージョンは以前インストールしていたバージョンよりも古いものです。プラグインをアップグレードしてください。" + +msgid "cmps_no_db_update_file_available" +msgstr "DB更新スクリプトがありません。" + +msgid "cmps_no_language_file_available" +msgstr "言語ファイルがありません。" + +msgid "cmps_plugin" +msgstr "プラグイン" + +msgid "cmps_plugin_activated" +msgstr "プラグインがアクティグになりました。" + +msgid "cmps_plugin_db_prefixes" +msgstr "DBテーブルプレフィックス" + +msgid "cmps_plugin_deactivated" +msgstr "プラグインは解除されました。" + +msgid "cmps_plugin_deinstalled" +msgstr "このプラグインは削除されました。" + +msgid "cmps_plugin_file" +msgstr "プラグインファイル" + +msgid "cmps_plugin_lang_prefixes" +msgstr "プラグイン言語プレフィックス" + +msgid "cmps_plugin_slot" +msgstr "プラグインスロット" + +msgid "cmps_plugin_uninstalled" +msgstr "全ての言語とデータベース関連エントリーは削除されてプラグインは設定解除されました。完全にプラグインをアンインストールするために'Customizing'ディレクトリ内のプラグインのディレクトリとソースコードを削除できます。" + +msgid "cmps_plugin_updated" +msgstr "プラグインはupdateされました。" + +msgid "cmps_plugins" +msgstr "プラグイン" + +msgid "cmps_refresh" +msgstr "言語をリフレッシュ" + +msgid "cmps_refresh_lng" +msgstr "言語をリフレッシュ" + +msgid "cmps_rep_object" +msgstr "オブジェクトタイプのリポジトリ" + +msgid "cmps_repository_object_types" +msgstr "リポジトリオブジェクトタイプ" + +msgid "cmps_responsible" +msgstr "責任者" + +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "オプションを保存" + +msgid "cmps_service" +msgstr "サービス" + +msgid "cmps_show_details" +msgstr "詳細を表示" + +msgid "cmps_slots" +msgstr "スロット" + +msgid "cmps_status" +msgstr "ステータス" + +msgid "cmps_supports_cli_setup" +msgstr "CLI-Setupをサポート" + +msgid "cmps_supports_export" +msgstr "exportをサポート" + +msgid "cmps_supports_learning_progress" +msgstr "学習進捗をサポート" + +msgid "cmps_uninstall" +msgstr "アンインストール" + +msgid "cmps_uninstall_confirm" +msgstr "本当にプラグイン\"%s\"をアンインストールして関連の全てのデータベースエントリーを削除しますか?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "プラグイン\"%1$s\"は現在アクティブ (\"%2$s\")にできません。この状態ではプラグインを完全にアンインストールできません。コアのみの統合に戻りプラグインカスタムデータは残る可能性があります。部分アンインストールを続行しますか?" + +msgid "cmps_update" +msgstr "更新" + +msgid "cmps_version" +msgstr "バージョン" + +msgid "database_is_uptodate" +msgstr "データベースは最新です。" + +msgid "no_changes" +msgstr "変更なし" diff --git a/components/ILIAS/Component/lang/cmps_ka.po b/components/ILIAS/Component/lang/cmps_ka.po new file mode 100644 index 000000000000..73086c3ef1ef --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_ka.po @@ -0,0 +1,254 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ka\n" +"Language-Team: Georgian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "გააქტიურება" + +msgid "cmps_active" +msgstr "აქტიური" + +msgid "cmps_add_new_rank" +msgstr "ადგილი ახალი ობოექტის დამატების სიაში" + +msgid "cmps_available" +msgstr "შესაძლებელია" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "ძირითადი ფაილები" + +msgid "cmps_class_file" +msgstr "კლასის ფაილი" + +msgid "cmps_component" +msgstr "კომპონენტი" + +msgid "cmps_configure" +msgstr "კონფიგურაცია" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "მიმდინარე ვერსია" + +msgid "cmps_database" +msgstr "მონაცემთა ბაზა" + +msgid "cmps_db_update" +msgstr "დი ბი განახლების ხელნაწერი" + +msgid "cmps_deactivate" +msgstr "დეაქტივაცია" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "მიმართულება" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "ფაილის ვერსიის განახლება" + +msgid "cmps_group" +msgstr "ჯგუფი" + +msgid "cmps_id" +msgstr "აი დი" + +msgid "cmps_ilias_max_version" +msgstr "მაქსიმალური . ილიასის ვერსია" + +msgid "cmps_ilias_min_version" +msgstr "მინ. ილიასის ვერსია" + +msgid "cmps_inactive" +msgstr "არააქტიური" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "ენის ფაილები" + +msgid "cmps_lang_prefix" +msgstr "ენის ცვლადების თავსართი" + +msgid "cmps_languages" +msgstr "ენები" + +msgid "cmps_main_dir" +msgstr "მთავარი მიმართულება" + +msgid "cmps_missing" +msgstr "დაკარგულია" + +msgid "cmps_module" +msgstr "მოდული" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "სახელი" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "ეს პროგრამული გაფართოვების ვერსია მოქმედებს მხოლოდ უახლეს ილიასის ვერსიაზე. გთხოვთ განაახლოთ ილიასი" + +msgid "cmps_needs_newer_plugin_version" +msgstr "ილიასის ამ ვერსიას სჭირდება უფრო ახალი პროგრამული გაფართოვების ვერსია. გთხოვთ განაახლოთ პროგრამული გაფართოების" + +msgid "cmps_needs_update" +msgstr "საჭიროა განახლება" + +msgid "cmps_needs_upgrade" +msgstr "პროგრამული გაფართოების ვერსია რომელიც ახლა დააინსტალირეთ უფრო ძველია ვიდრე წინა პროგრამული გაფართოვებისს ვერსია. გთხოვთ განაახლოთ პროგრამული გაფართოების" + +msgid "cmps_no_db_update_file_available" +msgstr "დი ბის განახლება შეუძლებელია" + +msgid "cmps_no_language_file_available" +msgstr "ენის ფაილები არ არის ხელმისსაწვდომი" + +msgid "cmps_plugin" +msgstr "პროგრამული გაფართოვება" + +msgid "cmps_plugin_activated" +msgstr "პროგრამული გაფართოვება გააქტიურებულია" + +msgid "cmps_plugin_db_prefixes" +msgstr "დი ბის მაგიდის თავსართები" + +msgid "cmps_plugin_deactivated" +msgstr "პროგრამული გაფართოვება არააქტიურია" + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "პროგრამული გაფართოვების ფაილი" + +msgid "cmps_plugin_lang_prefixes" +msgstr "ენის თავსართების პროგრამული გაფართოვება" + +msgid "cmps_plugin_slot" +msgstr "პროგრამული გაფართოვების კვალი" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "ჩანართები" + +msgid "cmps_refresh" +msgstr "განახლება" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "სასაწყობე ობიექტების სახეობა" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "პასუხისმგებელი" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "პარამეტრების შენახვა" + +msgid "cmps_service" +msgstr "სერვისი" + +msgid "cmps_show_details" +msgstr "დეტალების ჩვენება" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "სტატუსი" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "განახლება" + +msgid "cmps_version" +msgstr "ვერსია" + +msgid "database_is_uptodate" +msgstr "მონაცემთა ბაზა განახლებულია" + +msgid "no_changes" +msgstr "ცვლილებები არ არის" diff --git a/components/ILIAS/Component/lang/cmps_lt.po b/components/ILIAS/Component/lang/cmps_lt.po new file mode 100644 index 000000000000..6078f001d543 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_lt.po @@ -0,0 +1,261 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: lt\n" +"Language-Team: Lithuanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && (n % 100 < 11 || n % 100 > 19)) ? 0 : ((n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktyvuoti" + +msgid "cmps_active" +msgstr "Aktyvu" + +#, fuzzy +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +msgid "cmps_available" +msgstr "Pasiekiama" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Pagrindiniai Failai" + +msgid "cmps_class_file" +msgstr "Klasės Failas" + +msgid "cmps_component" +msgstr "Komponentas" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Versija" + +msgid "cmps_database" +msgstr "Duombazė" + +msgid "cmps_db_update" +msgstr "DB Atnaujinimo Skriptas" + +msgid "cmps_deactivate" +msgstr "Išjungti" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Katalogas" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Atnaujinti Failo Versiją" + +msgid "cmps_group" +msgstr "Grupė" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maks. ILIAS Versija" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Versija" + +msgid "cmps_inactive" +msgstr "Neaktyvu" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Kalbos Failai" + +msgid "cmps_lang_prefix" +msgstr "Kalbos Kintamųjų Priešdėliai" + +msgid "cmps_languages" +msgstr "Kalbos" + +msgid "cmps_main_dir" +msgstr "Pagrindinis Katalogas" + +msgid "cmps_missing" +msgstr "Trūksta" + +msgid "cmps_module" +msgstr "Modulis" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Pavadinimas" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Šis įskiepis veikia tik naujesnėje ILIAS versijoje. Atnaujinkite ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Šiai ILIAS versijai reikia naujesnio įskiepio. Atnaujinkite įskiepį." + +msgid "cmps_needs_update" +msgstr "Reikia atnaujinti" + +msgid "cmps_needs_upgrade" +msgstr "Dabar diegiamas įskiepis yra senesnis už jau įdiegtą. Atnaujinkite įskiepį." + +msgid "cmps_no_db_update_file_available" +msgstr "Nėra DB atnaujinimo skripto" + +msgid "cmps_no_language_file_available" +msgstr "Nėra kalbos failo" + +msgid "cmps_plugin" +msgstr "Įskiepis" + +#, fuzzy +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Lentelės Priešdėliai" + +#, fuzzy +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Įskiepio Failas" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Įskiepių Kalbos Priešdėliai" + +msgid "cmps_plugin_slot" +msgstr "Įskiepio Vieta" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Įskiepiai" + +msgid "cmps_refresh" +msgstr "Atnaujinti" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Saugyklos Objekto Tipas" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +#, fuzzy +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Išsaugoti Nustatymus" + +msgid "cmps_service" +msgstr "Tarnyba" + +msgid "cmps_show_details" +msgstr "Rodyti Detales" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Statusas" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Naujinti" + +msgid "cmps_version" +msgstr "Versija" + +#, fuzzy +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +#, fuzzy +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_nl.po b/components/ILIAS/Component/lang/cmps_nl.po new file mode 100644 index 000000000000..c4ed07f8c72a --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_nl.po @@ -0,0 +1,251 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: nl\n" +"Language-Team: Dutch\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Inschakelen" + +msgid "cmps_active" +msgstr "Actief" + +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +msgid "cmps_available" +msgstr "Beschikbaar" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Standaard bestanden" + +msgid "cmps_class_file" +msgstr "Class File" + +msgid "cmps_component" +msgstr "Component" + +msgid "cmps_configure" +msgstr "Configureren" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Huidige versie" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "DB Update Script" + +msgid "cmps_deactivate" +msgstr "Uitschakelen" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Folder" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Aanmaken toestaan" + +msgid "cmps_file_version" +msgstr "Update File Version" + +msgid "cmps_group" +msgstr "Groep" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Versie" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Versie" + +msgid "cmps_inactive" +msgstr "Inactief" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Taalbestanden" + +msgid "cmps_lang_prefix" +msgstr "Prefix taalvariabelen" + +msgid "cmps_languages" +msgstr "Talen" + +msgid "cmps_main_dir" +msgstr "Hoofdfolder" + +msgid "cmps_missing" +msgstr "Niet aanwezig" + +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Naam" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +msgid "cmps_needs_update" +msgstr "Wijziging nodig." + +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin File" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Verversen" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Studiecentrum Object Type" + +msgid "cmps_repository_object_types" +msgstr "Studiecentrum objecttypes" + +msgid "cmps_responsible" +msgstr "Verantwoordelijk" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Opties opslaan" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Tonen Details" + +msgid "cmps_slots" +msgstr "Openingen" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Update" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "De database is bijgewerkt" + +msgid "no_changes" +msgstr "Geen veranderingen" diff --git a/components/ILIAS/Component/lang/cmps_pl.po b/components/ILIAS/Component/lang/cmps_pl.po new file mode 100644 index 000000000000..9001042e3898 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_pl.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pl\n" +"Language-Team: Polish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktywuj" + +msgid "cmps_active" +msgstr "Aktywny" + +msgid "cmps_add_new_rank" +msgstr "Jesteś w liście wyboru dla obiektów, które należy dodać" + +msgid "cmps_available" +msgstr "Dostępne" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Pliki podstawowe" + +msgid "cmps_class_file" +msgstr "Pliki klas" + +msgid "cmps_component" +msgstr "Elementy" + +msgid "cmps_configure" +msgstr "Konfiguruj" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Aktualna wersja" + +msgid "cmps_database" +msgstr "Baza danych" + +msgid "cmps_db_update" +msgstr "Skrypt do aktualizacji bazy danych" + +msgid "cmps_deactivate" +msgstr "Deaktywuj" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Indeks" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Włącz tworzenie" + +msgid "cmps_file_version" +msgstr "Zaktualizuj wersję pliku" + +msgid "cmps_group" +msgstr "Grupa" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maksymalna wersja ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Minimalna wersja ILIAS" + +msgid "cmps_inactive" +msgstr "Nieaktywny" + +msgid "cmps_install" +msgstr "Instaluj" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Plik językowy" + +msgid "cmps_lang_prefix" +msgstr "Prefiks dla zmiennej języków" + +msgid "cmps_languages" +msgstr "Języki" + +msgid "cmps_main_dir" +msgstr "Indeks główny" + +msgid "cmps_missing" +msgstr "Brak" + +msgid "cmps_module" +msgstr "Moduł" + +msgid "cmps_must_installed" +msgstr "Należy zainstalować element." + +msgid "cmps_name" +msgstr "Nazwa" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Tej wtyczki można używać wyłącznie z nowszą wersją ILIAS. Zaktualizuj swoją wersję ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Ta wersja ILIAS potrzebują nowszą wersję wtyczki. Zaktualizuj wtyczkę." + +msgid "cmps_needs_update" +msgstr "Konieczna jest aktualizacja" + +msgid "cmps_needs_upgrade" +msgstr "Aktualnie zainstalowana wtyczka jest starsza niż poprzednia wersja. Zainstaluj aktualną wersję wtyczki." + +msgid "cmps_no_db_update_file_available" +msgstr "Skrypt dla aktualizacji bazy danych nie jest dostępny" + +msgid "cmps_no_language_file_available" +msgstr "Brak dostępnego pliku językowego" + +msgid "cmps_plugin" +msgstr "Wtyczka" + +msgid "cmps_plugin_activated" +msgstr "Włączono wtyczkę." + +msgid "cmps_plugin_db_prefixes" +msgstr "Prefiksy dla tabeli bazy danych" + +msgid "cmps_plugin_deactivated" +msgstr "Wyłączono wtyczkę." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plik wtyczki" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Prefiksy językowe wtyczki" + +msgid "cmps_plugin_slot" +msgstr "Interfejs wtyczki" + +msgid "cmps_plugin_uninstalled" +msgstr "Wszystkie wpisy językowe i w bazie danych, które odnoszą się do wtyczki, zostały usunięte, a wtyczka odinstalowana. Teraz możesz usunąć indeks i kod źródłowy wtyczki z indeksu ‘Customizing’, aby ostatecznie odinstalować wtyczkę." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Wtyczki" + +msgid "cmps_refresh" +msgstr "Ponownie załaduj język" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Typ obiektu w dostępnych zasobach" + +msgid "cmps_repository_object_types" +msgstr "Typy obiektów w dostępnych zasobach" + +msgid "cmps_responsible" +msgstr "Odpowiedzialny" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Zapisz" + +msgid "cmps_service" +msgstr "Serwis" + +msgid "cmps_show_details" +msgstr "Pokaż szczegóły" + +msgid "cmps_slots" +msgstr "Sloty" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Odinstaluj" + +msgid "cmps_uninstall_confirm" +msgstr "Czy naprawdę chcesz odinstalować wtyczkę \"%s\" i usunąć wszystkie przydzielone wpisy w bazie danych?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Aktualnie nie można aktywować wtyczki \"%1$s\" (\"%2$s\"). Ten status nie pozwala na całkowite odinstalowanie wtyczek. Ponieważ usuwana jest wyłącznie integracja systemowa, istnieje możliwość, że zostaną zachowane niektóre dane wtyczek. Czy chcesz kontynuować częściowe odinstalowanie?" + +msgid "cmps_update" +msgstr "Zaktualizuj" + +msgid "cmps_version" +msgstr "Wersja" + +msgid "database_is_uptodate" +msgstr "Baza danych jest aktualna." + +msgid "no_changes" +msgstr "Brak zmian" diff --git a/components/ILIAS/Component/lang/cmps_pt.po b/components/ILIAS/Component/lang/cmps_pt.po new file mode 100644 index 000000000000..a70605414bca --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_pt.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pt\n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Verifique o seu ficheiro de idioma" + +msgid "cmps_active" +msgstr "Verificar todos os idiomas" + +msgid "cmps_add_new_rank" +msgstr "Posição em lista Adicionar novos objetos" + +msgid "cmps_available" +msgstr "Verificação do weblink" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Se estiver ativado, todos os links externos nos módulos de aprendizagem ILIAS serão verificados quanto á sua ativação." + +msgid "cmps_class_file" +msgstr "Verificar as contas de utilizador" + +msgid "cmps_component" +msgstr "Se estiver ativado, todos os utilizadores cujas contas expiram serão informados por e-mail duas semanas antes. Além disso, todos os utilizadores que não confirmaram a sua conta depois de um registo com confirmação de e-mail serão apagados." + +msgid "cmps_configure" +msgstr "Configurar" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Verificar links de web" + +msgid "cmps_database" +msgstr "Se estiver ativado, todos os weblinks serão verificados quanto à sua ativação." + +msgid "cmps_db_update" +msgstr "Mudar palavra-passe da pasta web" + +msgid "cmps_deactivate" +msgstr "Mudar palavra-passe ILIAS" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Mudar palavra-passe" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Ativar criação" + +msgid "cmps_file_version" +msgstr "Atualizar versão de ficheiro" + +msgid "cmps_group" +msgstr "Selecione seu idioma" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Selecione apenas um idioma" + +msgid "cmps_ilias_min_version" +msgstr "Acesso de leitura à administração da sala de chat" + +msgid "cmps_inactive" +msgstr "A administração da sala de chat está visível" + +msgid "cmps_install" +msgstr "Instalar" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Copiar sala de chat" + +msgid "cmps_lang_prefix" +msgstr "Cidade, estado" + +msgid "cmps_languages" +msgstr "Editar administração da sala de chat" + +msgid "cmps_main_dir" +msgstr "Limpar" + +msgid "cmps_missing" +msgstr "Limpar área de transferência" + +msgid "cmps_module" +msgstr "Cliente" + +msgid "cmps_must_installed" +msgstr "O componente tem de estar instalado." + +msgid "cmps_name" +msgstr "ID do cliente" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Esta versão de plugin apenas funciona em versões mais recentes de ILIAS. Atualize o ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Esta versão de ILIAS requer uma versão de plugin mais recente. Atualize o plugin." + +msgid "cmps_needs_update" +msgstr "IP do cliente" + +msgid "cmps_needs_upgrade" +msgstr "A versão de plugin atualmente instalada é mais antiga do que a versão anteriormente instalada. Atualize o plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "Nenhum script de atualização DB disponível." + +msgid "cmps_no_language_file_available" +msgstr "Nenhum ficheiro de idioma disponível." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "O plugin foi ativado." + +msgid "cmps_plugin_db_prefixes" +msgstr "Fechar" + +msgid "cmps_plugin_deactivated" +msgstr "O plugin foi desativado." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "(%s Novo)" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Colapsado" + +msgid "cmps_plugin_slot" +msgstr "Colunas" + +msgid "cmps_plugin_uninstalled" +msgstr "Separado por vírgulas" + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Comentário" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Compor" + +msgid "cmps_repository_object_types" +msgstr "Tipos de objeto do repositório" + +msgid "cmps_responsible" +msgstr "Confirmar" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Contacto" + +msgid "cmps_service" +msgstr "Informação de contacto" + +msgid "cmps_show_details" +msgstr "Contactar a Assistência Técnica" + +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Estado" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Desinstalar" + +msgid "cmps_uninstall_confirm" +msgstr "Conteúdo" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "O plugin \"%1$s\" atualmente não pode ser ativado (\"%2$s\"). Neste estado, os plugins não podem ser desinstalados completamente. Como apenas a integração nuclear é revertida, os dados personalizados do plugin podem permanecer. Quer continuar com a desinstalação parcial?" + +msgid "cmps_update" +msgstr "Atualizar" + +msgid "cmps_version" +msgstr "Versão" + +msgid "database_is_uptodate" +msgstr "Estilos de conteúdo" + +msgid "no_changes" +msgstr "Contexto" diff --git a/components/ILIAS/Component/lang/cmps_ro.po b/components/ILIAS/Component/lang/cmps_ro.po new file mode 100644 index 000000000000..573e4a21fe88 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_ro.po @@ -0,0 +1,305 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ro\n" +"Language-Team: Romanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2);\n" +"X-Domain: cmps\n" + +#, fuzzy +msgid "cmps_activate" +msgstr "Activate" + +#, fuzzy +msgid "cmps_active" +msgstr "Active" + +#, fuzzy +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +#, fuzzy +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +#, fuzzy +msgid "cmps_basic_files" +msgstr "Basic Files" + +#, fuzzy +msgid "cmps_class_file" +msgstr "Class File" + +#, fuzzy +msgid "cmps_component" +msgstr "Component" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +#, fuzzy +msgid "cmps_current_version" +msgstr "Current Version" + +#, fuzzy +msgid "cmps_database" +msgstr "Database" + +#, fuzzy +msgid "cmps_db_update" +msgstr "DB Update Script" + +#, fuzzy +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +#, fuzzy +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +#, fuzzy +msgid "cmps_file_version" +msgstr "Update File Version" + +#, fuzzy +msgid "cmps_group" +msgstr "Group" + +#, fuzzy +msgid "cmps_id" +msgstr "ID" + +#, fuzzy +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +#, fuzzy +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +#, fuzzy +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +#, fuzzy +msgid "cmps_lang_files" +msgstr "Language Files" + +#, fuzzy +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +#, fuzzy +msgid "cmps_languages" +msgstr "Languages" + +#, fuzzy +msgid "cmps_main_dir" +msgstr "Main Directory" + +#, fuzzy +msgid "cmps_missing" +msgstr "Missing" + +#, fuzzy +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +#, fuzzy +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +#, fuzzy +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +#, fuzzy +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_needs_update" +msgstr "Needs update." + +#, fuzzy +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +#, fuzzy +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +#, fuzzy +msgid "cmps_plugin" +msgstr "Plugin" + +#, fuzzy +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +#, fuzzy +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +#, fuzzy +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +#, fuzzy +msgid "cmps_plugin_file" +msgstr "Plugin File" + +#, fuzzy +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +#, fuzzy +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +#, fuzzy +msgid "cmps_plugins" +msgstr "Plugins" + +#, fuzzy +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +#, fuzzy +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +#, fuzzy +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +#, fuzzy +msgid "cmps_save_options" +msgstr "Save Options" + +#, fuzzy +msgid "cmps_service" +msgstr "Service" + +#, fuzzy +msgid "cmps_show_details" +msgstr "Show Details" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +#, fuzzy +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +#, fuzzy +msgid "cmps_update" +msgstr "Update" + +#, fuzzy +msgid "cmps_version" +msgstr "Version" + +#, fuzzy +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +#, fuzzy +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_ru.po b/components/ILIAS/Component/lang/cmps_ru.po new file mode 100644 index 000000000000..66795fa51992 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_ru.po @@ -0,0 +1,251 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ru\n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Activate" + +msgid "cmps_active" +msgstr "Active" + +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Basic Files" + +msgid "cmps_class_file" +msgstr "Class File" + +msgid "cmps_component" +msgstr "Component" + +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Current Version" + +msgid "cmps_database" +msgstr "Database" + +msgid "cmps_db_update" +msgstr "DB Update Script" + +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Директория" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Update File Version" + +msgid "cmps_group" +msgstr "Group" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Language Files" + +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +msgid "cmps_languages" +msgstr "Languages" + +msgid "cmps_main_dir" +msgstr "Главная директория" + +msgid "cmps_missing" +msgstr "Missing" + +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +msgid "cmps_needs_update" +msgstr "Needs update." + +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin File" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +msgid "cmps_plugin_slot" +msgstr "Слот плагина" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Плагины" + +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Save Options" + +msgid "cmps_service" +msgstr "Service" + +msgid "cmps_show_details" +msgstr "Show Details" + +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Update" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_sk.po b/components/ILIAS/Component/lang/cmps_sk.po new file mode 100644 index 000000000000..cfe193783c77 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_sk.po @@ -0,0 +1,254 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sk\n" +"Language-Team: Slovak\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktivovat" + +msgid "cmps_active" +msgstr "Aktivní" + +msgid "cmps_add_new_rank" +msgstr "Pozice v seznamu \"Přidat nový objekt\"" + +msgid "cmps_available" +msgstr "Dostupné" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Základní soubory" + +msgid "cmps_class_file" +msgstr "Soubor třídy" + +msgid "cmps_component" +msgstr "Komponenta" + +msgid "cmps_configure" +msgstr "Konfigurovat" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Aktuální verze" + +msgid "cmps_database" +msgstr "Databáze" + +msgid "cmps_db_update" +msgstr "Skript aktualizace DB" + +msgid "cmps_deactivate" +msgstr "Deaktivovat" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Adresář" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Verze akualizačního souboru" + +msgid "cmps_group" +msgstr "Skupina" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. verze ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Min. verze ILIAS" + +msgid "cmps_inactive" +msgstr "Neaktivní" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Soubory jazyků" + +msgid "cmps_lang_prefix" +msgstr "Označení jazykových proměnných" + +msgid "cmps_languages" +msgstr "Jazyky" + +msgid "cmps_main_dir" +msgstr "Hlavní adresář" + +msgid "cmps_missing" +msgstr "Chybí" + +msgid "cmps_module" +msgstr "Modul" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Název" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Tato verze pluginu běží jen na novější verzi ILIAS. Aktualizujte prosím ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Tato verze ILIAS vyžaduje novější verzi zásuvného souboru. Aktualizujte prosím plugin." + +msgid "cmps_needs_update" +msgstr "Vyžaduje aktualizaci." + +msgid "cmps_needs_upgrade" +msgstr "Verze pluginu kterou instalujete je starší než nainstalovaná verze. Aktualizujte prosím plugin." + +msgid "cmps_no_db_update_file_available" +msgstr "Není dostupný žádný skript aktualizace DB." + +msgid "cmps_no_language_file_available" +msgstr "Žádný jazykový soubor není dostupný." + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "Plugin byl aktivován." + +msgid "cmps_plugin_db_prefixes" +msgstr "Označení tabulek DB" + +msgid "cmps_plugin_deactivated" +msgstr "Plugin byl deaktivován." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Soubor pluginu" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Označení jazykových pluginů" + +msgid "cmps_plugin_slot" +msgstr "Slot pluginu" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Pluginy" + +msgid "cmps_refresh" +msgstr "Obnovení" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Typ objektu úložiště" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Zodpovědný" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Uložit nastavení" + +msgid "cmps_service" +msgstr "Služba" + +msgid "cmps_show_details" +msgstr "Zobrazit detaily" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Akualizovat" + +msgid "cmps_version" +msgstr "Verze" + +msgid "database_is_uptodate" +msgstr "Tato databáze je aktuální." + +msgid "no_changes" +msgstr "Žádné změny" diff --git a/components/ILIAS/Component/lang/cmps_sl.po b/components/ILIAS/Component/lang/cmps_sl.po new file mode 100644 index 000000000000..8643d8aff74d --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_sl.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sl\n" +"Language-Team: Slovenian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=4; plural=(n % 100 == 1) ? 0 : ((n % 100 == 2) ? 1 : ((n % 100 == 3 || n % 100 == 4) ? 2 : 3));\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktiviraj" + +msgid "cmps_active" +msgstr "Aktivno" + +msgid "cmps_add_new_rank" +msgstr "Položaj na izbirnem seznamu objektov za dodajanje" + +msgid "cmps_available" +msgstr "Razpoložljivo" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Osnovne datoteke" + +msgid "cmps_class_file" +msgstr "Razredna datoteka" + +msgid "cmps_component" +msgstr "Komponenta" + +msgid "cmps_configure" +msgstr "Konfiguriraj" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Aktualna različica" + +msgid "cmps_database" +msgstr "Baza podatkov" + +msgid "cmps_db_update" +msgstr "Skripta za posodobitev baze podatkov" + +msgid "cmps_deactivate" +msgstr "Onemogoči" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Seznam" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Vključi ustvarjanje" + +msgid "cmps_file_version" +msgstr "Posodobi različico datoteke" + +msgid "cmps_group" +msgstr "Skupina" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maksimalna različica ILIAS" + +msgid "cmps_ilias_min_version" +msgstr "Minimalna različica ILIAS" + +msgid "cmps_inactive" +msgstr "Neaktiven" + +msgid "cmps_install" +msgstr "Namesti" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Jezikovna datoteka" + +msgid "cmps_lang_prefix" +msgstr "Predpona jezikovnih spremenljivk" + +msgid "cmps_languages" +msgstr "Jeziki" + +msgid "cmps_main_dir" +msgstr "Glavni seznam" + +msgid "cmps_missing" +msgstr "Manjka" + +msgid "cmps_module" +msgstr "Module" + +msgid "cmps_must_installed" +msgstr "Komponenta mora biti nameščena." + +msgid "cmps_name" +msgstr "Ime" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Ta vtičnik se lahko uporablja samo z novejšo različico ILIAS. Posodobite različico ILIAS." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Ta različica ILIAS potrebuje novejšo različico vtičnika. Posodobite vtičnik." + +msgid "cmps_needs_update" +msgstr "Potrebna je posodobitev" + +msgid "cmps_needs_upgrade" +msgstr "Trenutno nameščena različica vtičnika je starejša od predhodno nameščene. Namestite aktualen vtičnik." + +msgid "cmps_no_db_update_file_available" +msgstr "Skripte za posodobitev baze podatkov ni na voljo" + +msgid "cmps_no_language_file_available" +msgstr "Nobena jezikovna datoteka ni na voljo" + +msgid "cmps_plugin" +msgstr "Vtičnik" + +msgid "cmps_plugin_activated" +msgstr "Vtičnik je bil aktiviran." + +msgid "cmps_plugin_db_prefixes" +msgstr "Predpone tabele baze podatkov" + +msgid "cmps_plugin_deactivated" +msgstr "Vtičnik je bil onemogočen." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Datoteka vtičnika" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Jezikovne predpone vtičnika" + +msgid "cmps_plugin_slot" +msgstr "Reža vtičnika" + +msgid "cmps_plugin_uninstalled" +msgstr "Vsi jezikovni vnosi in vnosi v bazo podatkov, ki se nanašajo na vtičnik, so bili izbrisani in vtičnik je onemogočen. Zdaj lahko s seznama ’Prilagajanje’ odstranite seznam in izvorno kodo vtičnika, da vtičnik dokončno odstranite." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Vtičniki" + +msgid "cmps_refresh" +msgstr "Na novo naloži jezike" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Tip objekta v magazinu" + +msgid "cmps_repository_object_types" +msgstr "Tipi objektov v magazinu" + +msgid "cmps_responsible" +msgstr "Odgovoren" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Shrani" + +msgid "cmps_service" +msgstr "Storitev" + +msgid "cmps_show_details" +msgstr "Prikaži podrobnosti" + +msgid "cmps_slots" +msgstr "Dodeljeni čas" + +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +msgid "cmps_uninstall" +msgstr "Odstrani" + +msgid "cmps_uninstall_confirm" +msgstr "Ali res želite odstraniti vtičnik \"%s\" in izbrisati vse povezane vnose v bazi podatkov?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Vtičnika \"%1$s\" se trenutno ne da aktivirati (\"%2$s\"). V tem statusu ni mogoče v celoti odstraniti vtičnikov. Ker je izbrisana samo sistemska integracija, lahko lastne datoteke vtičnika ostanejo. Ali želite nadaljevati z delno odstranitvijo?" + +msgid "cmps_update" +msgstr "Posodobi" + +msgid "cmps_version" +msgstr "Različica" + +msgid "database_is_uptodate" +msgstr "Baza podatkov je posodobljena." + +msgid "no_changes" +msgstr "Brez sprememb" diff --git a/components/ILIAS/Component/lang/cmps_sq.po b/components/ILIAS/Component/lang/cmps_sq.po new file mode 100644 index 000000000000..4672cb547d76 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_sq.po @@ -0,0 +1,305 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sq\n" +"Language-Team: Albanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +#, fuzzy +msgid "cmps_activate" +msgstr "Activate" + +#, fuzzy +msgid "cmps_active" +msgstr "Active" + +#, fuzzy +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +#, fuzzy +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +#, fuzzy +msgid "cmps_basic_files" +msgstr "Basic Files" + +#, fuzzy +msgid "cmps_class_file" +msgstr "Class File" + +#, fuzzy +msgid "cmps_component" +msgstr "Component" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +#, fuzzy +msgid "cmps_current_version" +msgstr "Current Version" + +#, fuzzy +msgid "cmps_database" +msgstr "Database" + +#, fuzzy +msgid "cmps_db_update" +msgstr "DB Update Script" + +#, fuzzy +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +#, fuzzy +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +#, fuzzy +msgid "cmps_file_version" +msgstr "Update File Version" + +#, fuzzy +msgid "cmps_group" +msgstr "Group" + +#, fuzzy +msgid "cmps_id" +msgstr "ID" + +#, fuzzy +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +#, fuzzy +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +#, fuzzy +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +#, fuzzy +msgid "cmps_lang_files" +msgstr "Language Files" + +#, fuzzy +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +#, fuzzy +msgid "cmps_languages" +msgstr "Languages" + +#, fuzzy +msgid "cmps_main_dir" +msgstr "Main Directory" + +#, fuzzy +msgid "cmps_missing" +msgstr "Missing" + +#, fuzzy +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +#, fuzzy +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +#, fuzzy +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +#, fuzzy +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_needs_update" +msgstr "Needs update." + +#, fuzzy +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +#, fuzzy +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +#, fuzzy +msgid "cmps_plugin" +msgstr "Plugin" + +#, fuzzy +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +#, fuzzy +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +#, fuzzy +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +#, fuzzy +msgid "cmps_plugin_file" +msgstr "Plugin File" + +#, fuzzy +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +#, fuzzy +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +#, fuzzy +msgid "cmps_plugins" +msgstr "Plugins" + +#, fuzzy +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +#, fuzzy +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +#, fuzzy +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +#, fuzzy +msgid "cmps_save_options" +msgstr "Save Options" + +#, fuzzy +msgid "cmps_service" +msgstr "Service" + +#, fuzzy +msgid "cmps_show_details" +msgstr "Show Details" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +#, fuzzy +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +#, fuzzy +msgid "cmps_update" +msgstr "Update" + +#, fuzzy +msgid "cmps_version" +msgstr "Version" + +#, fuzzy +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +#, fuzzy +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_sr.po b/components/ILIAS/Component/lang/cmps_sr.po new file mode 100644 index 000000000000..588e9d1f9a56 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_sr.po @@ -0,0 +1,305 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sr\n" +"Language-Team: Serbian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: cmps\n" + +#, fuzzy +msgid "cmps_activate" +msgstr "Activate" + +#, fuzzy +msgid "cmps_active" +msgstr "Active" + +#, fuzzy +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +#, fuzzy +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +#, fuzzy +msgid "cmps_basic_files" +msgstr "Basic Files" + +#, fuzzy +msgid "cmps_class_file" +msgstr "Class File" + +#, fuzzy +msgid "cmps_component" +msgstr "Component" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +#, fuzzy +msgid "cmps_current_version" +msgstr "Current Version" + +#, fuzzy +msgid "cmps_database" +msgstr "Database" + +#, fuzzy +msgid "cmps_db_update" +msgstr "DB Update Script" + +#, fuzzy +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +#, fuzzy +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +#, fuzzy +msgid "cmps_file_version" +msgstr "Update File Version" + +#, fuzzy +msgid "cmps_group" +msgstr "Group" + +#, fuzzy +msgid "cmps_id" +msgstr "ID" + +#, fuzzy +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +#, fuzzy +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +#, fuzzy +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +#, fuzzy +msgid "cmps_lang_files" +msgstr "Language Files" + +#, fuzzy +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +#, fuzzy +msgid "cmps_languages" +msgstr "Languages" + +#, fuzzy +msgid "cmps_main_dir" +msgstr "Main Directory" + +#, fuzzy +msgid "cmps_missing" +msgstr "Missing" + +#, fuzzy +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +#, fuzzy +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +#, fuzzy +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +#, fuzzy +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_needs_update" +msgstr "Needs update." + +#, fuzzy +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +#, fuzzy +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +#, fuzzy +msgid "cmps_plugin" +msgstr "Plugin" + +#, fuzzy +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +#, fuzzy +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +#, fuzzy +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +#, fuzzy +msgid "cmps_plugin_file" +msgstr "Plugin File" + +#, fuzzy +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +#, fuzzy +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +#, fuzzy +msgid "cmps_plugins" +msgstr "Plugins" + +#, fuzzy +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +#, fuzzy +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +#, fuzzy +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +#, fuzzy +msgid "cmps_save_options" +msgstr "Save Options" + +#, fuzzy +msgid "cmps_service" +msgstr "Service" + +#, fuzzy +msgid "cmps_show_details" +msgstr "Show Details" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +#, fuzzy +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +#, fuzzy +msgid "cmps_update" +msgstr "Update" + +#, fuzzy +msgid "cmps_version" +msgstr "Version" + +#, fuzzy +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +#, fuzzy +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_sv.po b/components/ILIAS/Component/lang/cmps_sv.po new file mode 100644 index 000000000000..3d5e00d729c1 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_sv.po @@ -0,0 +1,232 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sv\n" +"Language-Team: Swedish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Aktivera" + +msgid "cmps_active" +msgstr "Aktiv" + +msgid "cmps_add_new_rank" +msgstr "Position i urvalslistan för objekt som ska läggas till" + +msgid "cmps_available" +msgstr "Tillgänglig" + +msgid "cmps_available_version" +msgstr "Tillgänglig version" + +msgid "cmps_basic_files" +msgstr "Basisfiler" + +msgid "cmps_class_file" +msgstr " Klassfil" + +msgid "cmps_component" +msgstr "Komponent" + +msgid "cmps_configure" +msgstr "Konfigurera" + +msgid "cmps_current_db_version" +msgstr "Aktuell DB-version" + +msgid "cmps_current_version" +msgstr "Aktuell version" + +msgid "cmps_database" +msgstr "Databas" + +msgid "cmps_db_update" +msgstr "Script för uppdatering av databas" + +msgid "cmps_deactivate" +msgstr "Avaktivera" + +msgid "cmps_detailed_information" +msgstr "Detaljerad information" + +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +msgid "cmps_enable_creation" +msgstr "Starta skapandet" + +msgid "cmps_file_version" +msgstr "Uppdatera filversion" + +msgid "cmps_group" +msgstr "Grupp" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maximal ILIAS-version" + +msgid "cmps_ilias_min_version" +msgstr "Minsta ILIAS-version" + +msgid "cmps_inactive" +msgstr "Inaktiv" + +msgid "cmps_install" +msgstr "Installera" + +msgid "cmps_is_active" +msgstr "Aktiverad" + +msgid "cmps_is_installed" +msgstr "Installerad" + +msgid "cmps_lang_files" +msgstr "Språkfil" + +msgid "cmps_lang_prefix" +msgstr "Prefix språkvariabel" + +msgid "cmps_languages" +msgstr "Språk" + +msgid "cmps_main_dir" +msgstr "Huvudkatalog" + +msgid "cmps_missing" +msgstr "Saknad" + +msgid "cmps_module" +msgstr "Modul" + +msgid "cmps_must_installed" +msgstr "Komponenten måste vara installerad." + +msgid "cmps_name" +msgstr "Namn" + +msgid "cmps_needs_matching_ilias_version" +msgstr "Detta plugin kan inte användas med den aktuella versionen av denna ILIAS-installation." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Detta plugin kan endast användas med en nyare ILIAS-version. Vänligen uppdatera din ILIAS-version." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Den här ILIAS-versionen kräver en nyare plugin-version. Vänligen uppdatera pluginet." + +msgid "cmps_needs_update" +msgstr "Uppdatering nödvändig" + +msgid "cmps_needs_upgrade" +msgstr "Den nuvarande installerade plugin-versionen är äldre än den tidigare installerade. Vänligen installera det senaste pluginet." + +msgid "cmps_no_db_update_file_available" +msgstr "Inget skript för uppdatering av databas tillgängligt" + +msgid "cmps_no_language_file_available" +msgstr "Ingen språkfil tillgänglig" + +msgid "cmps_plugin" +msgstr "Plugin" + +msgid "cmps_plugin_activated" +msgstr "Insticksprogrammet har aktiverats." + +msgid "cmps_plugin_db_prefixes" +msgstr "Prefixar databastabell" + +msgid "cmps_plugin_deactivated" +msgstr "Insticksprogrammet har avaktiverats." + +msgid "cmps_plugin_deinstalled" +msgstr "Insticksprogrammet har avinstallerats" + +msgid "cmps_plugin_file" +msgstr "Plugin-fil" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin språk prefix" + +msgid "cmps_plugin_slot" +msgstr "Plugin-plats" + +msgid "cmps_plugin_uninstalled" +msgstr "Alla språk och databasposter relaterade till pluginet har tagits bort och pluginet har avaktiverats. Du kan nu ta bort katalogen och källkoden för insticksprogrammet från katalogen \"Customizing\" för att slutligen avinstallera insticksprogrammet." + +msgid "cmps_plugin_updated" +msgstr "Insticksprogrammet har uppdaterats" + +msgid "cmps_plugins" +msgstr "Plugins" + +msgid "cmps_refresh" +msgstr "Reload språk" + +msgid "cmps_refresh_lng" +msgstr "Språk uppdaterat" + +msgid "cmps_rep_object" +msgstr "Objekttyp i tidningen" + +msgid "cmps_repository_object_types" +msgstr "Objekttyper i tidningen" + +msgid "cmps_responsible" +msgstr "Ansvarsfull" + +msgid "cmps_responsible_mail" +msgstr "Mail (ansvarig person)" + +msgid "cmps_save_options" +msgstr "Spara" + +msgid "cmps_service" +msgstr "Tjänst" + +msgid "cmps_show_details" +msgstr "Visa detaljer" + +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Status" + +msgid "cmps_supports_cli_setup" +msgstr "Stödjer CLI-installation" + +msgid "cmps_supports_export" +msgstr "Stödd export" + +msgid "cmps_supports_learning_progress" +msgstr "Stödjer framsteg i inlärningen" + +msgid "cmps_uninstall" +msgstr "Avinstallera" + +msgid "cmps_uninstall_confirm" +msgstr "Vill du verkligen avinstallera insticksprogrammet \"%s\" och ta bort alla tillhörande databasposter?" + +msgid "cmps_uninstall_inactive_confirm" +msgstr "Insticksprogrammet \"%1$s\" kan för närvarande inte aktiveras (\"%2$s\"). I denna status kan plugins inte avinstalleras helt. Eftersom endast systemintegrationen tas bort kan data som ägs av plugin finnas kvar. Vill du fortsätta med den partiella avinstallat" + +msgid "cmps_update" +msgstr "Uppdatera" + +msgid "cmps_version" +msgstr "Version" + +msgid "database_is_uptodate" +msgstr "Databasen är uppdaterad." + +msgid "no_changes" +msgstr "Inga förändringar" diff --git a/components/ILIAS/Component/lang/cmps_tr.po b/components/ILIAS/Component/lang/cmps_tr.po new file mode 100644 index 000000000000..ce3b24a356b2 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_tr.po @@ -0,0 +1,254 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: tr\n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "Etkinleştir" + +msgid "cmps_active" +msgstr "Aktif" + +msgid "cmps_add_new_rank" +msgstr "Yeni Nesne Listesi Pozisyonu" + +msgid "cmps_available" +msgstr "Mevcut" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "Temel Dosyalar" + +msgid "cmps_class_file" +msgstr "Sınıf Dosyası" + +msgid "cmps_component" +msgstr "Bileşen" + +msgid "cmps_configure" +msgstr "Yapılandırma" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "Güncel Sürüm" + +msgid "cmps_database" +msgstr "Veritabanı" + +msgid "cmps_db_update" +msgstr "DB Güncelleme Senaryo" + +msgid "cmps_deactivate" +msgstr "Devre dışı bırak" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "Dizin" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "Güncelleme Dosya Sürümü" + +msgid "cmps_group" +msgstr "Grubu" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Maks. ILIAS Sürüm" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Sürüm" + +msgid "cmps_inactive" +msgstr "İnaktif" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "Lisan Dosyalar" + +msgid "cmps_lang_prefix" +msgstr "Dil Değişkenleri Önek" + +msgid "cmps_languages" +msgstr "Diller" + +msgid "cmps_main_dir" +msgstr "Ana Dizin" + +msgid "cmps_missing" +msgstr "Eksik" + +msgid "cmps_module" +msgstr "Modülü" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "Adı" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "Bu eklenti sürümü sadece yeni ILIAS sürümlerinde çalışır. ILIAS yükseltin." + +msgid "cmps_needs_newer_plugin_version" +msgstr "Bu ILIAS sürümü daha yeni bir eklenti sürümü gerekiyor. Eklenti yükseltin." + +msgid "cmps_needs_update" +msgstr "İhtiyaçları güncelleme." + +msgid "cmps_needs_upgrade" +msgstr "Yüklü eklenti sürümü önceden yüklenmiş sürümü daha eski. Eklenti yükseltin." + +msgid "cmps_no_db_update_file_available" +msgstr "Mevcut Resim DB güncelleştirme komut dosyası." + +msgid "cmps_no_language_file_available" +msgstr "Mevcut dil dosyası yok." + +msgid "cmps_plugin" +msgstr "Eklentisi" + +msgid "cmps_plugin_activated" +msgstr "eklentisi devreye alınmıştır." + +msgid "cmps_plugin_db_prefixes" +msgstr "DB tablo Önekler" + +msgid "cmps_plugin_deactivated" +msgstr "eklentisi devrede değil." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "Plugin Dosyası" + +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Dil Önekler" + +msgid "cmps_plugin_slot" +msgstr "Plugin Yuvası" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "Eklentiler" + +msgid "cmps_refresh" +msgstr "Yenile" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "Depo Nesne Türü" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Sorumlu" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "Seçenekleri Kaydet" + +msgid "cmps_service" +msgstr "Hizmet" + +msgid "cmps_show_details" +msgstr "Detayları göster" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "Durumu" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "Güncelleme" + +msgid "cmps_version" +msgstr "Sürüm" + +msgid "database_is_uptodate" +msgstr "veritabanı güncel olduğunu." + +msgid "no_changes" +msgstr "Hayır değişiklikler" diff --git a/components/ILIAS/Component/lang/cmps_uk.po b/components/ILIAS/Component/lang/cmps_uk.po new file mode 100644 index 000000000000..19e0610fdf16 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_uk.po @@ -0,0 +1,305 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: uk\n" +"Language-Team: Ukrainian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: cmps\n" + +#, fuzzy +msgid "cmps_activate" +msgstr "Activate" + +#, fuzzy +msgid "cmps_active" +msgstr "Active" + +#, fuzzy +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +#, fuzzy +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +#, fuzzy +msgid "cmps_basic_files" +msgstr "Basic Files" + +#, fuzzy +msgid "cmps_class_file" +msgstr "Class File" + +#, fuzzy +msgid "cmps_component" +msgstr "Component" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +#, fuzzy +msgid "cmps_current_version" +msgstr "Current Version" + +#, fuzzy +msgid "cmps_database" +msgstr "Database" + +#, fuzzy +msgid "cmps_db_update" +msgstr "DB Update Script" + +#, fuzzy +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +#, fuzzy +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +#, fuzzy +msgid "cmps_file_version" +msgstr "Update File Version" + +#, fuzzy +msgid "cmps_group" +msgstr "Group" + +#, fuzzy +msgid "cmps_id" +msgstr "ID" + +#, fuzzy +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +#, fuzzy +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +#, fuzzy +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +#, fuzzy +msgid "cmps_lang_files" +msgstr "Language Files" + +#, fuzzy +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +#, fuzzy +msgid "cmps_languages" +msgstr "Languages" + +#, fuzzy +msgid "cmps_main_dir" +msgstr "Main Directory" + +#, fuzzy +msgid "cmps_missing" +msgstr "Missing" + +#, fuzzy +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +#, fuzzy +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +#, fuzzy +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +#, fuzzy +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_needs_update" +msgstr "Needs update." + +#, fuzzy +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +#, fuzzy +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +#, fuzzy +msgid "cmps_plugin" +msgstr "Plugin" + +#, fuzzy +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +#, fuzzy +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +#, fuzzy +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +#, fuzzy +msgid "cmps_plugin_file" +msgstr "Plugin File" + +#, fuzzy +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +#, fuzzy +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +#, fuzzy +msgid "cmps_plugins" +msgstr "Plugins" + +#, fuzzy +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +#, fuzzy +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +#, fuzzy +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +#, fuzzy +msgid "cmps_save_options" +msgstr "Save Options" + +#, fuzzy +msgid "cmps_service" +msgstr "Service" + +#, fuzzy +msgid "cmps_show_details" +msgstr "Show Details" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +#, fuzzy +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +#, fuzzy +msgid "cmps_update" +msgstr "Update" + +#, fuzzy +msgid "cmps_version" +msgstr "Version" + +#, fuzzy +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +#, fuzzy +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_vi.po b/components/ILIAS/Component/lang/cmps_vi.po new file mode 100644 index 000000000000..fa8d4e1eacf2 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_vi.po @@ -0,0 +1,305 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: vi\n" +"Language-Team: Vietnamese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: cmps\n" + +#, fuzzy +msgid "cmps_activate" +msgstr "Activate" + +#, fuzzy +msgid "cmps_active" +msgstr "Active" + +#, fuzzy +msgid "cmps_add_new_rank" +msgstr "Position in Add-New-Object List" + +#, fuzzy +msgid "cmps_available" +msgstr "Available" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +#, fuzzy +msgid "cmps_basic_files" +msgstr "Basic Files" + +#, fuzzy +msgid "cmps_class_file" +msgstr "Class File" + +#, fuzzy +msgid "cmps_component" +msgstr "Component" + +#, fuzzy +msgid "cmps_configure" +msgstr "Configure" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +#, fuzzy +msgid "cmps_current_version" +msgstr "Current Version" + +#, fuzzy +msgid "cmps_database" +msgstr "Database" + +#, fuzzy +msgid "cmps_db_update" +msgstr "DB Update Script" + +#, fuzzy +msgid "cmps_deactivate" +msgstr "Deactivate" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +#, fuzzy +msgid "cmps_dir" +msgstr "Directory" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +#, fuzzy +msgid "cmps_file_version" +msgstr "Update File Version" + +#, fuzzy +msgid "cmps_group" +msgstr "Group" + +#, fuzzy +msgid "cmps_id" +msgstr "ID" + +#, fuzzy +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS Version" + +#, fuzzy +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS Version" + +#, fuzzy +msgid "cmps_inactive" +msgstr "Inactive" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +#, fuzzy +msgid "cmps_lang_files" +msgstr "Language Files" + +#, fuzzy +msgid "cmps_lang_prefix" +msgstr "Language Variables Prefix" + +#, fuzzy +msgid "cmps_languages" +msgstr "Languages" + +#, fuzzy +msgid "cmps_main_dir" +msgstr "Main Directory" + +#, fuzzy +msgid "cmps_missing" +msgstr "Missing" + +#, fuzzy +msgid "cmps_module" +msgstr "Module" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +#, fuzzy +msgid "cmps_name" +msgstr "Name" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +#, fuzzy +msgid "cmps_needs_newer_ilias_version" +msgstr "This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS." + +#, fuzzy +msgid "cmps_needs_newer_plugin_version" +msgstr "This ILIAS version needs a newer plugin version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_needs_update" +msgstr "Needs update." + +#, fuzzy +msgid "cmps_needs_upgrade" +msgstr "The plugin version currently installed is older than a previously installed version. Please upgrade the plugin." + +#, fuzzy +msgid "cmps_no_db_update_file_available" +msgstr "No DB update script available." + +#, fuzzy +msgid "cmps_no_language_file_available" +msgstr "No language file available." + +#, fuzzy +msgid "cmps_plugin" +msgstr "Plugin" + +#, fuzzy +msgid "cmps_plugin_activated" +msgstr "The plugin has been activated." + +#, fuzzy +msgid "cmps_plugin_db_prefixes" +msgstr "DB Table Prefixes" + +#, fuzzy +msgid "cmps_plugin_deactivated" +msgstr "The plugin has been deactivated." + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +#, fuzzy +msgid "cmps_plugin_file" +msgstr "Plugin File" + +#, fuzzy +msgid "cmps_plugin_lang_prefixes" +msgstr "Plugin Language Prefixes" + +#, fuzzy +msgid "cmps_plugin_slot" +msgstr "Plugin Slot" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +#, fuzzy +msgid "cmps_plugins" +msgstr "Plugins" + +#, fuzzy +msgid "cmps_refresh" +msgstr "Refresh" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +#, fuzzy +msgid "cmps_rep_object" +msgstr "Repository Object Type" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +#, fuzzy +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +#, fuzzy +msgid "cmps_save_options" +msgstr "Save Options" + +#, fuzzy +msgid "cmps_service" +msgstr "Service" + +#, fuzzy +msgid "cmps_show_details" +msgstr "Show Details" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +#, fuzzy +msgid "cmps_status" +msgstr "Status" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +#, fuzzy +msgid "cmps_update" +msgstr "Update" + +#, fuzzy +msgid "cmps_version" +msgstr "Version" + +#, fuzzy +msgid "database_is_uptodate" +msgstr "Die Datenbank ist aktuell." + +#, fuzzy +msgid "no_changes" +msgstr "Keine Änderungen" diff --git a/components/ILIAS/Component/lang/cmps_zh.po b/components/ILIAS/Component/lang/cmps_zh.po new file mode 100644 index 000000000000..6858f22f1964 --- /dev/null +++ b/components/ILIAS/Component/lang/cmps_zh.po @@ -0,0 +1,254 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: zh\n" +"Language-Team: Chinese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: cmps\n" + +msgid "cmps_activate" +msgstr "激活" + +msgid "cmps_active" +msgstr "激活" + +msgid "cmps_add_new_rank" +msgstr "在“添加新对象”列表中的位置" + +msgid "cmps_available" +msgstr "有效的" + +#, fuzzy +msgid "cmps_available_version" +msgstr "Available Version" + +msgid "cmps_basic_files" +msgstr "基本文件" + +msgid "cmps_class_file" +msgstr "类文件" + +msgid "cmps_component" +msgstr "组件" + +msgid "cmps_configure" +msgstr "配置" + +#, fuzzy +msgid "cmps_current_db_version" +msgstr "Current DB-Version" + +msgid "cmps_current_version" +msgstr "当前版本" + +msgid "cmps_database" +msgstr "数据库" + +msgid "cmps_db_update" +msgstr "数据库更新脚本" + +msgid "cmps_deactivate" +msgstr "停用" + +#, fuzzy +msgid "cmps_detailed_information" +msgstr "Detailed Informationen" + +msgid "cmps_dir" +msgstr "目录" + +#, fuzzy +msgid "cmps_disable_creation" +msgstr "Disable Creation" + +#, fuzzy +msgid "cmps_enable_creation" +msgstr "Enable Creation" + +msgid "cmps_file_version" +msgstr "更新文件版本" + +msgid "cmps_group" +msgstr "用户组" + +msgid "cmps_id" +msgstr "ID" + +msgid "cmps_ilias_max_version" +msgstr "Max. ILIAS 版本" + +msgid "cmps_ilias_min_version" +msgstr "Min. ILIAS 版本" + +msgid "cmps_inactive" +msgstr "不活跃的" + +#, fuzzy +msgid "cmps_install" +msgstr "Install" + +#, fuzzy +msgid "cmps_is_active" +msgstr "Activated" + +#, fuzzy +msgid "cmps_is_installed" +msgstr "Installed" + +msgid "cmps_lang_files" +msgstr "语言文件" + +msgid "cmps_lang_prefix" +msgstr "语言变量前缀" + +msgid "cmps_languages" +msgstr "语言" + +msgid "cmps_main_dir" +msgstr "主目录" + +msgid "cmps_missing" +msgstr "缺失" + +msgid "cmps_module" +msgstr "模块" + +#, fuzzy +msgid "cmps_must_installed" +msgstr "The component must be installed." + +msgid "cmps_name" +msgstr "名字" + +#, fuzzy +msgid "cmps_needs_matching_ilias_version" +msgstr "This plugin does not work with your current ILIAS version." + +msgid "cmps_needs_newer_ilias_version" +msgstr "这个插件版本仅运行在更新的ILIAS版本上。请升级ILIAS。" + +msgid "cmps_needs_newer_plugin_version" +msgstr "这个ILIAS版本需要一个更新的插件版本。请升级插件。" + +msgid "cmps_needs_update" +msgstr "需要更新。" + +msgid "cmps_needs_upgrade" +msgstr "当前已安装的插件版本比先前安装的版本更老,请升级插件。" + +msgid "cmps_no_db_update_file_available" +msgstr "没有可用的数据库升级脚本。" + +msgid "cmps_no_language_file_available" +msgstr "没有可用的语言文件。" + +msgid "cmps_plugin" +msgstr "插件" + +msgid "cmps_plugin_activated" +msgstr "插件已激活" + +msgid "cmps_plugin_db_prefixes" +msgstr "数据库表前缀" + +msgid "cmps_plugin_deactivated" +msgstr "插件已停用" + +#, fuzzy +msgid "cmps_plugin_deinstalled" +msgstr "The plugin has been uninstalled" + +msgid "cmps_plugin_file" +msgstr "插件文件" + +msgid "cmps_plugin_lang_prefixes" +msgstr "插件语言前缀" + +msgid "cmps_plugin_slot" +msgstr "插件插槽(Slot)" + +#, fuzzy +msgid "cmps_plugin_uninstalled" +msgstr "The plugin has been uninstalled." + +#, fuzzy +msgid "cmps_plugin_updated" +msgstr "The plugin has been updated" + +msgid "cmps_plugins" +msgstr "插件" + +msgid "cmps_refresh" +msgstr "刷新" + +#, fuzzy +msgid "cmps_refresh_lng" +msgstr "Refresh language" + +msgid "cmps_rep_object" +msgstr "库对象类型" + +#, fuzzy +msgid "cmps_repository_object_types" +msgstr "Repository Object Types" + +msgid "cmps_responsible" +msgstr "Responsible" + +#, fuzzy +msgid "cmps_responsible_mail" +msgstr "Mail (Responsible)" + +msgid "cmps_save_options" +msgstr "保存选项" + +msgid "cmps_service" +msgstr "服务" + +msgid "cmps_show_details" +msgstr "细节展示" + +#, fuzzy +msgid "cmps_slots" +msgstr "Slots" + +msgid "cmps_status" +msgstr "状态" + +#, fuzzy +msgid "cmps_supports_cli_setup" +msgstr "Supports CLI-Setup" + +#, fuzzy +msgid "cmps_supports_export" +msgstr "Supports export" + +#, fuzzy +msgid "cmps_supports_learning_progress" +msgstr "Supports learning progress" + +#, fuzzy +msgid "cmps_uninstall" +msgstr "Uninstall" + +#, fuzzy +msgid "cmps_uninstall_confirm" +msgstr "Are you sure you want to uninstall the plugin \"%s\"?" + +#, fuzzy +msgid "cmps_uninstall_inactive_confirm" +msgstr "The plugin \"%1$s\" can currently not be activated (\"%2$s\"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?" + +msgid "cmps_update" +msgstr "更新" + +msgid "cmps_version" +msgstr "版本" + +msgid "database_is_uptodate" +msgstr "该数据库是当前数据库" + +msgid "no_changes" +msgstr "没有变化" diff --git a/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php b/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php index 1c82a206fa90..3b84f027b675 100755 --- a/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php +++ b/components/ILIAS/Component/tests/Setup/ilComponentBuildPluginInfoObjectiveTest.php @@ -139,7 +139,7 @@ public function testPluginsAdded(): void public function testScanDir(): void { // Use the component directory without artifacts, because this should be mostly stable. - $expected = ["Component.php", "PRIVACY.md", "README.md", "ROADMAP.md", "classes", "exceptions", "service.xml", "src", "tests", "docs"]; + $expected = ["Component.php", "PRIVACY.md", "README.md", "ROADMAP.md", "classes", "exceptions", "lang", "service.xml", "src", "tests", "docs"]; $actual = array_values( array_diff( $this->builder->_scanDir(__DIR__ . "/../.."), diff --git a/lang/ilias_ar.lang b/lang/ilias_ar.lang index d11e11a15acc..443d0e9f7993 100644 --- a/lang/ilias_ar.lang +++ b/lang/ilias_ar.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#تفعيل -cmps#:#cmps_active#:#فعال -cmps#:#cmps_add_new_rank#:#الوضعية في قائمة اضافة-عنصر-جديد -cmps#:#cmps_available#:#Available -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files -cmps#:#cmps_class_file#:#Class File -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Configure -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#DB Update Script -cmps#:#cmps_deactivate#:#Deactivate -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#الدليل -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Update File Version -cmps#:#cmps_group#:#مجموعة -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version -cmps#:#cmps_inactive#:#Inactive -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files -cmps#:#cmps_lang_prefix#:#بادئة متغيرات اللغة -cmps#:#cmps_languages#:#Languages -cmps#:#cmps_main_dir#:#Main Directory -cmps#:#cmps_missing#:#Missing -cmps#:#cmps_module#:#وحدة -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin. -cmps#:#cmps_needs_update#:#Needs update. -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin. -cmps#:#cmps_no_db_update_file_available#:#No DB update script available. -cmps#:#cmps_no_language_file_available#:#لا يوجد ملف لغة -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#The plugin has been activated. -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes -cmps#:#cmps_plugin_slot#:#قائمة الاضافات -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#الاضافات -cmps#:#cmps_refresh#:#Refresh -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#نوع عنصر المكتبة -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#حفظ الخيارات -cmps#:#cmps_service#:#خدمة -cmps#:#cmps_show_details#:#عرض التفاصيل -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#The database is up to date. -cmps#:#no_changes#:#No changes cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#اضافة عنصر جديد diff --git a/lang/ilias_bg.lang b/lang/ilias_bg.lang index 35745a876d14..bd56a92ec425 100644 --- a/lang/ilias_bg.lang +++ b/lang/ilias_bg.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#Ако ресурсът поддържа тази опц cmix#:#xapi_statements_fetched_successfully#:#Резултатите от Learning Record Store бяха извлечени успешно cmix#:#xapi_statements_last_fetch_date#:#Последното извличане на резултати: cmix#:#xapi_statements_not_fetched_yet#:#Резултатите от Learning Record Store все още не са извлечени -cmps#:#cmps_activate#:#Активиране -cmps#:#cmps_active#:#Активиран -cmps#:#cmps_add_new_rank#:#Позиция в списъка за добавяне на нови обекти -cmps#:#cmps_available#:#Available -cmps#:#cmps_available_version#:#Available Version###31 03 2023 new variable -cmps#:#cmps_basic_files#:#Основни файлове -cmps#:#cmps_class_file#:#Файл на класа -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Конфигуриране -cmps#:#cmps_current_db_version#:#Current DB-Version###31 03 2023 new variable -cmps#:#cmps_current_version#:# Текуща версия -cmps#:#cmps_database#:#База данни -cmps#:#cmps_db_update#:#Скрипт за обновяване на базата данни -cmps#:#cmps_deactivate#:#Деактивиране -cmps#:#cmps_detailed_information#:#Detailed Informationen###31 03 2023 new variable -cmps#:#cmps_dir#:#Директория -cmps#:#cmps_enable_creation#:#Активиране на създаването -cmps#:#cmps_file_version#:#Актуализиране на версията на файла -cmps#:#cmps_group#:#Група -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. Версия на ILIAS -cmps#:#cmps_ilias_min_version#:#Min. Версия на ILIAS -cmps#:#cmps_inactive#:#Inactive -cmps#:#cmps_install#:#Инсталиране -cmps#:#cmps_is_active#:#Activated###31 03 2023 new variable -cmps#:#cmps_is_installed#:#Installed###31 03 2023 new variable -cmps#:#cmps_lang_files#:#Езикови файлове -cmps#:#cmps_lang_prefix#:# Префикс на езиковите променливи -cmps#:#cmps_languages#:#Езици -cmps#:#cmps_main_dir#:#Основна директория -cmps#:#cmps_missing#:#Пропускане -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#Компонентът трябва да е инсталиран. -cmps#:#cmps_name#:#Име -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###31 03 2023 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Тази версия на плъгина работи само с по-нови версии на ILIAS. Моля, надградете ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Тази версия на ILIAS се нуждае от по-нова версия на плъгина. Моля, актуализирайте плъгина. -cmps#:#cmps_needs_update#:#Необходима е актуализация. -cmps#:#cmps_needs_upgrade#:#Инсталираната в момента версия на плъгина е по-стара от предишната. Моля, актуализирайте плъгина. -cmps#:#cmps_no_db_update_file_available#:#Няма наличен скрипт за актуализация на БД. -cmps#:#cmps_no_language_file_available#:#Няма наличен езиков файл. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#Плъгинът е активиран. -cmps#:#cmps_plugin_db_prefixes#:# Префикси на таблици за БД -cmps#:#cmps_plugin_deactivated#:#Плъгинът е деактивиран. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###31 03 2023 new variable -cmps#:#cmps_plugin_file#:#Файл на приставка -cmps#:#cmps_plugin_lang_prefixes#:#Префикси на езика на приставката -cmps#:#cmps_plugin_slot#:#Плъгин слот -cmps#:#cmps_plugin_uninstalled#:#Плъгинът е деинсталиран. -cmps#:#cmps_plugin_updated#:#The plugin has been updated###31 03 2023 new variable -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Refresh -cmps#:#cmps_refresh_lng#:#Refresh language###31 03 2023 new variable -cmps#:#cmps_rep_object#:# Тип на обекта на хранилището -cmps#:#cmps_repository_object_types#:#Типове обекти на хранилището -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###31 03 2023 new variable -cmps#:#cmps_save_options#:#Запазване на опции -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Покажи подробности -cmps#:#cmps_slots#:#Места -cmps#:#cmps_status#:#Статус -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###31 03 2023 new variable -cmps#:#cmps_supports_export#:#Supports export###31 03 2023 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###31 03 2023 new variable -cmps#:#cmps_uninstall#:#Деинсталиране -cmps#:#cmps_uninstall_confirm#:#Със сигурност ли искате да деинсталирате приставката "%s"? -cmps#:#cmps_uninstall_inactive_confirm#:#Плъгинът "%1$s" понастоящем не може да бъде активиран ("%2$s"). В това състояние плъгините не могат да бъдат деинсталирани напълно. Тъй като се възстановява само интеграцията на ядрото, потребителските данни на плъгина могат да останат. Искате ли да продължите с частичното деинсталиране? -cmps#:#cmps_update#:#Актуализация -cmps#:#cmps_version#:#Версия -cmps#:#database_is_uptodate#:#Базата данни е актуална -cmps#:#no_changes#:#Няма промени cmxv#:#cmxv_create#:#Създаване на сертификат за обект xAPI/cmi5 cmxv#:#cmxv_create_info#:#Изберете завършен обект xAPI/cmi5, за да генерирате сертификат за него cntr#:#cntr_add_new_item#:#Добавяне на нов артикул diff --git a/lang/ilias_cs.lang b/lang/ilias_cs.lang index c3eb7e4489b7..a71b63e2e272 100644 --- a/lang/ilias_cs.lang +++ b/lang/ilias_cs.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Aktivovat -cmps#:#cmps_active#:#Aktivní -cmps#:#cmps_add_new_rank#:#Pozice v seznamu "Přidat nový objekt" -cmps#:#cmps_available#:#Dostupné -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Základní soubory -cmps#:#cmps_class_file#:#Soubor třídy -cmps#:#cmps_component#:#Komponenta -cmps#:#cmps_configure#:#Konfigurovat -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Aktuální verze -cmps#:#cmps_database#:#Databáze -cmps#:#cmps_db_update#:#Skript aktualizace DB -cmps#:#cmps_deactivate#:#Deaktivovat -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Adresář -cmps#:#cmps_enable_creation#:#Zapnout tvorbu -cmps#:#cmps_file_version#:#Verze akualizačního souboru -cmps#:#cmps_group#:#Skupina -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. verze ILIAS -cmps#:#cmps_ilias_min_version#:#Min. verze ILIAS -cmps#:#cmps_inactive#:#Neaktivní -cmps#:#cmps_install#:#Install -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Soubory jazyků -cmps#:#cmps_lang_prefix#:#Označení jazykových proměnných -cmps#:#cmps_languages#:#Jazyky -cmps#:#cmps_main_dir#:#Hlavní adresář -cmps#:#cmps_missing#:#Chybí -cmps#:#cmps_module#:#Modul -cmps#:#cmps_must_installed#:#Komponenta musí být instalována. -cmps#:#cmps_name#:#Název -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Tato verze pluginu běží jen na novější verzi ILIAS. Aktualizujte prosím ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Tato verze ILIAS vyžaduje novější verzi zásuvného souboru. Aktualizujte prosím plugin. -cmps#:#cmps_needs_update#:#Vyžaduje aktualizaci. -cmps#:#cmps_needs_upgrade#:#Verze pluginu, kterou instalujete, je starší než nainstalovaná verze. Aktualizujte prosím plugin. -cmps#:#cmps_no_db_update_file_available#:#Není dostupný žádný skript aktualizace DB. -cmps#:#cmps_no_language_file_available#:#Žádný jazykový soubor není dostupný. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#Plugin byl aktivován. -cmps#:#cmps_plugin_db_prefixes#:#Označení tabulek DB -cmps#:#cmps_plugin_deactivated#:#Plugin byl deaktivován. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Soubor pluginu -cmps#:#cmps_plugin_lang_prefixes#:#Označení jazykových pluginů -cmps#:#cmps_plugin_slot#:#Slot pluginu -cmps#:#cmps_plugin_uninstalled#:#Plugin byl odinstalován. -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Pluginy -cmps#:#cmps_refresh#:#Obnovení -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Typ objektu úložiště -cmps#:#cmps_repository_object_types#:#Typy objektů úložiště -cmps#:#cmps_responsible#:#Zodpovědný -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Uložit nastavení -cmps#:#cmps_service#:#Služba -cmps#:#cmps_show_details#:#Zobrazit detaily -cmps#:#cmps_slots#:#Sloty -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Odinstalovat -cmps#:#cmps_uninstall_confirm#:#Opravdu chcete odinstalovat plugin „%s“? -cmps#:#cmps_uninstall_inactive_confirm#:#Plugin „%1$s“ nelze v současné době aktivovat („%2$s“). V tomto stavu nelze pluginy zcela odinstalovat. Vzhledem k tomu, že je vrácena pouze integrace jádra, mohou zůstat zachována vlastní data pluginu. Chcete pokračovat v částečném odinstalování? -cmps#:#cmps_update#:#Akualizovat -cmps#:#cmps_version#:#Verze -cmps#:#database_is_uptodate#:#Tato databáze je aktuální. -cmps#:#no_changes#:#Žádné změny cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Přidat novou položku diff --git a/lang/ilias_da.lang b/lang/ilias_da.lang index 185d6b3fc93d..c111541c1cad 100644 --- a/lang/ilias_da.lang +++ b/lang/ilias_da.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Aktiver -cmps#:#cmps_active#:#Aktiv -cmps#:#cmps_add_new_rank#:#Position i opret rullemenu -cmps#:#cmps_available#:#Tilgængelig -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basisfiler -cmps#:#cmps_class_file#:#Klassefiler -cmps#:#cmps_component#:#Komponent -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Nuværende version -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#Database opdaterings script -cmps#:#cmps_deactivate#:#Deaktiver -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Mappe -cmps#:#cmps_enable_creation#:#Enable Creation###16 09 2013 new variable -cmps#:#cmps_file_version#:#Opdater filversion -cmps#:#cmps_group#:#Gruppe -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maks ILIAS version -cmps#:#cmps_ilias_min_version#:#Min ILIAS version -cmps#:#cmps_inactive#:#Inaktiv -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Sprogfiler -cmps#:#cmps_lang_prefix#:#Prefix for sprogvariable -cmps#:#cmps_languages#:#Sprog -cmps#:#cmps_main_dir#:#Rod folder -cmps#:#cmps_missing#:#Mangler -cmps#:#cmps_module#:#Modul -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Navn -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Denne version af pluginet kører kun på en nyerere version af ILIAS. Du skal opgadere ILIAS før du kan anvende dette. -cmps#:#cmps_needs_newer_plugin_version#:#Denne ILIAS version kræver en nyerere plugin. Opgrader venligst pluginet. -cmps#:#cmps_needs_update#:#Kræver opdatering -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_no_db_update_file_available#:#Intet database script tilgængeligt -cmps#:#cmps_no_language_file_available#:#Ingen sprogfil tilgængelig -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#Plugin blev aktiveret. -cmps#:#cmps_plugin_db_prefixes#:#Database tabel prefix -cmps#:#cmps_plugin_deactivated#:#Plugin er deaktiveret. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin fil -cmps#:#cmps_plugin_lang_prefixes#:#Plugin sprog prefixer -cmps#:#cmps_plugin_slot#:#Plugin plads -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Opfrisk -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Oversigts objekttype -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Ansvarlig -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Gem indstillinger -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Vis detaljer -cmps#:#cmps_slots#:#Slots###16 09 2013 new variable -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Opdater -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#Databasen er opdateret -cmps#:#no_changes#:#Ingen ændringer cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Tilføj indhold diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index f324968d1849..96075803f20e 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -3371,80 +3371,6 @@ cmix#:#use_fetch_info#:#Solange die Ressource diese Option unterstützt, sollten cmix#:#xapi_statements_fetched_successfully#:#Ergebnisse aus dem Learning Record Store wurden erfolgreich abgerufen. cmix#:#xapi_statements_last_fetch_date#:#Letzter Abruf von Ergebnissen: cmix#:#xapi_statements_not_fetched_yet#:#Ergebnisse aus dem Learning Record Store wurden noch nicht abgerufen. -cmps#:#cmps_activate#:#Aktivieren -cmps#:#cmps_active#:#Aktiv -cmps#:#cmps_add_new_rank#:#Position in der Auswahlliste für hinzuzufügende Objekte -cmps#:#cmps_available#:#Verfügbar -cmps#:#cmps_available_version#:#Verfügbare Version -cmps#:#cmps_basic_files#:#Basis-Dateien -cmps#:#cmps_class_file#:#Klassen-Datei -cmps#:#cmps_component#:#Komponente -cmps#:#cmps_configure#:#Konfigurieren -cmps#:#cmps_current_db_version#:#Aktuelle DB-Version -cmps#:#cmps_current_version#:#Aktuelle Version -cmps#:#cmps_database#:#Datenbank -cmps#:#cmps_db_update#:#Skript für Datenbankaktualisierung -cmps#:#cmps_deactivate#:#Deaktivieren -cmps#:#cmps_detailed_information#:#Detaillierte Informationen -cmps#:#cmps_dir#:#Verzeichnis -cmps#:#cmps_disable_creation#:#Erstellung ausschalten -cmps#:#cmps_enable_creation#:#Erstellung einschalten -cmps#:#cmps_file_version#:#Dateiversion aktualisieren -cmps#:#cmps_group#:#Gruppe -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maximale ILIAS-Version -cmps#:#cmps_ilias_min_version#:#Minimale ILIAS-Version -cmps#:#cmps_inactive#:#Inaktiv -cmps#:#cmps_install#:#Installieren -cmps#:#cmps_is_active#:#Aktiviert -cmps#:#cmps_is_installed#:#Installiert -cmps#:#cmps_lang_files#:#Sprachdatei -cmps#:#cmps_lang_prefix#:#Präfix Sprachvariable -cmps#:#cmps_languages#:#Sprachen -cmps#:#cmps_main_dir#:#Hauptverzeichnis -cmps#:#cmps_missing#:#Fehlt -cmps#:#cmps_module#:#Modul -cmps#:#cmps_must_installed#:#Die Komponente muss installiert werden. -cmps#:#cmps_name#:#Name -cmps#:#cmps_needs_matching_ilias_version#:#Dieses Plugin kann nicht mit der aktuellen Version dieser ILIAS-Installation verwendet werden. -cmps#:#cmps_needs_newer_ilias_version#:#Dieses Plugin kann nur mit einer neueren ILIAS-Version verwendet werden. Bitte aktualisieren Sie Ihre ILIAS-Version. -cmps#:#cmps_needs_newer_plugin_version#:#Diese ILIAS-Version benötigt eine neuere Plugin-Version. Bitte aktualisieren Sie das Plugin. -cmps#:#cmps_needs_update#:#Aktualisierung notwendig -cmps#:#cmps_needs_upgrade#:#Die aktuell installierte Plugin-Version ist älter als die zuvor installierte. Bitte installieren Sie das aktuelle Plugin. -cmps#:#cmps_no_db_update_file_available#:#Kein Datenbank-Update-Skript verfügbar -cmps#:#cmps_no_language_file_available#:#Keine Sprachdatei verfügbar -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#Das Plugin wurde aktiviert. -cmps#:#cmps_plugin_db_prefixes#:#Präfixe Datenbanktabelle -cmps#:#cmps_plugin_deactivated#:#Das Plugin wurde deaktiviert. -cmps#:#cmps_plugin_deinstalled#:#Das Plugin wurde deinstalliert. -cmps#:#cmps_plugin_file#:#Plugin-Datei -cmps#:#cmps_plugin_lang_prefixes#:#Plugin-Sprachpräfixe -cmps#:#cmps_plugin_slot#:#Plugin-Slot -cmps#:#cmps_plugin_uninstalled#:#Alle Sprach- und Datenbankeinträge, die sich auf das Plugin beziehen, wurden gelöscht und das Plugin deaktiviert. Sie können jetzt das Verzeichnis und den Quellcode des Plugins aus dem ‘Customizing’-Verzeichnis entfernen, um das Plugin endgültig zu deinstallieren. -cmps#:#cmps_plugin_updated#:#Das Plugin wurde aktualisiert. -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Sprachen neu laden -cmps#:#cmps_refresh_lng#:#Sprache aktualisiert -cmps#:#cmps_rep_object#:#Objekttyp im Magazin -cmps#:#cmps_repository_object_types#:#Objekttypen im Magazin -cmps#:#cmps_responsible#:#Verantwortlich -cmps#:#cmps_responsible_mail#:#Mail (Verantwortlicher) -cmps#:#cmps_save_options#:#Speichern -cmps#:#cmps_service#:#Dienst -cmps#:#cmps_show_details#:#Details anzeigen -cmps#:#cmps_slots#:#Slots -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Unterstützt CLI-Setup -cmps#:#cmps_supports_export#:#Unterstützt Export -cmps#:#cmps_supports_learning_progress#:#Unterstützt Lernfortschritt -cmps#:#cmps_uninstall#:#Deinstallieren -cmps#:#cmps_uninstall_confirm#:#Wollen Sie wirklich das Plugin „%s“ deinstallieren und alle zugehörigen Datenbankeinträge löschen? -cmps#:#cmps_uninstall_inactive_confirm#:#Das Plugin "%1$s" ist aktuell nicht aktivierbar ("%2$s"). In diesem Status können Plugins nicht komplett deinstalliert werden. Da nur die Systemintegration entfernt wird, könnten Plugin-eigene Daten zurückbleiben. Möchten Sie mit der teilweisen Deinstallation fortfahren? -cmps#:#cmps_update#:#Aktualisieren -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell. -cmps#:#no_changes#:#Keine Änderungen cmxv#:#cmxv_create#:#Zertifikat für xAPI/cmi5-Objekt erstellen cmxv#:#cmxv_create_info#:#Wählen Sie ein abgeschlossenes xAPI/cmi5-Objekt aus, um ein Zertifikat dafür zu generieren. cntr#:#cntr_add_new_item#:#Neues Objekt hinzufügen diff --git a/lang/ilias_el.lang b/lang/ilias_el.lang index 79b9ea7ebc56..4b2e8a7e813f 100644 --- a/lang/ilias_el.lang +++ b/lang/ilias_el.lang @@ -3315,79 +3315,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate -cmps#:#cmps_active#:#Active -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List -cmps#:#cmps_available#:#Available -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files -cmps#:#cmps_class_file#:#Class File -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#DB Update Script -cmps#:#cmps_deactivate#:#Deactivate -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Directory -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Update File Version -cmps#:#cmps_group#:#Group -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version -cmps#:#cmps_inactive#:#Inactive -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files -cmps#:#cmps_lang_prefix#:#Language Variables Prefix -cmps#:#cmps_languages#:#Languages -cmps#:#cmps_main_dir#:#Main Directory -cmps#:#cmps_missing#:#Missing -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin. -cmps#:#cmps_needs_update#:#Needs update. -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin. -cmps#:#cmps_no_db_update_file_available#:#No DB update script available. -cmps#:#cmps_no_language_file_available#:#No language file available. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#The plugin has been activated.###22 05 2009 new variable -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated.###22 05 2009 new variable -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes -cmps#:#cmps_plugin_slot#:#Plugin Slot -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Refresh -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Show Details -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell.###18 08 2009 new variable -cmps#:#no_changes#:#Keine Änderungen###18 08 2009 new variable cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Add New Item###30 04 2009 new variable diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 68e598c6d253..9a043ef79b3e 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -3372,80 +3372,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results: cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet -cmps#:#cmps_activate#:#Activate -cmps#:#cmps_active#:#Active -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List -cmps#:#cmps_available#:#Available -cmps#:#cmps_available_version#:#Available Version -cmps#:#cmps_basic_files#:#Basic Files -cmps#:#cmps_class_file#:#Class File -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Configure -cmps#:#cmps_current_db_version#:#Current DB-Version -cmps#:#cmps_current_version#:#Current Version -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#DB Update Script -cmps#:#cmps_deactivate#:#Deactivate -cmps#:#cmps_detailed_information#:#Detailed Informationen -cmps#:#cmps_dir#:#Directory -cmps#:#cmps_disable_creation#:#Disable Creation -cmps#:#cmps_enable_creation#:#Enable Creation -cmps#:#cmps_file_version#:#Update File Version -cmps#:#cmps_group#:#Group -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version -cmps#:#cmps_inactive#:#Inactive -cmps#:#cmps_install#:#Install -cmps#:#cmps_is_active#:#Activated -cmps#:#cmps_is_installed#:#Installed -cmps#:#cmps_lang_files#:#Language Files -cmps#:#cmps_lang_prefix#:#Language Variables Prefix -cmps#:#cmps_languages#:#Languages -cmps#:#cmps_main_dir#:#Main Directory -cmps#:#cmps_missing#:#Missing -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#The component must be installed. -cmps#:#cmps_name#:#Name -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version. -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin. -cmps#:#cmps_needs_update#:#Needs update. -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin. -cmps#:#cmps_no_db_update_file_available#:#No DB update script available. -cmps#:#cmps_no_language_file_available#:#No language file available. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#The plugin has been activated. -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled -cmps#:#cmps_plugin_file#:#Plugin File -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes -cmps#:#cmps_plugin_slot#:#Plugin Slot -cmps#:#cmps_plugin_uninstalled#:#All language and database related entries have been deleted and the plugin has been set inactive. Now you can remove the plugin's directory and source code in the ‘Customizing’ directory to uninstall the plugin definitely. -cmps#:#cmps_plugin_updated#:#The plugin has been updated -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Refresh Languages -cmps#:#cmps_refresh_lng#:#Refresh language -cmps#:#cmps_rep_object#:#Repository Object Type -cmps#:#cmps_repository_object_types#:#Repository Object Types -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible) -cmps#:#cmps_save_options#:#Save -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Show Details -cmps#:#cmps_slots#:#Slots -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup -cmps#:#cmps_supports_export#:#Supports export -cmps#:#cmps_supports_learning_progress#:#Supports learning progress -cmps#:#cmps_uninstall#:#Uninstall -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s" and remove all related database entries? -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall? -cmps#:#cmps_update#:#Update -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#The database is up to date. -cmps#:#no_changes#:#No changes cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it cntr#:#cntr_add_new_item#:#Add New Object diff --git a/lang/ilias_es.lang b/lang/ilias_es.lang index 3dcc6365b36b..279ef2f81ed0 100644 --- a/lang/ilias_es.lang +++ b/lang/ilias_es.lang @@ -3324,79 +3324,6 @@ cmix#:#use_fetch_info#:#Siempre que el recurso admita esta opción, debería uti cmix#:#xapi_statements_fetched_successfully#:#Los resultados del Learning Record Store se han obtenido correctamente cmix#:#xapi_statements_last_fetch_date#:#Última obtención de resultados: cmix#:#xapi_statements_not_fetched_yet#:#Aún no se han obtenido los resultados del Learning Record Store -cmps#:#cmps_activate#:#Activar -cmps#:#cmps_active#:#Activo -cmps#:#cmps_add_new_rank#:#Posición en la lista de Añadir-Nuevo-Objeto -cmps#:#cmps_available#:#Disponible -cmps#:#cmps_available_version#:#Versión disponible -cmps#:#cmps_basic_files#:#Archivos básicos -cmps#:#cmps_class_file#:#Archivo de clase -cmps#:#cmps_component#:#Componente -cmps#:#cmps_configure#:#Configurar -cmps#:#cmps_current_db_version#:#Versión actual de BD -cmps#:#cmps_current_version#:#Versión actual -cmps#:#cmps_database#:#Base de datos -cmps#:#cmps_db_update#:#Script de actualización de BD -cmps#:#cmps_deactivate#:#Desactivar -cmps#:#cmps_detailed_information#:#Información detallada -cmps#:#cmps_dir#:#Directorio -cmps#:#cmps_enable_creation#:#Habilitar creación -cmps#:#cmps_file_version#:#Actualizar versión de archivo -cmps#:#cmps_group#:#Grupo -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Versión máx. de ILIAS -cmps#:#cmps_ilias_min_version#:#Versión mín. de ILIAS -cmps#:#cmps_inactive#:#Inactivo -cmps#:#cmps_install#:#Instalar -cmps#:#cmps_is_active#:#Activado -cmps#:#cmps_is_installed#:#Instalado -cmps#:#cmps_lang_files#:#Archivos de idioma -cmps#:#cmps_lang_prefix#:#Prefijo de variables de idioma -cmps#:#cmps_languages#:#Idiomas -cmps#:#cmps_main_dir#:#Directorio principal -cmps#:#cmps_missing#:#Faltante -cmps#:#cmps_module#:#Módulo -cmps#:#cmps_must_installed#:#El componente debe estar instalado. -cmps#:#cmps_name#:#Nombre -cmps#:#cmps_needs_matching_ilias_version#:#Este complemento no funciona con su versión actual de ILIAS. -cmps#:#cmps_needs_newer_ilias_version#:#Esta versión del complemento solo funciona en versiones más recientes de ILIAS. Por favor, actualice ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Esta versión de ILIAS necesita una versión más reciente del complemento. Por favor, actualice el complemento. -cmps#:#cmps_needs_update#:#Necesita actualización. -cmps#:#cmps_needs_upgrade#:#La versión del complemento instalada actualmente es más antigua que una versión instalada previamente. Por favor, actualice el complemento. -cmps#:#cmps_no_db_update_file_available#:#No hay ningún script de actualización de base de datos disponible. -cmps#:#cmps_no_language_file_available#:#No hay ningún archivo de idioma disponible. -cmps#:#cmps_plugin#:#Complemento (Plugin) -cmps#:#cmps_plugin_activated#:#El complemento ha sido activado. -cmps#:#cmps_plugin_db_prefixes#:#Prefijos de tablas de BD -cmps#:#cmps_plugin_deactivated#:#El complemento ha sido desactivado. -cmps#:#cmps_plugin_deinstalled#:#El complemento ha sido desinstalado -cmps#:#cmps_plugin_file#:#Archivo del complemento -cmps#:#cmps_plugin_lang_prefixes#:#Prefijos de idioma del complemento -cmps#:#cmps_plugin_slot#:#Ubicación (Slot) del complemento -cmps#:#cmps_plugin_uninstalled#:#Todas las entradas relacionadas con el idioma y la base de datos han sido eliminadas y el complemento se ha configurado como inactivo. Ahora puede eliminar el directorio y el código fuente del complemento en el directorio 'Customizing' para desinstalar el complemento definitivamente. -cmps#:#cmps_plugin_updated#:#El complemento ha sido actualizado -cmps#:#cmps_plugins#:#Complementos (Plugins) -cmps#:#cmps_refresh#:#Actualizar idiomas -cmps#:#cmps_refresh_lng#:#Actualizar idioma -cmps#:#cmps_rep_object#:#Tipo de objeto de repositorio -cmps#:#cmps_repository_object_types#:#Tipos de objetos de repositorio -cmps#:#cmps_responsible#:#Responsable -cmps#:#cmps_responsible_mail#:#Correo electrónico (Responsable) -cmps#:#cmps_save_options#:#Guardar -cmps#:#cmps_service#:#Servicio -cmps#:#cmps_show_details#:#Mostrar detalles -cmps#:#cmps_slots#:#Ubicaciones (Slots) -cmps#:#cmps_status#:#Estado -cmps#:#cmps_supports_cli_setup#:#Soporta configuración por CLI -cmps#:#cmps_supports_export#:#Soporta exportación -cmps#:#cmps_supports_learning_progress#:#Soporta progreso de aprendizaje -cmps#:#cmps_uninstall#:#Desinstalar -cmps#:#cmps_uninstall_confirm#:#¿Está seguro de que desea desinstalar el complemento "%s" y eliminar todas las entradas de la base de datos relacionadas? -cmps#:#cmps_uninstall_inactive_confirm#:#El complemento "%1$s" actualmente no puede activarse ("%2$s"). En este estado, los complementos no pueden desinstalarse completamente. Dado que solo se revierte la integración del núcleo, los datos personalizados del complemento pueden permanecer. ¿Desea continuar con la desinstalación parcial? -cmps#:#cmps_update#:#Actualizar -cmps#:#cmps_version#:#Versión -cmps#:#database_is_uptodate#:#La base de datos está actualizada. -cmps#:#no_changes#:#Sin cambios cmxv#:#cmxv_create#:#Crear certificado para objeto xAPI/cmi5 cmxv#:#cmxv_create_info#:#Seleccione un objeto xAPI/cmi5 finalizado para generar un certificado para el mismo cntr#:#cntr_add_new_item#:#Añadir nuevo objeto diff --git a/lang/ilias_et.lang b/lang/ilias_et.lang index 0b65ca2efee8..c6460b5e7169 100644 --- a/lang/ilias_et.lang +++ b/lang/ilias_et.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Aktiveeri -cmps#:#cmps_active#:#Aktiivne -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List -cmps#:#cmps_available#:#Kättesaadav -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Põhifailid -cmps#:#cmps_class_file#:#Class File -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Konfigureeri -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Hetkeversioon -cmps#:#cmps_database#:#Andmebaas -cmps#:#cmps_db_update#:#DB Update Script -cmps#:#cmps_deactivate#:#Deaktiveeri -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Kataloog -cmps#:#cmps_enable_creation#:#Enable Creation -cmps#:#cmps_file_version#:#Uuenda faili versiooni -cmps#:#cmps_group#:#Grupp -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version -cmps#:#cmps_inactive#:#Passiivne -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Keelefailid -cmps#:#cmps_lang_prefix#:#Language Variables Prefix -cmps#:#cmps_languages#:#Keeled -cmps#:#cmps_main_dir#:#Juurkataloog -cmps#:#cmps_missing#:#Puudub -cmps#:#cmps_module#:#Moodul -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Nimi -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Antud lisamoodul töötab ainult ILIASe uuema versiooniga. Uuenda ILIASe versiooni. -cmps#:#cmps_needs_newer_plugin_version#:#ILIASe antud versioon vajab lisamooduli uuemat versiooni. Uuenda lisamoodulit. -cmps#:#cmps_needs_update#:#Vajab uuendamist. -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin. -cmps#:#cmps_no_db_update_file_available#:#No DB update script available. -cmps#:#cmps_no_language_file_available#:#Keelefaili pole. -cmps#:#cmps_plugin#:#Lisamoodul -cmps#:#cmps_plugin_activated#:#Lisamoodul on aktiveeritud. -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes -cmps#:#cmps_plugin_deactivated#:#Lisamoodul on deaktiveeritud. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Lisamooduli fail -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes -cmps#:#cmps_plugin_slot#:#Plugin Slot -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Lisamoodulid -cmps#:#cmps_refresh#:#Värskenda -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Varamu objekti tüüp -cmps#:#cmps_repository_object_types#:#Repository Object Types -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Salvestamise valikud -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Näita detaile -cmps#:#cmps_slots#:#Slots -cmps#:#cmps_status#:#Staatus -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Uuenda -cmps#:#cmps_version#:#Versioon -cmps#:#database_is_uptodate#:#The database is up to date. -cmps#:#no_changes#:#Muudatused puuduvad cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Lisa uus üksus diff --git a/lang/ilias_fa.lang b/lang/ilias_fa.lang index b8678d242d2b..d8ac642c14d7 100644 --- a/lang/ilias_fa.lang +++ b/lang/ilias_fa.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate -cmps#:#cmps_active#:#Active -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List -cmps#:#cmps_available#:#Available -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files -cmps#:#cmps_class_file#:#Class File -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Configure -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#DB Update Script -cmps#:#cmps_deactivate#:#Deactivate -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Directory -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#بروز رسانی نسخه فایل -cmps#:#cmps_group#:#Group -cmps#:#cmps_id#:#شناسه -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version -cmps#:#cmps_inactive#:#Inactive -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files -cmps#:#cmps_lang_prefix#:#Language Variables Prefix -cmps#:#cmps_languages#:#زبانها -cmps#:#cmps_main_dir#:#Main Directory -cmps#:#cmps_missing#:#Missing -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin. -cmps#:#cmps_needs_update#:#Needs update. -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin. -cmps#:#cmps_no_db_update_file_available#:#No DB update script available. -cmps#:#cmps_no_language_file_available#:#No language file available. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#The plugin has been activated. -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes -cmps#:#cmps_plugin_slot#:#Plugin Slot -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Refresh -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Show Details -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#بروز رسانی -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#The database is up to date. -cmps#:#no_changes#:#No changes cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#اضافه کردن آیتم جدید diff --git a/lang/ilias_fr.lang b/lang/ilias_fr.lang index b19e4b1477fc..4f02412f16e5 100644 --- a/lang/ilias_fr.lang +++ b/lang/ilias_fr.lang @@ -3299,79 +3299,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results: cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet -cmps#:#cmps_activate#:#Activer -cmps#:#cmps_active#:#Actif -cmps#:#cmps_add_new_rank#:#Position dans la liste Ajouter-un-nouvel-objet -cmps#:#cmps_available#:#Disponible -cmps#:#cmps_available_version#:#Available Version###31 10 2023 new variable -cmps#:#cmps_basic_files#:#Fichiers basiques -cmps#:#cmps_class_file#:#Fichier de Classe -cmps#:#cmps_component#:#Composant -cmps#:#cmps_configure#:#Configurer -cmps#:#cmps_current_db_version#:#Current DB-Version###31 10 2023 new variable -cmps#:#cmps_current_version#:#Version actuelle -cmps#:#cmps_database#:#Base de données -cmps#:#cmps_db_update#:#Script de mise à jour de la BbD -cmps#:#cmps_deactivate#:#Désactiver -cmps#:#cmps_detailed_information#:#Detailed Informationen###31 10 2023 new variable -cmps#:#cmps_dir#:#Dossier -cmps#:#cmps_enable_creation#:#Autoriser Création -cmps#:#cmps_file_version#:#Mettre à jour la version du fichier -cmps#:#cmps_group#:#Groupe -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Version Max. ILIAS -cmps#:#cmps_ilias_min_version#:#Version Min. ILIAS -cmps#:#cmps_inactive#:#Inactif -cmps#:#cmps_install#:#Installer -cmps#:#cmps_is_active#:#Activated###31 10 2023 new variable -cmps#:#cmps_is_installed#:#Installed###31 10 2023 new variable -cmps#:#cmps_lang_files#:#Fichier de langues -cmps#:#cmps_lang_prefix#:#Préfixe des variables de langue -cmps#:#cmps_languages#:#Langues -cmps#:#cmps_main_dir#:#Dossier Principal -cmps#:#cmps_missing#:#Manquant -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#Le composant doit être installé. -cmps#:#cmps_name#:#Nom -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###31 10 2023 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Cette version de plugin fonctionne uniquement sur les nouvelles versions d'ILIAS. Veuillez mettre à jour ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Cette version d'ILIAS nécessite une nouvelle version de plugin. Veuillez mettre à jour le plugin. -cmps#:#cmps_needs_update#:#Nécessite un update. -cmps#:#cmps_needs_upgrade#:#La version de plugin actuellement installée est plus ancienne que la précédente installée. Veuillez mettre à jour le plugin. -cmps#:#cmps_no_db_update_file_available#:#Pas de script de mise à jour de la BdD disponible. -cmps#:#cmps_no_language_file_available#:#Pas de fichier de langue disponible. -cmps#:#cmps_plugin#:#Plug-in -cmps#:#cmps_plugin_activated#:#Le plugin a été activé. -cmps#:#cmps_plugin_db_prefixes#:#Préfixes de tables de BdD -cmps#:#cmps_plugin_deactivated#:#Le plugin a été désactivé. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###31 10 2023 new variable -cmps#:#cmps_plugin_file#:#Fichier Plugin -cmps#:#cmps_plugin_lang_prefixes#:#Préfixes de langage de plugin -cmps#:#cmps_plugin_slot#:#Slot du Plugin -cmps#:#cmps_plugin_uninstalled#:#Le plugin a été désinstallé. -cmps#:#cmps_plugin_updated#:#The plugin has been updated###31 10 2023 new variable -cmps#:#cmps_plugins#:#Plug-ins -cmps#:#cmps_refresh#:#Rafraà®chir -cmps#:#cmps_refresh_lng#:#Refresh language###31 10 2023 new variable -cmps#:#cmps_rep_object#:#Type d'Objet -cmps#:#cmps_repository_object_types#:#Types d'Objets -cmps#:#cmps_responsible#:#Responsable -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###31 10 2023 new variable -cmps#:#cmps_save_options#:#Enregistrer Options -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Afficher détails -cmps#:#cmps_slots#:#Emplacements -cmps#:#cmps_status#:#Statut -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###31 10 2023 new variable -cmps#:#cmps_supports_export#:#Supports export###31 10 2023 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###31 10 2023 new variable -cmps#:#cmps_uninstall#:#Désinstaller -cmps#:#cmps_uninstall_confirm#:#Etes-vous sûr de vouloir désinstaller le plugin "%s"? -cmps#:#cmps_uninstall_inactive_confirm#:#Le plug-in «%1$s» ne peut pas être activé pour le moment («%2$s»). Dans cet état, les plug-ins ne peuvent pas être installés complètement. Puisque seule l’intégration principale est inversée, les données personnalisées des plug-ins peuvent rester. Voulez-vous continuer la désinstallation partielle? -cmps#:#cmps_update#:#Mettre à jour -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#La base de donnée est à jour. -cmps#:#no_changes#:#Aucun changement cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it cntr#:#cntr_add_new_item#:#Ajouter un nouvel objet diff --git a/lang/ilias_hr.lang b/lang/ilias_hr.lang index f8c69b463b87..574d679b1d05 100644 --- a/lang/ilias_hr.lang +++ b/lang/ilias_hr.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###04 06 2021 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###04 06 2021 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###04 06 2021 new variable -cmps#:#cmps_activate#:#Aktiviraj -cmps#:#cmps_active#:#Aktivan -cmps#:#cmps_add_new_rank#:#Pozicija na popisu dodavanja novih objekata -cmps#:#cmps_available#:#Dostupno -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Osnovne datoteke -cmps#:#cmps_class_file#:#Datoteka razreda -cmps#:#cmps_component#:#Komponenta -cmps#:#cmps_configure#:#Konfiguriraj -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Trenutačna verzija -cmps#:#cmps_database#:#Baza podataka -cmps#:#cmps_db_update#:#DB Skripta za ažuriranje -cmps#:#cmps_deactivate#:#Deaktiviraj -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Direktorij -cmps#:#cmps_enable_creation#:#Omogući kreiranje -cmps#:#cmps_file_version#:#Ažuriranje verzije datoteke -cmps#:#cmps_group#:#Skupina -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maks. verzija ILIAS-a -cmps#:#cmps_ilias_min_version#:#Min. verzija ILIAS-a -cmps#:#cmps_inactive#:#Neaktivan -cmps#:#cmps_install#:#Instaliraj -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Jezične datoteke -cmps#:#cmps_lang_prefix#:#Prefiks jezičnih varijabli -cmps#:#cmps_languages#:#Jezici -cmps#:#cmps_main_dir#:#Glavni direktorij -cmps#:#cmps_missing#:#Nedostaje -cmps#:#cmps_module#:#Modul -cmps#:#cmps_must_installed#:#Komponenta se mora instalirati. -cmps#:#cmps_name#:#Ime -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Ova verzija dodatka radi samo na novijim verzijama ILIAS-a. Molimo nadogradite ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin. -cmps#:#cmps_needs_update#:#Potrebno je ažuriranje. -cmps#:#cmps_needs_upgrade#:#Trenutno instalirana verzija dodatka starija je od prethodno instalirane verzije. Molimo nadogradite dodatak. -cmps#:#cmps_no_db_update_file_available#:#Nije dostupna skripta za ažuriranje baze podataka -cmps#:#cmps_no_language_file_available#:#Nije dostupna jezična datoteka. -cmps#:#cmps_plugin#:#Dodatak -cmps#:#cmps_plugin_activated#:#Dodatak je aktiviran. -cmps#:#cmps_plugin_db_prefixes#:#Prefiksi tablice baze podataka -cmps#:#cmps_plugin_deactivated#:#Dodatak je deaktiviran. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Datoteka dodatka -cmps#:#cmps_plugin_lang_prefixes#:#Dodatak jezičnih prefiksa -cmps#:#cmps_plugin_slot#:#Slot dodatka -cmps#:#cmps_plugin_uninstalled#:#Svi unosi povezani s jezikom i bazom podataka izbrisani su, a dodatak je neaktivan. Sada možete ukloniti direktorij i izvorni kod dodatka u direktoriju ‘Prilagođavanje’ kako biste dodatak definitivno deinstalirali. -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Dodaci -cmps#:#cmps_refresh#:#Osvježi jezike -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Tip objekta u repozitoriju -cmps#:#cmps_repository_object_types#:#Tipovi objekata u repozitoriju -cmps#:#cmps_responsible#:#Odgovoran -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Spremi -cmps#:#cmps_service#:#Servis -cmps#:#cmps_show_details#:#Pokaži detalje -cmps#:#cmps_slots#:#Slotovi -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Deinstaliraj -cmps#:#cmps_uninstall_confirm#:#Jeste li sigurni da želite deinstalirati dodatak "%s" i ukloniti sve povezane unose u bazu podataka? -cmps#:#cmps_uninstall_inactive_confirm#:#Dodatak "%1$s" trenutačno se ne može aktivirati ("%2$s"). U ovom stanju dodaci se ne mogu potpuno deinstalirati. Kako se briše samo integracija u sustav, vlastite datoteke dodataka mogu ostati. Želite li nastaviti s djelomičnom deinstalacijom? -cmps#:#cmps_update#:#Ažuriraj -cmps#:#cmps_version#:#Verzija -cmps#:#database_is_uptodate#:#Baza podataka je ažurirana. -cmps#:#no_changes#:#Nema promjena cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###04 06 2021 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###04 06 2021 new variable cntr#:#cntr_add_new_item#:#Dodaj novu stavku diff --git a/lang/ilias_hu.lang b/lang/ilias_hu.lang index d2d8b6d37830..24fd900c50c8 100644 --- a/lang/ilias_hu.lang +++ b/lang/ilias_hu.lang @@ -3373,79 +3373,6 @@ cmix#:#use_fetch_info#:#Mindaddig, amíg az erőforrás támogatja ezt a beáll cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results: cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet -cmps#:#cmps_activate#:#Aktiválás -cmps#:#cmps_active#:#Aktív -cmps#:#cmps_add_new_rank#:#Pozíció az Új objektum létrehozása listában -cmps#:#cmps_available#:#Elérhető -cmps#:#cmps_available_version#:#Elérhető verzió -cmps#:#cmps_basic_files#:#Alapfájlok -cmps#:#cmps_class_file#:#Osztályfájl -cmps#:#cmps_component#:#Komponens -cmps#:#cmps_configure#:#Konfigurálás -cmps#:#cmps_current_db_version#:#Jelenlegi adatbázis-verzió -cmps#:#cmps_current_version#:#Jelenlegi verzió -cmps#:#cmps_database#:#Adatbázis -cmps#:#cmps_db_update#:#Adatbázis-frissítési szkript -cmps#:#cmps_deactivate#:#Deaktiválás -cmps#:#cmps_detailed_information#:#Részletes információ -cmps#:#cmps_dir#:#Mappa -cmps#:#cmps_enable_creation#:#Létrehozás engedélyezése -cmps#:#cmps_file_version#:#Frissítési fájlverzió -cmps#:#cmps_group#:#Csoport -cmps#:#cmps_id#:#Azonosító -cmps#:#cmps_ilias_max_version#:#Maximum ILIAS-verzió -cmps#:#cmps_ilias_min_version#:#Minimum ILIAS-verzió -cmps#:#cmps_inactive#:#Inaktív -cmps#:#cmps_install#:#Telepítés -cmps#:#cmps_is_active#:#Aktivált -cmps#:#cmps_is_installed#:#Telepített -cmps#:#cmps_lang_files#:#Nyelvi fájlok -cmps#:#cmps_lang_prefix#:#Nyelvi változó előtag -cmps#:#cmps_languages#:#Nyelvek -cmps#:#cmps_main_dir#:#Főmappa -cmps#:#cmps_missing#:#Hiányzik -cmps#:#cmps_module#:#Modul -cmps#:#cmps_must_installed#:#A komponenst telepíteni kell. -cmps#:#cmps_name#:#Név -cmps#:#cmps_needs_matching_ilias_version#:#Ez a bővítmény nem működik ezen az ILIAS verzión. -cmps#:#cmps_needs_newer_ilias_version#:#Ez a bővítményverzió csak újabb ILIAS-verziókon fut. Frissítse az ILIAS-t! -cmps#:#cmps_needs_newer_plugin_version#:#Ehhez az ILIAS-verzióhoz újabb bővítményverzió szükséges. Frissítse a bővítményt! -cmps#:#cmps_needs_update#:#Frissítés szükséges. -cmps#:#cmps_needs_upgrade#:#A jelenleg telepített bővítmény verziója régebbi, mint egy korábban telepítetté. Frissítse a bővítményt! -cmps#:#cmps_no_db_update_file_available#:#Nem érhető el az adatbázis-frissítő szkript. -cmps#:#cmps_no_language_file_available#:#Nem érhető el a nyelvi fájl. -cmps#:#cmps_plugin#:#Bővítmény -cmps#:#cmps_plugin_activated#:#A bővítményt bekapcsolta. -cmps#:#cmps_plugin_db_prefixes#:#Adatbázistábla előtag -cmps#:#cmps_plugin_deactivated#:#A bővítményt kikapcsolta. -cmps#:#cmps_plugin_deinstalled#:#A bővítményt eltávolította. -cmps#:#cmps_plugin_file#:#Bővítmény fájl -cmps#:#cmps_plugin_lang_prefixes#:#Bővítmény nyelvi előtagok -cmps#:#cmps_plugin_slot#:#Bővítmény csatlakozás -cmps#:#cmps_plugin_uninstalled#:#Az összes nyelvi- és adatbázis-bejegyzést sikeresen eltávolította, és a bővítményt inaktiválta. Most már biztonságosan letávolíthatja a bővítmény - ‘Customizing’ mappa alatt található - fájljait/mappáit. -cmps#:#cmps_plugin_updated#:#A bővítményt frissítette -cmps#:#cmps_plugins#:#Bővítmények -cmps#:#cmps_refresh#:#Frissítés -cmps#:#cmps_refresh_lng#:#Nyelv frissítés -cmps#:#cmps_rep_object#:#Tartalomtárban lévő objektumtípus -cmps#:#cmps_repository_object_types#:#ILIAS-objektum típusok -cmps#:#cmps_responsible#:#Felelős -cmps#:#cmps_responsible_mail#:#E-mail cím (Felelős) -cmps#:#cmps_save_options#:#Mentés -cmps#:#cmps_service#:#Szolgáltatás -cmps#:#cmps_show_details#:#Részletek megjelenítése -cmps#:#cmps_slots#:#Foglalatok -cmps#:#cmps_status#:#Állapot -cmps#:#cmps_supports_cli_setup#:#Parancssori telepítés támogatása -cmps#:#cmps_supports_export#:#Exportálás támogatása -cmps#:#cmps_supports_learning_progress#:#Tanulási haladás támogatása -cmps#:#cmps_uninstall#:#Eltávolítás -cmps#:#cmps_uninstall_confirm#:#Biztos, hogy eltávolítja a következő ‘%s’ bővítményt annak összes adatbázis-bejegyzésével együtt? -cmps#:#cmps_uninstall_inactive_confirm#:#‘%1$s’ bővítmény jelenleg nem aktiválható (‘%2$s’), így nem távolítható el teljesen, néhány adata a rendszerben marad. Biztos, hogy folytatja a részleges eltávolítást? -cmps#:#cmps_update#:#Frissítés -cmps#:#cmps_version#:#Verzió -cmps#:#database_is_uptodate#:#Az adatbázis naprakész. -cmps#:#no_changes#:#Nincsenek változások cmxv#:#cmxv_create#:#Tanúsítvány létrehozása a xAPI/cmi5-objektumhoz cmxv#:#cmxv_create_info#:#Válassza ki a teljesített xAPI/cmi5-objektumot, melyhez tanúsítványt kíván generálni cntr#:#cntr_add_new_item#:#Új objektum létrehozása diff --git a/lang/ilias_it.lang b/lang/ilias_it.lang index 88cd75049758..9aa1add2de68 100644 --- a/lang/ilias_it.lang +++ b/lang/ilias_it.lang @@ -3313,79 +3313,6 @@ cmix#:#use_fetch_info#:#Se la risorsa lo supporta, è consigliato utilizzare que cmix#:#xapi_statements_fetched_successfully#:#I risultati del Learning Record Store sono stati recuperati correttamente cmix#:#xapi_statements_last_fetch_date#:#Ultimo recupero dei risultati: cmix#:#xapi_statements_not_fetched_yet#:#I risultati di Learning Record Store non sono ancora stati recuperati -cmps#:#cmps_activate#:#Attivare -cmps#:#cmps_active#:#Attivo -cmps#:#cmps_add_new_rank#:#Posizione nel menù per Aggiungi-Nuovo-Oggetto -cmps#:#cmps_available#:#Disponibile -cmps#:#cmps_available_version#:#Available Version###31 03 2023 new variable -cmps#:#cmps_basic_files#:#File di base -cmps#:#cmps_class_file#:#File classe -cmps#:#cmps_component#:#Componenti -cmps#:#cmps_configure#:#Configura -cmps#:#cmps_current_db_version#:#Current DB-Version###31 03 2023 new variable -cmps#:#cmps_current_version#:#Versione attuale -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#Script per l'attualizzazione della banca dati -cmps#:#cmps_deactivate#:#Disattivato -cmps#:#cmps_detailed_information#:#Detailed Informationen###31 03 2023 new variable -cmps#:#cmps_dir#:#Directory -cmps#:#cmps_enable_creation#:#Consenti creazione -cmps#:#cmps_file_version#:#Aggiornamento del file -cmps#:#cmps_group#:#Gruppo -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Versione max. di ILIAS -cmps#:#cmps_ilias_min_version#:#Versione min. di ILIAS -cmps#:#cmps_inactive#:#Inattivo -cmps#:#cmps_install#:#Installa -cmps#:#cmps_is_active#:#Activated###31 03 2023 new variable -cmps#:#cmps_is_installed#:#Installed###31 03 2023 new variable -cmps#:#cmps_lang_files#:#File di lingua -cmps#:#cmps_lang_prefix#:#Prefisso della variabile di testo -cmps#:#cmps_languages#:#Lingue -cmps#:#cmps_main_dir#:#Directory principale -cmps#:#cmps_missing#:#Mancante -cmps#:#cmps_module#:#Modulo -cmps#:#cmps_must_installed#:#Il componente deve essere installato. -cmps#:#cmps_name#:#Nome -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###31 03 2023 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Questa versione del plugin funziona solo su una versione recente di ILIAS. Aggiornalo per favore. -cmps#:#cmps_needs_newer_plugin_version#:#Questa versione di ILIAS necessita di un'aggiornamento del plugin. Procedi per favore. -cmps#:#cmps_needs_update#:#Aggiornamento necessario -cmps#:#cmps_needs_upgrade#:#Il plugin che hai installato è di una versione precedente. Scarica la versione recente. -cmps#:#cmps_no_db_update_file_available#:#Non è disponibile nessuno script per il DB -cmps#:#cmps_no_language_file_available#:#Nessun file di lingua disponibile -cmps#:#cmps_plugin#:#Plug-in -cmps#:#cmps_plugin_activated#:#Il plugin è stato attivato. -cmps#:#cmps_plugin_db_prefixes#:#Prefissi delle tabelle del DB -cmps#:#cmps_plugin_deactivated#:#Il plugin è stato disattivato. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###31 03 2023 new variable -cmps#:#cmps_plugin_file#:#File Plugin -cmps#:#cmps_plugin_lang_prefixes#:#Plugin dei prefissi di lingua -cmps#:#cmps_plugin_slot#:#Plug-in fascia oraria -cmps#:#cmps_plugin_uninstalled#:#Il plugin è stato disinstallato. -cmps#:#cmps_plugin_updated#:#The plugin has been updated###31 03 2023 new variable -cmps#:#cmps_plugins#:#Plug-in -cmps#:#cmps_refresh#:#Aggiorna -cmps#:#cmps_refresh_lng#:#Refresh language###31 03 2023 new variable -cmps#:#cmps_rep_object#:#Oggetto del repository -cmps#:#cmps_repository_object_types#:#Tipi di oggetto repository -cmps#:#cmps_responsible#:#Responsabile -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###31 03 2023 new variable -cmps#:#cmps_save_options#:#Salva le opzioni -cmps#:#cmps_service#:#Servizio -cmps#:#cmps_show_details#:#Mostra i dettagli -cmps#:#cmps_slots#:#Fasce orarie -cmps#:#cmps_status#:#Stato -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###31 03 2023 new variable -cmps#:#cmps_supports_export#:#Supports export###31 03 2023 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###31 03 2023 new variable -cmps#:#cmps_uninstall#:#Disinstalla -cmps#:#cmps_uninstall_confirm#:#Sei sicuro di voler disinstallare il plugin "%s"? -cmps#:#cmps_uninstall_inactive_confirm#:#Il plug-in "%1$s" attualmente non può essere attivato ("%2$s"). In questo stato i plug-in non possono essere disinstallati completamente. Poiché solo l'integrazione di base viene ripristinata, i dati personalizzati del plug-in possono rimanere. Vuoi continuare con la disinstallazione parziale? -cmps#:#cmps_update#:#Aggiornamento -cmps#:#cmps_version#:#Versione -cmps#:#database_is_uptodate#:#Il database è aggiornato. -cmps#:#no_changes#:#Nessuna modifica cmxv#:#cmxv_create#:#Crea certificato per l'oggetto xAPI/cmi5 cmxv#:#cmxv_create_info#:#Seleziona un oggetto xAPI/cmi5 completato per generare un certificato relativo ad esso cntr#:#cntr_add_new_item#:#Aggiungi nuovo elemento diff --git a/lang/ilias_ja.lang b/lang/ilias_ja.lang index 436136590ae7..dc2a97bbd04f 100644 --- a/lang/ilias_ja.lang +++ b/lang/ilias_ja.lang @@ -3326,79 +3326,6 @@ cmix#:#use_fetch_info#:#リソースがこのオプションをサポートす cmix#:#xapi_statements_fetched_successfully#:#Learning Record Storeの結果は正しく取得されました。 cmix#:#xapi_statements_last_fetch_date#:#結果の更新取得 cmix#:#xapi_statements_not_fetched_yet#:#まだLearning Record Store結果が取得されていません。 -cmps#:#cmps_activate#:#有効にする -cmps#:#cmps_active#:#アクティブ -cmps#:#cmps_add_new_rank#:#新規追加オブジェクトラインの位置 -cmps#:#cmps_available#:#利用可能 -cmps#:#cmps_available_version#:#利用可能なバージョン -cmps#:#cmps_basic_files#:#基本ファイル -cmps#:#cmps_class_file#:#クラスファイル -cmps#:#cmps_component#:#構成要素 -cmps#:#cmps_configure#:#構成 -cmps#:#cmps_current_db_version#:#現在のDB-Version -cmps#:#cmps_current_version#:#現在のバージョン -cmps#:#cmps_database#:#データベース -cmps#:#cmps_db_update#:#DB更新スクリプト -cmps#:#cmps_deactivate#:#アクティブ解除 -cmps#:#cmps_detailed_information#:#詳細を表示情報 -cmps#:#cmps_dir#:#ディレクトリ -cmps#:#cmps_enable_creation#:#作成を有効にする -cmps#:#cmps_file_version#:#更新ファイルバージョン -cmps#:#cmps_group#:#グループ -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version -cmps#:#cmps_inactive#:#非アクティブ -cmps#:#cmps_install#:#インストール -cmps#:#cmps_is_active#:#アクティブ化 -cmps#:#cmps_is_installed#:#インストール済 -cmps#:#cmps_lang_files#:#言語ファイル -cmps#:#cmps_lang_prefix#:#言語変数プレフィックス -cmps#:#cmps_languages#:#言語 -cmps#:#cmps_main_dir#:#メインディレクトリ -cmps#:#cmps_missing#:#不明 -cmps#:#cmps_module#:#モジュール -cmps#:#cmps_must_installed#:#コンポーネントをインストールする必要があります。 -cmps#:#cmps_name#:#名前 -cmps#:#cmps_needs_matching_ilias_version#:#このプラグインは現在のILIASバージョンでは動作しません。 -cmps#:#cmps_needs_newer_ilias_version#:#このプラグインバージョンは新しいILIASバージョンでのみ動作します。 ILIASをバージョンアップしてください。 -cmps#:#cmps_needs_newer_plugin_version#:#このILIASのバージョンには新しいプラグインバージョンが必要です。プラグインを更新してください。 -cmps#:#cmps_needs_update#:#アップグレードが必要 -cmps#:#cmps_needs_upgrade#:#現在インストールされているプラグインバージョンは以前インストールしていたバージョンよりも古いものです。プラグインをアップグレードしてください。 -cmps#:#cmps_no_db_update_file_available#:#DB更新スクリプトがありません。 -cmps#:#cmps_no_language_file_available#:#言語ファイルがありません。 -cmps#:#cmps_plugin#:#プラグイン -cmps#:#cmps_plugin_activated#:#プラグインがアクティグになりました。 -cmps#:#cmps_plugin_db_prefixes#:#DBテーブルプレフィックス -cmps#:#cmps_plugin_deactivated#:#プラグインは解除されました。 -cmps#:#cmps_plugin_deinstalled#:#このプラグインは削除されました。 -cmps#:#cmps_plugin_file#:#プラグインファイル -cmps#:#cmps_plugin_lang_prefixes#:#プラグイン言語プレフィックス -cmps#:#cmps_plugin_slot#:#プラグインスロット -cmps#:#cmps_plugin_uninstalled#:#全ての言語とデータベース関連エントリーは削除されてプラグインは設定解除されました。完全にプラグインをアンインストールするために'Customizing'ディレクトリ内のプラグインのディレクトリとソースコードを削除できます。 -cmps#:#cmps_plugin_updated#:#プラグインはupdateされました。 -cmps#:#cmps_plugins#:#プラグイン -cmps#:#cmps_refresh#:#言語をリフレッシュ -cmps#:#cmps_refresh_lng#:#言語をリフレッシュ -cmps#:#cmps_rep_object#:#オブジェクトタイプのリポジトリ -cmps#:#cmps_repository_object_types#:#リポジトリオブジェクトタイプ -cmps#:#cmps_responsible#:#責任者 -cmps#:#cmps_responsible_mail#:#Mail (Responsible) -cmps#:#cmps_save_options#:#オプションを保存 -cmps#:#cmps_service#:#サービス -cmps#:#cmps_show_details#:#詳細を表示 -cmps#:#cmps_slots#:#スロット -cmps#:#cmps_status#:#ステータス -cmps#:#cmps_supports_cli_setup#:#CLI-Setupをサポート -cmps#:#cmps_supports_export#:#exportをサポート -cmps#:#cmps_supports_learning_progress#:#学習進捗をサポート -cmps#:#cmps_uninstall#:#アンインストール -cmps#:#cmps_uninstall_confirm#:#本当にプラグイン"%s"をアンインストールして関連の全てのデータベースエントリーを削除しますか? -cmps#:#cmps_uninstall_inactive_confirm#:#プラグイン"%1$s"は現在アクティブ ("%2$s")にできません。この状態ではプラグインを完全にアンインストールできません。コアのみの統合に戻りプラグインカスタムデータは残る可能性があります。部分アンインストールを続行しますか? -cmps#:#cmps_update#:#更新 -cmps#:#cmps_version#:#バージョン -cmps#:#database_is_uptodate#:#データベースは最新です。 -cmps#:#no_changes#:#変更なし cmxv#:#cmxv_create#:#xAPI/cmi5 Objectの証明書を作成 cmxv#:#cmxv_create_info#:#そのための証明書を作成するには、完了済xAPI/cmi5 objectを選択 cntr#:#cntr_add_new_item#:#新規アイテムを追加 diff --git a/lang/ilias_ka.lang b/lang/ilias_ka.lang index 083d40f89852..fdde15130e32 100644 --- a/lang/ilias_ka.lang +++ b/lang/ilias_ka.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#გააქტიურება -cmps#:#cmps_active#:#აქტიური -cmps#:#cmps_add_new_rank#:#ადგილი ახალი ობოექტის დამატების სიაში -cmps#:#cmps_available#:#შესაძლებელია -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#ძირითადი ფაილები -cmps#:#cmps_class_file#:#კლასის ფაილი -cmps#:#cmps_component#:#კომპონენტი -cmps#:#cmps_configure#:#კონფიგურაცია -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#მიმდინარე ვერსია -cmps#:#cmps_database#:#მონაცემთა ბაზა -cmps#:#cmps_db_update#:#დი ბი განახლების ხელნაწერი -cmps#:#cmps_deactivate#:#დეაქტივაცია -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#მიმართულება -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#ფაილის ვერსიის განახლება -cmps#:#cmps_group#:#ჯგუფი -cmps#:#cmps_id#:#აი დი -cmps#:#cmps_ilias_max_version#:#მაქსიმალური . ილიასის ვერსია -cmps#:#cmps_ilias_min_version#:#მინ. ილიასის ვერსია -cmps#:#cmps_inactive#:#არააქტიური -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#ენის ფაილები -cmps#:#cmps_lang_prefix#:#ენის ცვლადების თავსართი -cmps#:#cmps_languages#:#ენები -cmps#:#cmps_main_dir#:#მთავარი მიმართულება -cmps#:#cmps_missing#:#დაკარგულია -cmps#:#cmps_module#:#მოდული -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#სახელი -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#ეს პროგრამული გაფართოვების ვერსია მოქმედებს მხოლოდ უახლეს ილიასის ვერსიაზე. გთხოვთ განაახლოთ ილიასი -cmps#:#cmps_needs_newer_plugin_version#:#ილიასის ამ ვერსიას სჭირდება უფრო ახალი პროგრამული გაფართოვების ვერსია. გთხოვთ განაახლოთ პროგრამული გაფართოების -cmps#:#cmps_needs_update#:#საჭიროა განახლება -cmps#:#cmps_needs_upgrade#:#პროგრამული გაფართოების ვერსია რომელიც ახლა დააინსტალირეთ უფრო ძველია ვიდრე წინა პროგრამული გაფართოვებისს ვერსია. გთხოვთ განაახლოთ პროგრამული გაფართოების -cmps#:#cmps_no_db_update_file_available#:#დი ბის განახლება შეუძლებელია -cmps#:#cmps_no_language_file_available#:#ენის ფაილები არ არის ხელმისსაწვდომი -cmps#:#cmps_plugin#:#პროგრამული გაფართოვება -cmps#:#cmps_plugin_activated#:#პროგრამული გაფართოვება გააქტიურებულია -cmps#:#cmps_plugin_db_prefixes#:#დი ბის მაგიდის თავსართები -cmps#:#cmps_plugin_deactivated#:#პროგრამული გაფართოვება არააქტიურია -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#პროგრამული გაფართოვების ფაილი -cmps#:#cmps_plugin_lang_prefixes#:#ენის თავსართების პროგრამული გაფართოვება -cmps#:#cmps_plugin_slot#:#პროგრამული გაფართოვების კვალი -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#ჩანართები -cmps#:#cmps_refresh#:#განახლება -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#სასაწყობე ობიექტების სახეობა -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#პასუხისმგებელი -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#პარამეტრების შენახვა -cmps#:#cmps_service#:#სერვისი -cmps#:#cmps_show_details#:#დეტალების ჩვენება -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#სტატუსი -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#განახლება -cmps#:#cmps_version#:#ვერსია -cmps#:#database_is_uptodate#:#მონაცემთა ბაზა განახლებულია -cmps#:#no_changes#:#ცვლილებები არ არის cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#ახალი პუნქტის დამატება diff --git a/lang/ilias_lt.lang b/lang/ilias_lt.lang index 03c04ddc74e3..f865ddbc33a8 100644 --- a/lang/ilias_lt.lang +++ b/lang/ilias_lt.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Aktyvuoti -cmps#:#cmps_active#:#Aktyvu -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List###11 03 2008 new variable -cmps#:#cmps_available#:#Pasiekiama -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Pagrindiniai Failai -cmps#:#cmps_class_file#:#Klasės Failas -cmps#:#cmps_component#:#Komponentas -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Versija -cmps#:#cmps_database#:#Duombazė -cmps#:#cmps_db_update#:#DB Atnaujinimo Skriptas -cmps#:#cmps_deactivate#:#Išjungti -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Katalogas -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Atnaujinti Failo Versiją -cmps#:#cmps_group#:#Grupė -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maks. ILIAS Versija -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Versija -cmps#:#cmps_inactive#:#Neaktyvu -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Kalbos Failai -cmps#:#cmps_lang_prefix#:#Kalbos Kintamųjų Priešdėliai -cmps#:#cmps_languages#:#Kalbos -cmps#:#cmps_main_dir#:#Pagrindinis Katalogas -cmps#:#cmps_missing#:#Trūksta -cmps#:#cmps_module#:#Modulis -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Pavadinimas -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Šis įskiepis veikia tik naujesnėje ILIAS versijoje. Atnaujinkite ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Šiai ILIAS versijai reikia naujesnio įskiepio. Atnaujinkite įskiepį. -cmps#:#cmps_needs_update#:#Reikia atnaujinti -cmps#:#cmps_needs_upgrade#:#Dabar diegiamas įskiepis yra senesnis už jau įdiegtą. Atnaujinkite įskiepį. -cmps#:#cmps_no_db_update_file_available#:#Nėra DB atnaujinimo skripto -cmps#:#cmps_no_language_file_available#:#Nėra kalbos failo -cmps#:#cmps_plugin#:#Įskiepis -cmps#:#cmps_plugin_activated#:#The plugin has been activated.###22 05 2009 new variable -cmps#:#cmps_plugin_db_prefixes#:#DB Lentelės Priešdėliai -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated.###22 05 2009 new variable -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Įskiepio Failas -cmps#:#cmps_plugin_lang_prefixes#:#Įskiepių Kalbos Priešdėliai -cmps#:#cmps_plugin_slot#:#Įskiepio Vieta -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Įskiepiai -cmps#:#cmps_refresh#:#Atnaujinti -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Saugyklos Objekto Tipas -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible###07 10 2008 new variable -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Išsaugoti Nustatymus -cmps#:#cmps_service#:#Tarnyba -cmps#:#cmps_show_details#:#Rodyti Detales -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Statusas -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Naujinti -cmps#:#cmps_version#:#Versija -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell.###18 08 2009 new variable -cmps#:#no_changes#:#Keine Änderungen###18 08 2009 new variable cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Add New Item###30 04 2009 new variable diff --git a/lang/ilias_nl.lang b/lang/ilias_nl.lang index 704399676be2..589cf04eb330 100644 --- a/lang/ilias_nl.lang +++ b/lang/ilias_nl.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Inschakelen -cmps#:#cmps_active#:#Actief -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List -cmps#:#cmps_available#:#Beschikbaar -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Standaard bestanden -cmps#:#cmps_class_file#:#Class File -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Configureren -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Huidige versie -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#DB Update Script -cmps#:#cmps_deactivate#:#Uitschakelen -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Folder -cmps#:#cmps_enable_creation#:#Aanmaken toestaan -cmps#:#cmps_file_version#:#Update File Version -cmps#:#cmps_group#:#Groep -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Versie -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Versie -cmps#:#cmps_inactive#:#Inactief -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Taalbestanden -cmps#:#cmps_lang_prefix#:#Prefix taalvariabelen -cmps#:#cmps_languages#:#Talen -cmps#:#cmps_main_dir#:#Hoofdfolder -cmps#:#cmps_missing#:#Niet aanwezig -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Naam -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin. -cmps#:#cmps_needs_update#:#Wijziging nodig. -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin. -cmps#:#cmps_no_db_update_file_available#:#No DB update script available. -cmps#:#cmps_no_language_file_available#:#No language file available. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#The plugin has been activated. -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes -cmps#:#cmps_plugin_slot#:#Plugin Slot -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Verversen -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Studiecentrum Object Type -cmps#:#cmps_repository_object_types#:#Studiecentrum objecttypes -cmps#:#cmps_responsible#:#Verantwoordelijk -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Opties opslaan -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Tonen Details -cmps#:#cmps_slots#:#Openingen -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#De database is bijgewerkt -cmps#:#no_changes#:#Geen veranderingen cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Toevoegen item diff --git a/lang/ilias_pl.lang b/lang/ilias_pl.lang index 20ac2baecdd6..a8c32a0f6794 100644 --- a/lang/ilias_pl.lang +++ b/lang/ilias_pl.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Aktywuj -cmps#:#cmps_active#:#Aktywny -cmps#:#cmps_add_new_rank#:#Jesteś w liście wyboru dla obiektów, które należy dodać -cmps#:#cmps_available#:#Dostępne -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Pliki podstawowe -cmps#:#cmps_class_file#:#Pliki klas -cmps#:#cmps_component#:#Elementy -cmps#:#cmps_configure#:#Konfiguruj -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Aktualna wersja -cmps#:#cmps_database#:#Baza danych -cmps#:#cmps_db_update#:#Skrypt do aktualizacji bazy danych -cmps#:#cmps_deactivate#:#Deaktywuj -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Indeks -cmps#:#cmps_enable_creation#:#Włącz tworzenie -cmps#:#cmps_file_version#:#Zaktualizuj wersję pliku -cmps#:#cmps_group#:#Grupa -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maksymalna wersja ILIAS -cmps#:#cmps_ilias_min_version#:#Minimalna wersja ILIAS -cmps#:#cmps_inactive#:#Nieaktywny -cmps#:#cmps_install#:#Instaluj -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Plik językowy -cmps#:#cmps_lang_prefix#:#Prefiks dla zmiennej języków -cmps#:#cmps_languages#:#Języki -cmps#:#cmps_main_dir#:#Indeks główny -cmps#:#cmps_missing#:#Brak -cmps#:#cmps_module#:#Moduł -cmps#:#cmps_must_installed#:#Należy zainstalować element. -cmps#:#cmps_name#:#Nazwa -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Tej wtyczki można używać wyłącznie z nowszą wersją ILIAS. Zaktualizuj swoją wersję ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Ta wersja ILIAS potrzebują nowszą wersję wtyczki. Zaktualizuj wtyczkę. -cmps#:#cmps_needs_update#:#Konieczna jest aktualizacja -cmps#:#cmps_needs_upgrade#:#Aktualnie zainstalowana wtyczka jest starsza niż poprzednia wersja. Zainstaluj aktualną wersję wtyczki. -cmps#:#cmps_no_db_update_file_available#:#Skrypt dla aktualizacji bazy danych nie jest dostępny -cmps#:#cmps_no_language_file_available#:#Brak dostępnego pliku językowego -cmps#:#cmps_plugin#:#Wtyczka -cmps#:#cmps_plugin_activated#:#Włączono wtyczkę. -cmps#:#cmps_plugin_db_prefixes#:#Prefiksy dla tabeli bazy danych -cmps#:#cmps_plugin_deactivated#:#Wyłączono wtyczkę. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plik wtyczki -cmps#:#cmps_plugin_lang_prefixes#:#Prefiksy językowe wtyczki -cmps#:#cmps_plugin_slot#:#Interfejs wtyczki -cmps#:#cmps_plugin_uninstalled#:#Wszystkie wpisy językowe i w bazie danych, które odnoszą się do wtyczki, zostały usunięte, a wtyczka odinstalowana. Teraz możesz usunąć indeks i kod źródłowy wtyczki z indeksu ‘Customizing’, aby ostatecznie odinstalować wtyczkę. -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Wtyczki -cmps#:#cmps_refresh#:#Ponownie załaduj język -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Typ obiektu w dostępnych zasobach -cmps#:#cmps_repository_object_types#:#Typy obiektów w dostępnych zasobach -cmps#:#cmps_responsible#:#Odpowiedzialny -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Zapisz -cmps#:#cmps_service#:#Serwis -cmps#:#cmps_show_details#:#Pokaż szczegóły -cmps#:#cmps_slots#:#Sloty -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Odinstaluj -cmps#:#cmps_uninstall_confirm#:#Czy naprawdę chcesz odinstalować wtyczkę "%s" i usunąć wszystkie przydzielone wpisy w bazie danych? -cmps#:#cmps_uninstall_inactive_confirm#:#Aktualnie nie można aktywować wtyczki "%1$s" ("%2$s"). Ten status nie pozwala na całkowite odinstalowanie wtyczek. Ponieważ usuwana jest wyłącznie integracja systemowa, istnieje możliwość, że zostaną zachowane niektóre dane wtyczek. Czy chcesz kontynuować częściowe odinstalowanie? -cmps#:#cmps_update#:#Zaktualizuj -cmps#:#cmps_version#:#Wersja -cmps#:#database_is_uptodate#:#Baza danych jest aktualna. -cmps#:#no_changes#:#Brak zmian cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Dodaj nowy obiekt diff --git a/lang/ilias_pt.lang b/lang/ilias_pt.lang index 058becb904c2..19c525fb5ac4 100644 --- a/lang/ilias_pt.lang +++ b/lang/ilias_pt.lang @@ -3355,79 +3355,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Verifique o seu ficheiro de idioma -cmps#:#cmps_active#:#Verificar todos os idiomas -cmps#:#cmps_add_new_rank#:#Posição em lista Adicionar novos objetos -cmps#:#cmps_available#:#Verificação do weblink -cmps#:#cmps_available_version#:#Available Version###03 09 2026 new variable -cmps#:#cmps_basic_files#:#Se estiver ativado, todos os links externos nos módulos de aprendizagem ILIAS serão verificados quanto á sua ativação. -cmps#:#cmps_class_file#:#Verificar as contas de utilizador -cmps#:#cmps_component#:#Se estiver ativado, todos os utilizadores cujas contas expiram serão informados por e-mail duas semanas antes. Além disso, todos os utilizadores que não confirmaram a sua conta depois de um registo com confirmação de e-mail serão apagados. -cmps#:#cmps_configure#:#Configurar -cmps#:#cmps_current_db_version#:#Current DB-Version###03 09 2026 new variable -cmps#:#cmps_current_version#:#Verificar links de web -cmps#:#cmps_database#:#Se estiver ativado, todos os weblinks serão verificados quanto à sua ativação. -cmps#:#cmps_db_update#:#Mudar palavra-passe da pasta web -cmps#:#cmps_deactivate#:#Mudar palavra-passe ILIAS -cmps#:#cmps_detailed_information#:#Detailed Informationen###03 09 2026 new variable -cmps#:#cmps_dir#:#Mudar palavra-passe -cmps#:#cmps_enable_creation#:#Ativar criação -cmps#:#cmps_file_version#:#Atualizar versão de ficheiro -cmps#:#cmps_group#:#Selecione seu idioma -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Selecione apenas um idioma -cmps#:#cmps_ilias_min_version#:#Acesso de leitura à administração da sala de chat -cmps#:#cmps_inactive#:#A administração da sala de chat está visível -cmps#:#cmps_install#:#Instalar -cmps#:#cmps_is_active#:#Activated###03 09 2026 new variable -cmps#:#cmps_is_installed#:#Installed###03 09 2026 new variable -cmps#:#cmps_lang_files#:#Copiar sala de chat -cmps#:#cmps_lang_prefix#:#Cidade, estado -cmps#:#cmps_languages#:#Editar administração da sala de chat -cmps#:#cmps_main_dir#:#Limpar -cmps#:#cmps_missing#:#Limpar área de transferência -cmps#:#cmps_module#:#Cliente -cmps#:#cmps_must_installed#:#O componente tem de estar instalado. -cmps#:#cmps_name#:#ID do cliente -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###03 09 2026 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Esta versão de plugin apenas funciona em versões mais recentes de ILIAS. Atualize o ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Esta versão de ILIAS requer uma versão de plugin mais recente. Atualize o plugin. -cmps#:#cmps_needs_update#:#IP do cliente -cmps#:#cmps_needs_upgrade#:#A versão de plugin atualmente instalada é mais antiga do que a versão anteriormente instalada. Atualize o plugin. -cmps#:#cmps_no_db_update_file_available#:#Nenhum script de atualização DB disponível. -cmps#:#cmps_no_language_file_available#:#Nenhum ficheiro de idioma disponível. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#O plugin foi ativado. -cmps#:#cmps_plugin_db_prefixes#:#Fechar -cmps#:#cmps_plugin_deactivated#:#O plugin foi desativado. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###03 09 2026 new variable -cmps#:#cmps_plugin_file#:#(%s Novo) -cmps#:#cmps_plugin_lang_prefixes#:#Colapsado -cmps#:#cmps_plugin_slot#:#Colunas -cmps#:#cmps_plugin_uninstalled#:#Separado por vírgulas -cmps#:#cmps_plugin_updated#:#The plugin has been updated###03 09 2026 new variable -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Comentário -cmps#:#cmps_refresh_lng#:#Refresh language###03 09 2026 new variable -cmps#:#cmps_rep_object#:#Compor -cmps#:#cmps_repository_object_types#:#Tipos de objeto do repositório -cmps#:#cmps_responsible#:#Confirmar -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###03 09 2026 new variable -cmps#:#cmps_save_options#:#Contacto -cmps#:#cmps_service#:#Informação de contacto -cmps#:#cmps_show_details#:#Contactar a Assistência Técnica -cmps#:#cmps_slots#:#Slots -cmps#:#cmps_status#:#Estado -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###03 09 2026 new variable -cmps#:#cmps_supports_export#:#Supports export###03 09 2026 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###03 09 2026 new variable -cmps#:#cmps_uninstall#:#Desinstalar -cmps#:#cmps_uninstall_confirm#:#Conteúdo -cmps#:#cmps_uninstall_inactive_confirm#:#O plugin "%1$s" atualmente não pode ser ativado ("%2$s"). Neste estado, os plugins não podem ser desinstalados completamente. Como apenas a integração nuclear é revertida, os dados personalizados do plugin podem permanecer. Quer continuar com a desinstalação parcial? -cmps#:#cmps_update#:#Atualizar -cmps#:#cmps_version#:#Versão -cmps#:#database_is_uptodate#:#Estilos de conteúdo -cmps#:#no_changes#:#Contexto cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Aparecem popups de conteúdo quando o utilizador clica em partes interativas da imagem de fundo. Neste ecrã pode apenas definir os popups. O seu conteúdo pode ser editado no ecrã de edição da página. diff --git a/lang/ilias_ro.lang b/lang/ilias_ro.lang index 92d76aa3e417..242a9c00a82d 100644 --- a/lang/ilias_ro.lang +++ b/lang/ilias_ro.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate###11 03 2008 new variable -cmps#:#cmps_active#:#Active###11 03 2008 new variable -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List###11 03 2008 new variable -cmps#:#cmps_available#:#Available###11 03 2008 new variable -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files###11 03 2008 new variable -cmps#:#cmps_class_file#:#Class File###11 03 2008 new variable -cmps#:#cmps_component#:#Component###11 03 2008 new variable -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version###11 03 2008 new variable -cmps#:#cmps_database#:#Database###11 03 2008 new variable -cmps#:#cmps_db_update#:#DB Update Script###11 03 2008 new variable -cmps#:#cmps_deactivate#:#Deactivate###11 03 2008 new variable -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Directory###11 03 2008 new variable -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Update File Version###11 03 2008 new variable -cmps#:#cmps_group#:#Group###11 03 2008 new variable -cmps#:#cmps_id#:#ID###11 03 2008 new variable -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_inactive#:#Inactive###11 03 2008 new variable -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files###11 03 2008 new variable -cmps#:#cmps_lang_prefix#:#Language Variables Prefix###11 03 2008 new variable -cmps#:#cmps_languages#:#Languages###11 03 2008 new variable -cmps#:#cmps_main_dir#:#Main Directory###11 03 2008 new variable -cmps#:#cmps_missing#:#Missing###11 03 2008 new variable -cmps#:#cmps_module#:#Module###11 03 2008 new variable -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name###11 03 2008 new variable -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS.###11 03 2008 new variable -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_needs_update#:#Needs update.###11 03 2008 new variable -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_no_db_update_file_available#:#No DB update script available.###11 03 2008 new variable -cmps#:#cmps_no_language_file_available#:#No language file available.###11 03 2008 new variable -cmps#:#cmps_plugin#:#Plugin###11 03 2008 new variable -cmps#:#cmps_plugin_activated#:#The plugin has been activated.###22 05 2009 new variable -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated.###22 05 2009 new variable -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File###11 03 2008 new variable -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_slot#:#Plugin Slot###11 03 2008 new variable -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins###11 03 2008 new variable -cmps#:#cmps_refresh#:#Refresh###11 03 2008 new variable -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type###11 03 2008 new variable -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible###07 10 2008 new variable -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options###11 03 2008 new variable -cmps#:#cmps_service#:#Service###11 03 2008 new variable -cmps#:#cmps_show_details#:#Show Details###11 03 2008 new variable -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status###11 03 2008 new variable -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update###11 03 2008 new variable -cmps#:#cmps_version#:#Version###11 03 2008 new variable -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell.###18 08 2009 new variable -cmps#:#no_changes#:#Keine Änderungen###18 08 2009 new variable cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Add New Item###30 04 2009 new variable diff --git a/lang/ilias_ru.lang b/lang/ilias_ru.lang index 58480ec129ab..9fc5c606afd9 100644 --- a/lang/ilias_ru.lang +++ b/lang/ilias_ru.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate -cmps#:#cmps_active#:#Active -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List -cmps#:#cmps_available#:#Available -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files -cmps#:#cmps_class_file#:#Class File -cmps#:#cmps_component#:#Component -cmps#:#cmps_configure#:#Configure -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version -cmps#:#cmps_database#:#Database -cmps#:#cmps_db_update#:#DB Update Script -cmps#:#cmps_deactivate#:#Deactivate -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Директория -cmps#:#cmps_enable_creation#:#Enable Creation -cmps#:#cmps_file_version#:#Update File Version -cmps#:#cmps_group#:#Group -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version -cmps#:#cmps_inactive#:#Inactive -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files -cmps#:#cmps_lang_prefix#:#Language Variables Prefix -cmps#:#cmps_languages#:#Languages -cmps#:#cmps_main_dir#:#Главная директория -cmps#:#cmps_missing#:#Missing -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin. -cmps#:#cmps_needs_update#:#Needs update. -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin. -cmps#:#cmps_no_db_update_file_available#:#No DB update script available. -cmps#:#cmps_no_language_file_available#:#No language file available. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#The plugin has been activated. -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes -cmps#:#cmps_plugin_slot#:#Слот плагина -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Плагины -cmps#:#cmps_refresh#:#Refresh -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type -cmps#:#cmps_repository_object_types#:#Repository Object Types -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options -cmps#:#cmps_service#:#Service -cmps#:#cmps_show_details#:#Show Details -cmps#:#cmps_slots#:#Slots -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell. -cmps#:#no_changes#:#Keine Änderungen cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Добавить diff --git a/lang/ilias_sk.lang b/lang/ilias_sk.lang index c35153df1b1f..df02bc233cbb 100644 --- a/lang/ilias_sk.lang +++ b/lang/ilias_sk.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Aktivovat -cmps#:#cmps_active#:#Aktivní -cmps#:#cmps_add_new_rank#:#Pozice v seznamu "Přidat nový objekt" -cmps#:#cmps_available#:#Dostupné -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Základní soubory -cmps#:#cmps_class_file#:#Soubor třídy -cmps#:#cmps_component#:#Komponenta -cmps#:#cmps_configure#:#Konfigurovat -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Aktuální verze -cmps#:#cmps_database#:#Databáze -cmps#:#cmps_db_update#:#Skript aktualizace DB -cmps#:#cmps_deactivate#:#Deaktivovat -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Adresář -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Verze akualizačního souboru -cmps#:#cmps_group#:#Skupina -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. verze ILIAS -cmps#:#cmps_ilias_min_version#:#Min. verze ILIAS -cmps#:#cmps_inactive#:#Neaktivní -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Soubory jazyků -cmps#:#cmps_lang_prefix#:#Označení jazykových proměnných -cmps#:#cmps_languages#:#Jazyky -cmps#:#cmps_main_dir#:#Hlavní adresář -cmps#:#cmps_missing#:#Chybí -cmps#:#cmps_module#:#Modul -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Název -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Tato verze pluginu běží jen na novější verzi ILIAS. Aktualizujte prosím ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Tato verze ILIAS vyžaduje novější verzi zásuvného souboru. Aktualizujte prosím plugin. -cmps#:#cmps_needs_update#:#Vyžaduje aktualizaci. -cmps#:#cmps_needs_upgrade#:#Verze pluginu kterou instalujete je starší než nainstalovaná verze. Aktualizujte prosím plugin. -cmps#:#cmps_no_db_update_file_available#:#Není dostupný žádný skript aktualizace DB. -cmps#:#cmps_no_language_file_available#:#Žádný jazykový soubor není dostupný. -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#Plugin byl aktivován. -cmps#:#cmps_plugin_db_prefixes#:#Označení tabulek DB -cmps#:#cmps_plugin_deactivated#:#Plugin byl deaktivován. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Soubor pluginu -cmps#:#cmps_plugin_lang_prefixes#:#Označení jazykových pluginů -cmps#:#cmps_plugin_slot#:#Slot pluginu -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Pluginy -cmps#:#cmps_refresh#:#Obnovení -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Typ objektu úložiště -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Zodpovědný -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Uložit nastavení -cmps#:#cmps_service#:#Služba -cmps#:#cmps_show_details#:#Zobrazit detaily -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Akualizovat -cmps#:#cmps_version#:#Verze -cmps#:#database_is_uptodate#:#Tato databáze je aktuální. -cmps#:#no_changes#:#Žádné změny cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Přidat novou položku diff --git a/lang/ilias_sl.lang b/lang/ilias_sl.lang index 326cc127a130..22bff4f77d81 100644 --- a/lang/ilias_sl.lang +++ b/lang/ilias_sl.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet -cmps#:#cmps_activate#:#Aktiviraj -cmps#:#cmps_active#:#Aktivno -cmps#:#cmps_add_new_rank#:#Položaj na izbirnem seznamu objektov za dodajanje -cmps#:#cmps_available#:#Razpoložljivo -cmps#:#cmps_available_version#:#Available Version###19 08 2022 new variable -cmps#:#cmps_basic_files#:#Osnovne datoteke -cmps#:#cmps_class_file#:#Razredna datoteka -cmps#:#cmps_component#:#Komponenta -cmps#:#cmps_configure#:#Konfiguriraj -cmps#:#cmps_current_db_version#:#Current DB-Version###19 08 2022 new variable -cmps#:#cmps_current_version#:#Aktualna različica -cmps#:#cmps_database#:#Baza podatkov -cmps#:#cmps_db_update#:#Skripta za posodobitev baze podatkov -cmps#:#cmps_deactivate#:#Onemogoči -cmps#:#cmps_detailed_information#:#Detailed Informationen###19 08 2022 new variable -cmps#:#cmps_dir#:#Seznam -cmps#:#cmps_enable_creation#:#Vključi ustvarjanje -cmps#:#cmps_file_version#:#Posodobi različico datoteke -cmps#:#cmps_group#:#Skupina -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maksimalna različica ILIAS -cmps#:#cmps_ilias_min_version#:#Minimalna različica ILIAS -cmps#:#cmps_inactive#:#Neaktiven -cmps#:#cmps_install#:#Namesti -cmps#:#cmps_is_active#:#Activated###19 08 2022 new variable -cmps#:#cmps_is_installed#:#Installed###19 08 2022 new variable -cmps#:#cmps_lang_files#:#Jezikovna datoteka -cmps#:#cmps_lang_prefix#:#Predpona jezikovnih spremenljivk -cmps#:#cmps_languages#:#Jeziki -cmps#:#cmps_main_dir#:#Glavni seznam -cmps#:#cmps_missing#:#Manjka -cmps#:#cmps_module#:#Module -cmps#:#cmps_must_installed#:#Komponenta mora biti nameščena. -cmps#:#cmps_name#:#Ime -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###19 08 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Ta vtičnik se lahko uporablja samo z novejšo različico ILIAS. Posodobite različico ILIAS. -cmps#:#cmps_needs_newer_plugin_version#:#Ta različica ILIAS potrebuje novejšo različico vtičnika. Posodobite vtičnik. -cmps#:#cmps_needs_update#:#Potrebna je posodobitev -cmps#:#cmps_needs_upgrade#:#Trenutno nameščena različica vtičnika je starejša od predhodno nameščene. Namestite aktualen vtičnik. -cmps#:#cmps_no_db_update_file_available#:#Skripte za posodobitev baze podatkov ni na voljo -cmps#:#cmps_no_language_file_available#:#Nobena jezikovna datoteka ni na voljo -cmps#:#cmps_plugin#:#Vtičnik -cmps#:#cmps_plugin_activated#:#Vtičnik je bil aktiviran. -cmps#:#cmps_plugin_db_prefixes#:#Predpone tabele baze podatkov -cmps#:#cmps_plugin_deactivated#:#Vtičnik je bil onemogočen. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###19 08 2022 new variable -cmps#:#cmps_plugin_file#:#Datoteka vtičnika -cmps#:#cmps_plugin_lang_prefixes#:#Jezikovne predpone vtičnika -cmps#:#cmps_plugin_slot#:#Reža vtičnika -cmps#:#cmps_plugin_uninstalled#:#Vsi jezikovni vnosi in vnosi v bazo podatkov, ki se nanašajo na vtičnik, so bili izbrisani in vtičnik je onemogočen. Zdaj lahko s seznama ’Prilagajanje’ odstranite seznam in izvorno kodo vtičnika, da vtičnik dokončno odstranite. -cmps#:#cmps_plugin_updated#:#The plugin has been updated###19 08 2022 new variable -cmps#:#cmps_plugins#:#Vtičniki -cmps#:#cmps_refresh#:#Na novo naloži jezike -cmps#:#cmps_refresh_lng#:#Refresh language###19 08 2022 new variable -cmps#:#cmps_rep_object#:#Tip objekta v magazinu -cmps#:#cmps_repository_object_types#:#Tipi objektov v magazinu -cmps#:#cmps_responsible#:#Odgovoren -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###19 08 2022 new variable -cmps#:#cmps_save_options#:#Shrani -cmps#:#cmps_service#:#Storitev -cmps#:#cmps_show_details#:#Prikaži podrobnosti -cmps#:#cmps_slots#:#Dodeljeni čas -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###19 08 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###19 08 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###19 08 2022 new variable -cmps#:#cmps_uninstall#:#Odstrani -cmps#:#cmps_uninstall_confirm#:#Ali res želite odstraniti vtičnik "%s" in izbrisati vse povezane vnose v bazi podatkov? -cmps#:#cmps_uninstall_inactive_confirm#:#Vtičnika "%1$s" se trenutno ne da aktivirati ("%2$s"). V tem statusu ni mogoče v celoti odstraniti vtičnikov. Ker je izbrisana samo sistemska integracija, lahko lastne datoteke vtičnika ostanejo. Ali želite nadaljevati z delno odstranitvijo? -cmps#:#cmps_update#:#Posodobi -cmps#:#cmps_version#:#Različica -cmps#:#database_is_uptodate#:#Baza podatkov je posodobljena. -cmps#:#no_changes#:#Brez sprememb cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it cntr#:#cntr_add_new_item#:#Dodaj nov objekt diff --git a/lang/ilias_sq.lang b/lang/ilias_sq.lang index 9863a1951ec1..34c57ed426f6 100644 --- a/lang/ilias_sq.lang +++ b/lang/ilias_sq.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate###11 03 2008 new variable -cmps#:#cmps_active#:#Active###11 03 2008 new variable -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List###11 03 2008 new variable -cmps#:#cmps_available#:#Available###11 03 2008 new variable -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files###11 03 2008 new variable -cmps#:#cmps_class_file#:#Class File###11 03 2008 new variable -cmps#:#cmps_component#:#Component###11 03 2008 new variable -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version###11 03 2008 new variable -cmps#:#cmps_database#:#Database###11 03 2008 new variable -cmps#:#cmps_db_update#:#DB Update Script###11 03 2008 new variable -cmps#:#cmps_deactivate#:#Deactivate###11 03 2008 new variable -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Directory###11 03 2008 new variable -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Update File Version###11 03 2008 new variable -cmps#:#cmps_group#:#Group###11 03 2008 new variable -cmps#:#cmps_id#:#ID###11 03 2008 new variable -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_inactive#:#Inactive###11 03 2008 new variable -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files###11 03 2008 new variable -cmps#:#cmps_lang_prefix#:#Language Variables Prefix###11 03 2008 new variable -cmps#:#cmps_languages#:#Languages###11 03 2008 new variable -cmps#:#cmps_main_dir#:#Main Directory###11 03 2008 new variable -cmps#:#cmps_missing#:#Missing###11 03 2008 new variable -cmps#:#cmps_module#:#Module###11 03 2008 new variable -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name###11 03 2008 new variable -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS.###11 03 2008 new variable -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_needs_update#:#Needs update.###11 03 2008 new variable -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_no_db_update_file_available#:#No DB update script available.###11 03 2008 new variable -cmps#:#cmps_no_language_file_available#:#No language file available.###11 03 2008 new variable -cmps#:#cmps_plugin#:#Plugin###11 03 2008 new variable -cmps#:#cmps_plugin_activated#:#The plugin has been activated.###22 05 2009 new variable -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated.###22 05 2009 new variable -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File###11 03 2008 new variable -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_slot#:#Plugin Slot###11 03 2008 new variable -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins###11 03 2008 new variable -cmps#:#cmps_refresh#:#Refresh###11 03 2008 new variable -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type###11 03 2008 new variable -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible###07 10 2008 new variable -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options###11 03 2008 new variable -cmps#:#cmps_service#:#Service###11 03 2008 new variable -cmps#:#cmps_show_details#:#Show Details###11 03 2008 new variable -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status###11 03 2008 new variable -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update###11 03 2008 new variable -cmps#:#cmps_version#:#Version###11 03 2008 new variable -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell.###18 08 2009 new variable -cmps#:#no_changes#:#Keine Änderungen###18 08 2009 new variable cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Add New Item###30 04 2009 new variable diff --git a/lang/ilias_sr.lang b/lang/ilias_sr.lang index 6314243a8841..820e0dec6aef 100644 --- a/lang/ilias_sr.lang +++ b/lang/ilias_sr.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate###11 03 2008 new variable -cmps#:#cmps_active#:#Active###11 03 2008 new variable -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List###11 03 2008 new variable -cmps#:#cmps_available#:#Available###11 03 2008 new variable -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files###11 03 2008 new variable -cmps#:#cmps_class_file#:#Class File###11 03 2008 new variable -cmps#:#cmps_component#:#Component###11 03 2008 new variable -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version###11 03 2008 new variable -cmps#:#cmps_database#:#Database###11 03 2008 new variable -cmps#:#cmps_db_update#:#DB Update Script###11 03 2008 new variable -cmps#:#cmps_deactivate#:#Deactivate###11 03 2008 new variable -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Directory###11 03 2008 new variable -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Update File Version###11 03 2008 new variable -cmps#:#cmps_group#:#Group###11 03 2008 new variable -cmps#:#cmps_id#:#ID###11 03 2008 new variable -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_inactive#:#Inactive###11 03 2008 new variable -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files###11 03 2008 new variable -cmps#:#cmps_lang_prefix#:#Language Variables Prefix###11 03 2008 new variable -cmps#:#cmps_languages#:#Languages###11 03 2008 new variable -cmps#:#cmps_main_dir#:#Main Directory###11 03 2008 new variable -cmps#:#cmps_missing#:#Missing###11 03 2008 new variable -cmps#:#cmps_module#:#Module###11 03 2008 new variable -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name###11 03 2008 new variable -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS.###11 03 2008 new variable -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_needs_update#:#Needs update.###11 03 2008 new variable -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_no_db_update_file_available#:#No DB update script available.###11 03 2008 new variable -cmps#:#cmps_no_language_file_available#:#No language file available.###11 03 2008 new variable -cmps#:#cmps_plugin#:#Plugin###11 03 2008 new variable -cmps#:#cmps_plugin_activated#:#The plugin has been activated.###22 05 2009 new variable -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated.###22 05 2009 new variable -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File###11 03 2008 new variable -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_slot#:#Plugin Slot###11 03 2008 new variable -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins###11 03 2008 new variable -cmps#:#cmps_refresh#:#Refresh###11 03 2008 new variable -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type###11 03 2008 new variable -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible###07 10 2008 new variable -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options###11 03 2008 new variable -cmps#:#cmps_service#:#Service###11 03 2008 new variable -cmps#:#cmps_show_details#:#Show Details###11 03 2008 new variable -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status###11 03 2008 new variable -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update###11 03 2008 new variable -cmps#:#cmps_version#:#Version###11 03 2008 new variable -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell.###18 08 2009 new variable -cmps#:#no_changes#:#Keine Änderungen###18 08 2009 new variable cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Add New Item###30 04 2009 new variable diff --git a/lang/ilias_sv.lang b/lang/ilias_sv.lang index 7683df9b5ab7..3062d5fe0cd0 100644 --- a/lang/ilias_sv.lang +++ b/lang/ilias_sv.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#Så länge resursen stöder det här alternativet bör d cmix#:#xapi_statements_fetched_successfully#:#Resultat från Learning Record Store har hämtats framgångsrikt. cmix#:#xapi_statements_last_fetch_date#:#Sista hämtning av resultat cmix#:#xapi_statements_not_fetched_yet#:#Resultaten från Learning Record Store har ännu inte hämtats. -cmps#:#cmps_activate#:#Aktivera -cmps#:#cmps_active#:#Aktiv -cmps#:#cmps_add_new_rank#:#Position i urvalslistan för objekt som ska läggas till -cmps#:#cmps_available#:#Tillgänglig -cmps#:#cmps_available_version#:#Tillgänglig version -cmps#:#cmps_basic_files#:#Basisfiler -cmps#:#cmps_class_file#:# Klassfil -cmps#:#cmps_component#:#Komponent -cmps#:#cmps_configure#:#Konfigurera -cmps#:#cmps_current_db_version#:#Aktuell DB-version -cmps#:#cmps_current_version#:#Aktuell version -cmps#:#cmps_database#:#Databas -cmps#:#cmps_db_update#:#Script för uppdatering av databas -cmps#:#cmps_deactivate#:#Avaktivera -cmps#:#cmps_detailed_information#:#Detaljerad information -cmps#:#cmps_dir#:#Directory -cmps#:#cmps_enable_creation#:#Starta skapandet -cmps#:#cmps_file_version#:#Uppdatera filversion -cmps#:#cmps_group#:#Grupp -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maximal ILIAS-version -cmps#:#cmps_ilias_min_version#:#Minsta ILIAS-version -cmps#:#cmps_inactive#:#Inaktiv -cmps#:#cmps_install#:#Installera -cmps#:#cmps_is_active#:#Aktiverad -cmps#:#cmps_is_installed#:#Installerad -cmps#:#cmps_lang_files#:#Språkfil -cmps#:#cmps_lang_prefix#:#Prefix språkvariabel -cmps#:#cmps_languages#:#Språk -cmps#:#cmps_main_dir#:#Huvudkatalog -cmps#:#cmps_missing#:#Saknad -cmps#:#cmps_module#:#Modul -cmps#:#cmps_must_installed#:#Komponenten måste vara installerad. -cmps#:#cmps_name#:#Namn -cmps#:#cmps_needs_matching_ilias_version#:#Detta plugin kan inte användas med den aktuella versionen av denna ILIAS-installation. -cmps#:#cmps_needs_newer_ilias_version#:#Detta plugin kan endast användas med en nyare ILIAS-version. Vänligen uppdatera din ILIAS-version. -cmps#:#cmps_needs_newer_plugin_version#:#Den här ILIAS-versionen kräver en nyare plugin-version. Vänligen uppdatera pluginet. -cmps#:#cmps_needs_update#:#Uppdatering nödvändig -cmps#:#cmps_needs_upgrade#:#Den nuvarande installerade plugin-versionen är äldre än den tidigare installerade. Vänligen installera det senaste pluginet. -cmps#:#cmps_no_db_update_file_available#:#Inget skript för uppdatering av databas tillgängligt -cmps#:#cmps_no_language_file_available#:#Ingen språkfil tillgänglig -cmps#:#cmps_plugin#:#Plugin -cmps#:#cmps_plugin_activated#:#Insticksprogrammet har aktiverats. -cmps#:#cmps_plugin_db_prefixes#:#Prefixar databastabell -cmps#:#cmps_plugin_deactivated#:#Insticksprogrammet har avaktiverats. -cmps#:#cmps_plugin_deinstalled#:#Insticksprogrammet har avinstallerats -cmps#:#cmps_plugin_file#:#Plugin-fil -cmps#:#cmps_plugin_lang_prefixes#:#Plugin språk prefix -cmps#:#cmps_plugin_slot#:#Plugin-plats -cmps#:#cmps_plugin_uninstalled#:#Alla språk och databasposter relaterade till pluginet har tagits bort och pluginet har avaktiverats. Du kan nu ta bort katalogen och källkoden för insticksprogrammet från katalogen "Customizing" för att slutligen avinstallera insticksprogrammet. -cmps#:#cmps_plugin_updated#:#Insticksprogrammet har uppdaterats -cmps#:#cmps_plugins#:#Plugins -cmps#:#cmps_refresh#:#Reload språk -cmps#:#cmps_refresh_lng#:#Språk uppdaterat -cmps#:#cmps_rep_object#:#Objekttyp i tidningen -cmps#:#cmps_repository_object_types#:#Objekttyper i tidningen -cmps#:#cmps_responsible#:#Ansvarsfull -cmps#:#cmps_responsible_mail#:#Mail (ansvarig person) -cmps#:#cmps_save_options#:#Spara -cmps#:#cmps_service#:#Tjänst -cmps#:#cmps_show_details#:#Visa detaljer -cmps#:#cmps_slots#:#Slots -cmps#:#cmps_status#:#Status -cmps#:#cmps_supports_cli_setup#:#Stödjer CLI-installation -cmps#:#cmps_supports_export#:#Stödd export -cmps#:#cmps_supports_learning_progress#:#Stödjer framsteg i inlärningen -cmps#:#cmps_uninstall#:#Avinstallera -cmps#:#cmps_uninstall_confirm#:#Vill du verkligen avinstallera insticksprogrammet "%s" och ta bort alla tillhörande databasposter? -cmps#:#cmps_uninstall_inactive_confirm#:#Insticksprogrammet "%1$s" kan för närvarande inte aktiveras ("%2$s"). I denna status kan plugins inte avinstalleras helt. Eftersom endast systemintegrationen tas bort kan data som ägs av plugin finnas kvar. Vill du fortsätta med den partiella avinstallat -cmps#:#cmps_update#:#Uppdatera -cmps#:#cmps_version#:#Version -cmps#:#database_is_uptodate#:#Databasen är uppdaterad. -cmps#:#no_changes#:#Inga förändringar cmxv#:#cmxv_create#:#Skapa certifikat för xAPI/cmi5-objekt cmxv#:#cmxv_create_info#:#Välj ett slutfört xAPI/cmi5-objekt för att generera ett certifikat för det. cntr#:#cntr_add_new_item#:#Lägg till nytt objekt diff --git a/lang/ilias_tr.lang b/lang/ilias_tr.lang index 8e804b72194f..fd0b0b58c0d6 100644 --- a/lang/ilias_tr.lang +++ b/lang/ilias_tr.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Etkinleştir -cmps#:#cmps_active#:#Aktif -cmps#:#cmps_add_new_rank#:#Yeni Nesne Listesi Pozisyonu -cmps#:#cmps_available#:#Mevcut -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Temel Dosyalar -cmps#:#cmps_class_file#:#Sınıf Dosyası -cmps#:#cmps_component#:#Bileşen -cmps#:#cmps_configure#:#Yapılandırma -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Güncel Sürüm -cmps#:#cmps_database#:#Veritabanı -cmps#:#cmps_db_update#:#DB Güncelleme Senaryo -cmps#:#cmps_deactivate#:#Devre dışı bırak -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Dizin -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Güncelleme Dosya Sürümü -cmps#:#cmps_group#:#Grubu -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Maks. ILIAS Sürüm -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Sürüm -cmps#:#cmps_inactive#:#İnaktif -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Lisan Dosyalar -cmps#:#cmps_lang_prefix#:#Dil Değişkenleri Önek -cmps#:#cmps_languages#:#Diller -cmps#:#cmps_main_dir#:#Ana Dizin -cmps#:#cmps_missing#:#Eksik -cmps#:#cmps_module#:#Modülü -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Adı -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#Bu eklenti sürümü sadece yeni ILIAS sürümlerinde çalışır. ILIAS yükseltin. -cmps#:#cmps_needs_newer_plugin_version#:#Bu ILIAS sürümü daha yeni bir eklenti sürümü gerekiyor. Eklenti yükseltin. -cmps#:#cmps_needs_update#:#İhtiyaçları güncelleme. -cmps#:#cmps_needs_upgrade#:#Yüklü eklenti sürümü önceden yüklenmiş sürümü daha eski. Eklenti yükseltin. -cmps#:#cmps_no_db_update_file_available#:#Mevcut Resim DB güncelleştirme komut dosyası. -cmps#:#cmps_no_language_file_available#:#Mevcut dil dosyası yok. -cmps#:#cmps_plugin#:#Eklentisi -cmps#:#cmps_plugin_activated#:#eklentisi devreye alınmıştır. -cmps#:#cmps_plugin_db_prefixes#:#DB tablo Önekler -cmps#:#cmps_plugin_deactivated#:#eklentisi devrede değil. -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin Dosyası -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Dil Önekler -cmps#:#cmps_plugin_slot#:#Plugin Yuvası -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Eklentiler -cmps#:#cmps_refresh#:#Yenile -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Depo Nesne Türü -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Sorumlu -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Seçenekleri Kaydet -cmps#:#cmps_service#:#Hizmet -cmps#:#cmps_show_details#:#Detayları göster -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Durumu -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Güncelleme -cmps#:#cmps_version#:#Sürüm -cmps#:#database_is_uptodate#:#veritabanı güncel olduğunu. -cmps#:#no_changes#:#Hayır değişiklikler cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Yeni Öğe Ekle diff --git a/lang/ilias_uk.lang b/lang/ilias_uk.lang index b1d7b7f2c821..be5b8d0f2b25 100644 --- a/lang/ilias_uk.lang +++ b/lang/ilias_uk.lang @@ -3314,79 +3314,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate###11 03 2008 new variable -cmps#:#cmps_active#:#Active###11 03 2008 new variable -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List###11 03 2008 new variable -cmps#:#cmps_available#:#Available###11 03 2008 new variable -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files###11 03 2008 new variable -cmps#:#cmps_class_file#:#Class File###11 03 2008 new variable -cmps#:#cmps_component#:#Component###11 03 2008 new variable -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version###11 03 2008 new variable -cmps#:#cmps_database#:#Database###11 03 2008 new variable -cmps#:#cmps_db_update#:#DB Update Script###11 03 2008 new variable -cmps#:#cmps_deactivate#:#Deactivate###11 03 2008 new variable -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Directory###11 03 2008 new variable -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Update File Version###11 03 2008 new variable -cmps#:#cmps_group#:#Group###11 03 2008 new variable -cmps#:#cmps_id#:#ID###11 03 2008 new variable -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_inactive#:#Inactive###11 03 2008 new variable -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files###11 03 2008 new variable -cmps#:#cmps_lang_prefix#:#Language Variables Prefix###11 03 2008 new variable -cmps#:#cmps_languages#:#Languages###11 03 2008 new variable -cmps#:#cmps_main_dir#:#Main Directory###11 03 2008 new variable -cmps#:#cmps_missing#:#Missing###11 03 2008 new variable -cmps#:#cmps_module#:#Module###11 03 2008 new variable -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name###11 03 2008 new variable -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS.###11 03 2008 new variable -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_needs_update#:#Needs update.###11 03 2008 new variable -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_no_db_update_file_available#:#No DB update script available.###11 03 2008 new variable -cmps#:#cmps_no_language_file_available#:#No language file available.###11 03 2008 new variable -cmps#:#cmps_plugin#:#Plugin###11 03 2008 new variable -cmps#:#cmps_plugin_activated#:#The plugin has been activated.###22 05 2009 new variable -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated.###22 05 2009 new variable -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File###11 03 2008 new variable -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_slot#:#Plugin Slot###11 03 2008 new variable -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins###11 03 2008 new variable -cmps#:#cmps_refresh#:#Refresh###11 03 2008 new variable -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type###11 03 2008 new variable -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible###07 10 2008 new variable -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options###11 03 2008 new variable -cmps#:#cmps_service#:#Service###11 03 2008 new variable -cmps#:#cmps_show_details#:#Show Details###11 03 2008 new variable -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status###11 03 2008 new variable -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update###11 03 2008 new variable -cmps#:#cmps_version#:#Version###11 03 2008 new variable -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell.###18 08 2009 new variable -cmps#:#no_changes#:#Keine Änderungen###18 08 2009 new variable cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Add New Item###30 04 2009 new variable diff --git a/lang/ilias_vi.lang b/lang/ilias_vi.lang index ed84e205f2d2..e3b72b8c1116 100644 --- a/lang/ilias_vi.lang +++ b/lang/ilias_vi.lang @@ -3316,79 +3316,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#Activate###11 03 2008 new variable -cmps#:#cmps_active#:#Active###11 03 2008 new variable -cmps#:#cmps_add_new_rank#:#Position in Add-New-Object List###11 03 2008 new variable -cmps#:#cmps_available#:#Available###11 03 2008 new variable -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#Basic Files###11 03 2008 new variable -cmps#:#cmps_class_file#:#Class File###11 03 2008 new variable -cmps#:#cmps_component#:#Component###11 03 2008 new variable -cmps#:#cmps_configure#:#Configure###16 07 2010 new variable -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#Current Version###11 03 2008 new variable -cmps#:#cmps_database#:#Database###11 03 2008 new variable -cmps#:#cmps_db_update#:#DB Update Script###11 03 2008 new variable -cmps#:#cmps_deactivate#:#Deactivate###11 03 2008 new variable -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#Directory###11 03 2008 new variable -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#Update File Version###11 03 2008 new variable -cmps#:#cmps_group#:#Group###11 03 2008 new variable -cmps#:#cmps_id#:#ID###11 03 2008 new variable -cmps#:#cmps_ilias_max_version#:#Max. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_ilias_min_version#:#Min. ILIAS Version###11 03 2008 new variable -cmps#:#cmps_inactive#:#Inactive###11 03 2008 new variable -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#Language Files###11 03 2008 new variable -cmps#:#cmps_lang_prefix#:#Language Variables Prefix###11 03 2008 new variable -cmps#:#cmps_languages#:#Languages###11 03 2008 new variable -cmps#:#cmps_main_dir#:#Main Directory###11 03 2008 new variable -cmps#:#cmps_missing#:#Missing###11 03 2008 new variable -cmps#:#cmps_module#:#Module###11 03 2008 new variable -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#Name###11 03 2008 new variable -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#This plugin version only runs on newer ILIAS versions. Please upgrade ILIAS.###11 03 2008 new variable -cmps#:#cmps_needs_newer_plugin_version#:#This ILIAS version needs a newer plugin version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_needs_update#:#Needs update.###11 03 2008 new variable -cmps#:#cmps_needs_upgrade#:#The plugin version currently installed is older than a previously installed version. Please upgrade the plugin.###11 03 2008 new variable -cmps#:#cmps_no_db_update_file_available#:#No DB update script available.###11 03 2008 new variable -cmps#:#cmps_no_language_file_available#:#No language file available.###11 03 2008 new variable -cmps#:#cmps_plugin#:#Plugin###11 03 2008 new variable -cmps#:#cmps_plugin_activated#:#The plugin has been activated.###22 05 2009 new variable -cmps#:#cmps_plugin_db_prefixes#:#DB Table Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_deactivated#:#The plugin has been deactivated.###22 05 2009 new variable -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#Plugin File###11 03 2008 new variable -cmps#:#cmps_plugin_lang_prefixes#:#Plugin Language Prefixes###11 03 2008 new variable -cmps#:#cmps_plugin_slot#:#Plugin Slot###11 03 2008 new variable -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#Plugins###11 03 2008 new variable -cmps#:#cmps_refresh#:#Refresh###11 03 2008 new variable -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#Repository Object Type###11 03 2008 new variable -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible###07 10 2008 new variable -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#Save Options###11 03 2008 new variable -cmps#:#cmps_service#:#Service###11 03 2008 new variable -cmps#:#cmps_show_details#:#Show Details###11 03 2008 new variable -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#Status###11 03 2008 new variable -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#Update###11 03 2008 new variable -cmps#:#cmps_version#:#Version###11 03 2008 new variable -cmps#:#database_is_uptodate#:#Die Datenbank ist aktuell.###18 08 2009 new variable -cmps#:#no_changes#:#Keine Änderungen###18 08 2009 new variable cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#Add New Item###30 04 2009 new variable diff --git a/lang/ilias_zh.lang b/lang/ilias_zh.lang index c43b6b6ad416..768496f79ec5 100644 --- a/lang/ilias_zh.lang +++ b/lang/ilias_zh.lang @@ -3313,79 +3313,6 @@ cmix#:#use_fetch_info#:#As long as the resource supports this option, you should cmix#:#xapi_statements_fetched_successfully#:#Results from Learning Record Store were fetched successfully###07 02 2020 new variable cmix#:#xapi_statements_last_fetch_date#:#Last fetch of Results:###07 02 2020 new variable cmix#:#xapi_statements_not_fetched_yet#:#Results from Learning Record Store are not fetched yet###07 02 2020 new variable -cmps#:#cmps_activate#:#激活 -cmps#:#cmps_active#:#激活 -cmps#:#cmps_add_new_rank#:#在“添加新对象”列表中的位置 -cmps#:#cmps_available#:#有效的 -cmps#:#cmps_available_version#:#Available Version###29 07 2022 new variable -cmps#:#cmps_basic_files#:#基本文件 -cmps#:#cmps_class_file#:#类文件 -cmps#:#cmps_component#:#组件 -cmps#:#cmps_configure#:#配置 -cmps#:#cmps_current_db_version#:#Current DB-Version###29 07 2022 new variable -cmps#:#cmps_current_version#:#当前版本 -cmps#:#cmps_database#:#数据库 -cmps#:#cmps_db_update#:#数据库更新脚本 -cmps#:#cmps_deactivate#:#停用 -cmps#:#cmps_detailed_information#:#Detailed Informationen###29 07 2022 new variable -cmps#:#cmps_dir#:#目录 -cmps#:#cmps_enable_creation#:#Enable Creation###24 10 2013 new variable -cmps#:#cmps_file_version#:#更新文件版本 -cmps#:#cmps_group#:#用户组 -cmps#:#cmps_id#:#ID -cmps#:#cmps_ilias_max_version#:#Max. ILIAS 版本 -cmps#:#cmps_ilias_min_version#:#Min. ILIAS 版本 -cmps#:#cmps_inactive#:#不活跃的 -cmps#:#cmps_install#:#Install###10 11 2018 new variable -cmps#:#cmps_is_active#:#Activated###29 07 2022 new variable -cmps#:#cmps_is_installed#:#Installed###29 07 2022 new variable -cmps#:#cmps_lang_files#:#语言文件 -cmps#:#cmps_lang_prefix#:#语言变量前缀 -cmps#:#cmps_languages#:#语言 -cmps#:#cmps_main_dir#:#主目录 -cmps#:#cmps_missing#:#缺失 -cmps#:#cmps_module#:#模块 -cmps#:#cmps_must_installed#:#The component must be installed.###10 11 2018 new variable -cmps#:#cmps_name#:#名字 -cmps#:#cmps_needs_matching_ilias_version#:#This plugin does not work with your current ILIAS version.###29 07 2022 new variable -cmps#:#cmps_needs_newer_ilias_version#:#这个插件版本仅运行在更新的ILIAS版本上。请升级ILIAS。 -cmps#:#cmps_needs_newer_plugin_version#:#这个ILIAS版本需要一个更新的插件版本。请升级插件。 -cmps#:#cmps_needs_update#:#需要更新。 -cmps#:#cmps_needs_upgrade#:#当前已安装的插件版本比先前安装的版本更老,请升级插件。 -cmps#:#cmps_no_db_update_file_available#:#没有可用的数据库升级脚本。 -cmps#:#cmps_no_language_file_available#:#没有可用的语言文件。 -cmps#:#cmps_plugin#:#插件 -cmps#:#cmps_plugin_activated#:#插件已激活 -cmps#:#cmps_plugin_db_prefixes#:#数据库表前缀 -cmps#:#cmps_plugin_deactivated#:#插件已停用 -cmps#:#cmps_plugin_deinstalled#:#The plugin has been uninstalled###29 07 2022 new variable -cmps#:#cmps_plugin_file#:#插件文件 -cmps#:#cmps_plugin_lang_prefixes#:#插件语言前缀 -cmps#:#cmps_plugin_slot#:#插件插槽(Slot) -cmps#:#cmps_plugin_uninstalled#:#The plugin has been uninstalled.###30 08 2015 new variable -cmps#:#cmps_plugin_updated#:#The plugin has been updated###29 07 2022 new variable -cmps#:#cmps_plugins#:#插件 -cmps#:#cmps_refresh#:#刷新 -cmps#:#cmps_refresh_lng#:#Refresh language###29 07 2022 new variable -cmps#:#cmps_rep_object#:#库对象类型 -cmps#:#cmps_repository_object_types#:#Repository Object Types###30 12 2013 new variable -cmps#:#cmps_responsible#:#Responsible -cmps#:#cmps_responsible_mail#:#Mail (Responsible)###29 07 2022 new variable -cmps#:#cmps_save_options#:#保存选项 -cmps#:#cmps_service#:#服务 -cmps#:#cmps_show_details#:#细节展示 -cmps#:#cmps_slots#:#Slots###24 10 2013 new variable -cmps#:#cmps_status#:#状态 -cmps#:#cmps_supports_cli_setup#:#Supports CLI-Setup###29 07 2022 new variable -cmps#:#cmps_supports_export#:#Supports export###29 07 2022 new variable -cmps#:#cmps_supports_learning_progress#:#Supports learning progress###29 07 2022 new variable -cmps#:#cmps_uninstall#:#Uninstall###30 08 2015 new variable -cmps#:#cmps_uninstall_confirm#:#Are you sure you want to uninstall the plugin "%s"?###30 08 2015 new variable -cmps#:#cmps_uninstall_inactive_confirm#:#The plugin "%1$s" can currently not be activated ("%2$s"). In this state plugins cannot be uninstalled completely. As only the core integration is reverted, plugin custom data may remain. Would you like to continue with the partial uninstall?###23 12 2015 new variable -cmps#:#cmps_update#:#更新 -cmps#:#cmps_version#:#版本 -cmps#:#database_is_uptodate#:#该数据库是当前数据库 -cmps#:#no_changes#:#没有变化 cmxv#:#cmxv_create#:#Create Certificate for xAPI/cmi5 Object###07 02 2020 new variable cmxv#:#cmxv_create_info#:#Select a completed xAPI/cmi5 object to generate a certificate for it###07 02 2020 new variable cntr#:#cntr_add_new_item#:#加入新条目 From a91cb8f5f21d7ee488e423f5091f4fdef7a2180e Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:18 +0200 Subject: [PATCH 057/103] Repository: Load the `cmps` module instead of passing it to `txt()` `ModulesTableBuilder` loads the module in its constructor. Prepares the removal of the fallback module parameter from `ilLanguage::txt()`. --- .../Repository/Administration/Table/ModulesTableBuilder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/ILIAS/Repository/Administration/Table/ModulesTableBuilder.php b/components/ILIAS/Repository/Administration/Table/ModulesTableBuilder.php index f602a6fd8967..6f01d53bba67 100644 --- a/components/ILIAS/Repository/Administration/Table/ModulesTableBuilder.php +++ b/components/ILIAS/Repository/Administration/Table/ModulesTableBuilder.php @@ -36,6 +36,7 @@ public function __construct( object $parent_gui, string $parent_cmd ) { + $this->domain->lng()->loadLanguageModule('cmps'); parent::__construct($parent_gui, $parent_cmd, false); } @@ -92,7 +93,7 @@ protected function build(TableAdapterGUI $table): TableAdapterGUI if ($this->has_write_permission) { $table = $table ->singleAction('enableModuleCreation', $lng->txt('cmps_enable_creation')) - ->singleAction('disableModuleCreation', $lng->txt('cmps_disable_creation', 'cmps')) + ->singleAction('disableModuleCreation', $lng->txt('cmps_disable_creation')) ->singleAction('moveModuleToGroup', $lng->txt('move'), true); } From cc698133a007152acecf37c06a81101f6b244c8e Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:18 +0200 Subject: [PATCH 058/103] WebDAV: Load the `meta` module instead of passing it to `txt()` `DocumentTableGUI` and `DocumentFormGUI` load the module after construction. Prepares the removal of the fallback module parameter from `ilLanguage::txt()`. --- components/ILIAS/WebDAV/src/Mount/DocumentFormGUI.php | 3 ++- components/ILIAS/WebDAV/src/Mount/DocumentTableGUI.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/WebDAV/src/Mount/DocumentFormGUI.php b/components/ILIAS/WebDAV/src/Mount/DocumentFormGUI.php index 3794fc410cc9..8928dedc5cc8 100644 --- a/components/ILIAS/WebDAV/src/Mount/DocumentFormGUI.php +++ b/components/ILIAS/WebDAV/src/Mount/DocumentFormGUI.php @@ -52,6 +52,7 @@ public function __construct( protected bool $is_editable ) { parent::__construct(); + $this->lng->loadLanguageModule('meta'); $this->initForm(); } @@ -86,7 +87,7 @@ protected function initForm(): void $options = []; foreach ($this->lng->getInstalledLanguages() as $lng) { - $options[$lng] = $this->lng->txt('meta_l_' . $lng, 'meta'); + $options[$lng] = $this->lng->txt('meta_l_' . $lng); } asort($options); diff --git a/components/ILIAS/WebDAV/src/Mount/DocumentTableGUI.php b/components/ILIAS/WebDAV/src/Mount/DocumentTableGUI.php index 54030e814614..92e573b3280e 100644 --- a/components/ILIAS/WebDAV/src/Mount/DocumentTableGUI.php +++ b/components/ILIAS/WebDAV/src/Mount/DocumentTableGUI.php @@ -55,6 +55,7 @@ public function __construct( $this->setFormName('mount_instructions_documents'); parent::__construct($parent_obj, $command); + $this->lng->loadLanguageModule('meta'); $columns = $this->getColumnDefinition(); $this->optional_columns = $this->getSelectableColumns(); @@ -163,7 +164,7 @@ protected function getColumnDefinition(): array $columns[++$i] = [ 'field' => 'sorting', - 'txt' => $this->lng->txt('meta_order', 'meta'), + 'txt' => $this->lng->txt('meta_order'), 'default' => true, 'optional' => false, 'sortable' => false, From 5956b55336ffc7185c4668da8d68ae934f805131 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:27 +0200 Subject: [PATCH 059/103] Language: Replace `ntxt()` and the fallback module by `txt($a_topic, $n)` The interface `Language`, `ilLanguage`, `ilSetupLanguage` and `LanguageLegacyInitialisationAdapter` now offer `txt(string|LanguageIdentifier $a_topic, ?int $n = null)`: `$n` selects a plural form, `ntxt()` is gone. The fallback module parameter is removed, a `LanguageIdentifier` knows its module; strings no longer fall back to the default language (#13467). `ilPluginLanguage` and the fuzzy marker of the language editor keep that fallback explicitly. Named arguments are not used (review criteria). BREAKING: implementations of `Language` and subclasses of `ilLanguage` overriding `txt()` must adopt the new signature; callers passing a fallback module must load the module or use a `LanguageIdentifier`; `ntxt($a_topic, $n)` becomes `txt($a_topic, $n)`. --- components/ILIAS/Language/README.md | 47 +++++----- components/ILIAS/Language/ROADMAP.md | 4 +- .../classes/Setup/class.ilSetupLanguage.php | 16 +--- .../Language/classes/class.ilLanguage.php | 68 +++++---------- .../classes/class.ilObjLanguageExt.php | 8 +- .../classes/class.ilPluginLanguage.php | 9 +- components/ILIAS/Language/src/Language.php | 4 +- .../ILIAS/Language/src/LanguageIdentifier.php | 4 +- .../LanguageLegacyInitialisationAdapter.php | 4 +- .../tests/FuzzyMarkerDefaultLanguageTest.php | 86 +++++++++++++++++++ .../IdentifierTxtSetupAndAdapterTest.php | 24 ++++-- .../Language/tests/IdentifierTxtTest.php | 24 ++---- .../tests/MigratedAndLegacyModulesTest.php | 2 +- .../tests/{NtxtTest.php => PluralTxtTest.php} | 48 ++++------- .../PoMigrationLoadLanguageModuleTest.php | 14 --- .../Language/tests/ilPluginLanguageTest.php | 32 +++++++ .../tools/language-identifier-enum/README.md | 6 +- docs/development/language.md | 2 +- 18 files changed, 238 insertions(+), 164 deletions(-) create mode 100644 components/ILIAS/Language/tests/FuzzyMarkerDefaultLanguageTest.php rename components/ILIAS/Language/tests/{NtxtTest.php => PluralTxtTest.php} (83%) diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 55af334830e2..6ff2e51e7fab 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -23,11 +23,11 @@ session, or container state. It must not be used as the runtime language service $tpl->setVariable("TEXT", $language->txt("frm_new_posting")); ### Language Identifier Enums -`txt()` (`ILIAS\Language\Language`) and `ilLanguage::ntxt()` accept, besides a string, a `LanguageIdentifier`: +`txt()` (`ILIAS\Language\Language`) accepts, besides a string, a `LanguageIdentifier`: - $language->txt(PollLanguageIdentifier::POLL_ANSWERS); // text of the current language - $language->ntxt(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count - $language->txt('poll_answers'); // unchanged + $language->txt(PollLanguageIdentifier::POLL_ANSWERS); // text of the current language + $language->txt(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count + $language->txt('poll_answers'); // unchanged - `ILIAS\Language\LanguageIdentifier` is implemented by a string backed enum per module (`LanguageIdentifier`): the case value is the identifier, `module()` the module. The enum is generated from the module's `.pot` with @@ -39,14 +39,19 @@ session, or container state. It must not be used as the runtime language service language are read (cache of `lng_modules`, else `lng_modules`) and kept for the request, for the default language its entry is read from `lng_data`. No side effect on string calls: the module is not loaded, so the order "the module loaded last wins" stays as it is. Without a text in the current language the one of the default language is served, - otherwise `-identifier-`. The fallback module argument is ignored, the module is known. -- With a string `txt()` and `ntxt()` behave exactly as before. + otherwise `-identifier-`. +- With a string and without `$n`, `txt()` behaves as before, without a fallback module (see below). - An empty identifier (as for a string, also `"0"`) gives `""`. -- `$n` only selects a plural form of a plural message of a migrated module; the quantity is not inserted into the text. +- `$n` only selects a plural form of a plural message of a migrated module, see [Plural Forms](#plural-forms). - `txtlng()` and `exists()` take a string only. - Breaking change (announced for the Jour Fixe): implementations of `ILIAS\Language\Language` and subclasses of - `ilLanguage` that declare the parameter type of `txt()`/`ntxt()` must widen it to `string|LanguageIdentifier`. - `ilSetupLanguage` reads only the identifier of a `LanguageIdentifier` - Setup needs English at most. + `ilLanguage` that override `txt()` must accept `string|LanguageIdentifier $a_topic, ?int $n = null`. `ntxt()` is + gone, use `txt($a_topic, $n)`. The second parameter `$a_default_lang_fallback_mod` is gone: load the module with + `loadLanguageModule()` or pass a `LanguageIdentifier`. A string no longer falls back to the default language + (#13467); only a `LanguageIdentifier` does. Behaviour change: where callers passed a fallback module, a key missing + in the user's language now shows `-key-` instead of the text of the default language (`ilPluginLanguage` and the fuzzy marker of the + language editor keep the fallback). + `ilSetupLanguage` reads only the identifier of a `LanguageIdentifier` (`$n` without effect) - Setup needs English at most. ## Installing and Managing Languages Which languages are installed/available (`ILIAS\Language\Setup\InstalledLanguageRepository`) and installing, flushing @@ -143,9 +148,8 @@ module that is not migrated). problem is logged and shown in the language administration. With a thread-safe PHP (ZTS) the locale applies to every thread of the process. * Behaviour change: an identifier that the migrated module loaded last does not - translate gives `-identifier-`, not the value of a module loaded before. The - fallback module passed to `txt()` still applies. -* `txt()`/`ntxt()` with a `LanguageIdentifier` read from exactly its module, see + translate gives `-identifier-`, not the value of a module loaded before. +* `txt()` with a `LanguageIdentifier` reads from exactly its module, see [above](#language-identifier-enums). * Apart from these lookups, `.po`/`.mo` files are read and written with the library `gettext/gettext`, only through the adapter in @@ -154,16 +158,13 @@ module that is not migrated). library's `Scanner` is not used. ### Plural Forms -`ilLanguage::ntxt(string $a_topic, int $a_n, string -$a_default_lang_fallback_mod = ""): string` returns the form of a plural -message (`msgid_plural`, `msgstr[n]`) that the `Plural-Forms` rule of the -language selects for `$a_n`. The quantity is not inserted; callers do that, -e.g. with `sprintf()`. In every other case (module not migrated, plugin, no -plural message, empty form) it returns exactly what `txt()` returns. - -* `ntxt()` is not part of the interface `ILIAS\Language\Language` (adding it - would break implementations); it is available on `ilLanguage`. -* `txt()` returns the default form of a plural message: the last form +`txt($a_topic, $n)` returns the form of a plural message (`msgid_plural`, +`msgstr[n]`) that the `Plural-Forms` rule of the language selects for `$n`. +The quantity is not inserted; callers do that, e.g. with `sprintf()`. In every +other case (module not migrated, plugin, no plural message, empty form) it +returns exactly what `txt()` without `$n` returns. + +* `txt()` without `$n` returns the default form of a plural message: the last form (CLDR "other"), or the last non-empty one before it. The database holds this form only; all forms exist only in the `.po` files, the build and the overlay. @@ -321,7 +322,7 @@ Plugins cannot contribute a `LanguageFileDirectory` yet. A plugin may ship both exist): `msgid` is the key without the plugin prefix, entries with a `msgctxt` are ignored and logged. The values go into the database as before (no build, no overlay), `#.` comments are not stored, plural messages are -stored with their default form (`ntxt()` returns `txt()` for plugins). Values +stored with their default form (`$n` has no effect for plugins). Values of a plugin `.po` with markup that is not allowed are cleaned and logged; values of a plugin `.lang` are only logged and taken over unchanged. diff --git a/components/ILIAS/Language/ROADMAP.md b/components/ILIAS/Language/ROADMAP.md index abf0a1addb25..2033ae544032 100755 --- a/components/ILIAS/Language/ROADMAP.md +++ b/components/ILIAS/Language/ROADMAP.md @@ -14,7 +14,7 @@ * Extracted language-installation state access into `InstalledLanguageRepository` and `LanguageInstallationManager` (repository pattern), and language-file-directory resolution into `ILIAS\Language\ComponentTranslation\*` - see the README -* Plural forms for modules maintained in PO files: `ilLanguage::ntxt()`, `Plural-Forms` evaluated by +* Plural forms for modules maintained in PO files: `txt($a_topic, $n)`, `Plural-Forms` evaluated by `ILIAS\Language\ComponentTranslation\PluralForms` (pilot modules `tos` and `poll`, see [Translations Maintained in PO Files](README.md#translations-maintained-in-po-files)) @@ -30,7 +30,7 @@ * Let the GUI classes get the Activities by dependency injection once ilCtrl allows it, and drop the Activities and the `LanguageFileDirectoryManager` from the legacy `$DIC` (`AllModernComponents`) * Build `lng` in the dependency graph instead of `ilInitialisation::initLanguage()` -* Replace hand-made singular/plural key pairs by plural messages and `ilLanguage::ntxt()` together +* Replace hand-made singular/plural key pairs by plural messages and `txt($a_topic, $n)` together with the owning components (first proposal: `Poll`) ## Mid Term diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index d230a8f23169..2dc98ebe194c 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -173,15 +173,17 @@ public function getLanguageFileDirectoryManager(): LanguageFileDirectoryManager * * if the topic is not in the list, the topic itself with "-" will be returned * - * $a_topic topic; the module of a LanguageIdentifier has no effect in Setup + * $a_topic topic; the module of a LanguageIdentifier and $n have no effect for it in Setup */ - public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ''): string + public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string { /** @var ilLogger $log */ global $log; if ($a_topic instanceof LanguageIdentifier) { $a_topic = (string) $a_topic->value; + } elseif ($n !== null) { + return parent::txt($a_topic, $n); } if (empty($a_topic)) { return ""; @@ -205,16 +207,6 @@ public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_f return $translation; } - /** - * Same as txt() for a LanguageIdentifier: ilLanguage would read its module from the database. - */ - public function ntxt(string|LanguageIdentifier $a_topic, int $a_n, string $a_default_lang_fallback_mod = ""): string - { - return $a_topic instanceof LanguageIdentifier - ? $this->txt($a_topic) - : parent::ntxt($a_topic, $a_n, $a_default_lang_fallback_mod); - } - /** * install languages * diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index b45dfea798d4..5619fc39225a 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -201,12 +201,18 @@ public function txtlng(string $a_module, string $a_topic, string $a_language): s * if the topic is not in the list, the topic itself with "-" will be returned * * A LanguageIdentifier is read from exactly its module without loadLanguageModule() and without side effect, - * falling back to the default language; $a_default_lang_fallback_mod is then ignored. + * falling back to the default language. + * + * $n selects a plural form of a migrated module; the quantity is not inserted, callers do that. */ - public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string { if ($a_topic instanceof LanguageIdentifier) { - return $this->translateInModule($a_topic->module(), (string) $a_topic->value, null); + return $this->translateInModule($a_topic->module(), (string) $a_topic->value, $n); + } + if ($n !== null) { + // $this->txt(): a subclass (ilSetupLanguage) serves the singular as before + return $this->pluralTxt($a_topic, $n) ?? $this->txt($a_topic); } if (empty($a_topic)) { return ""; @@ -226,32 +232,11 @@ public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_f } // deliberately not the value of an earlier module: with the same identifier it mostly means // something else, and "-key-" is noticed. Differs from the .lang path (array_merge kept the earlier value). - // Only the fallback module the caller names still applies. $translation = ""; } else { $translation = $this->text[$a_topic] ?? ""; } - if ($translation === "" && $a_default_lang_fallback_mod !== "") { - // #13467 - try current language first (could be missing module) - if ($this->lang_key != $this->lang_default) { - $translation = self::_lookupEntry( - $this->lang_key, - $a_default_lang_fallback_mod, - $a_topic - ); - } - // try default language last - if ($translation === "" || $translation === "-" . $a_topic . "-") { - $translation = self::_lookupEntry( - $this->lang_default, - $a_default_lang_fallback_mod, - $a_topic - ); - } - } - - if ($translation === "") { if (ILIAS_LOG_ENABLED && is_object($this->log)) { $this->log->debug("Language (" . $this->lang_key . "): topic -" . $a_topic . "- not present"); @@ -267,33 +252,24 @@ public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_f } /** - * Plural form selected by the "Plural-Forms" rule (native gettext) for a plural message of a migrated - * module, otherwise exactly txt(). The quantity is not inserted; callers do that, e.g. with sprintf(). - * - * A LanguageIdentifier is read as in txt(), $a_default_lang_fallback_mod is then ignored. A fallback module is - * read the same way, language of this instance first, then the default language (#13467). + * `null` if txt() without $n applies. */ - public function ntxt(string|LanguageIdentifier $a_topic, int $a_n, string $a_default_lang_fallback_mod = ""): string + private function pluralTxt(string $a_topic, int $n): ?string { - if ($a_topic instanceof LanguageIdentifier) { - return $this->translateInModule($a_topic->module(), (string) $a_topic->value, $a_n); - } $module = $a_topic === '' ? null : ($this->migrated_key_modules[$a_topic] ?? null); - if ($module === null && $a_default_lang_fallback_mod !== '' && ($this->text[$a_topic] ?? '') === '') { - return $this->translateInModule($a_default_lang_fallback_mod, $a_topic, $a_n); - } - $value = $module === null - ? null - : MigratedTranslations::pluralText($module, $this->migratedLangKey(), $a_topic, $a_n, self::clientDataDir()); - if ($value !== null && $value !== '') { - self::$used_topics[$a_topic] = $a_topic; - if ($this->usage_log_enabled) { - self::logUsage($module, $a_topic); - } - return $value; + if ($module === null) { + return null; + } + $value = MigratedTranslations::pluralText($module, $this->migratedLangKey(), $a_topic, $n, self::clientDataDir()); + if ($value === null || $value === '') { + return null; + } + self::$used_topics[$a_topic] = $a_topic; + if ($this->usage_log_enabled) { + self::logUsage($module, $a_topic); } - return $this->txt($a_topic, $a_default_lang_fallback_mod); + return $value; } /** diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php index 8b1bcf754bdd..d6ab66f48bc2 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php @@ -350,7 +350,13 @@ private static function withFuzzyMarkers(array $comments, array $shipped): array return $comments; } global $DIC; - $marker = $DIC->language()->txt(self::SHIPPED_FUZZY_MARKER_TOPIC, 'lng'); + $lng = $DIC->language(); + $lng->loadLanguageModule('lng'); + $marker = $lng->txt(self::SHIPPED_FUZZY_MARKER_TOPIC); + if ($marker === '-' . self::SHIPPED_FUZZY_MARKER_TOPIC . '-' && $lng instanceof ilLanguage) { + // only shipped in de/en: the default language fills the gap, as before + $marker = ilLanguage::_lookupEntry($lng->getDefaultLanguage(), 'lng', self::SHIPPED_FUZZY_MARKER_TOPIC); + } foreach ($shipped['fuzzy'] as $key) { $comments[$key] = isset($comments[$key]) && $comments[$key] !== '' ? $comments[$key] . ' - ' . $marker diff --git a/components/ILIAS/Language/classes/class.ilPluginLanguage.php b/components/ILIAS/Language/classes/class.ilPluginLanguage.php index 2e80294d2667..8e8126af0b7a 100755 --- a/components/ILIAS/Language/classes/class.ilPluginLanguage.php +++ b/components/ILIAS/Language/classes/class.ilPluginLanguage.php @@ -384,6 +384,13 @@ public function txt(string $a_var): string $lng = $DIC->language(); $this->loadLanguageModule(); - return $lng->txt($this->getPrefix() . "_" . $a_var, $this->getPrefix()); + $key = $this->getPrefix() . "_" . $a_var; + $text = $lng->txt($key); + if ($text !== "-" . $key . "-" || !$lng instanceof ilLanguage) { + return $text; + } + + // plugin language files are often incomplete: the default language fills the gap, as before + return ilLanguage::_lookupEntry($lng->getDefaultLanguage(), $this->getPrefix(), $key); } } diff --git a/components/ILIAS/Language/src/Language.php b/components/ILIAS/Language/src/Language.php index 93f7dfd44372..21a892e1bbfa 100644 --- a/components/ILIAS/Language/src/Language.php +++ b/components/ILIAS/Language/src/Language.php @@ -23,9 +23,9 @@ interface Language { /** - * $a_default_lang_fallback_mod is ignored for a LanguageIdentifier. + * $n selects a plural form, e.g. txt($id, 5). */ - public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string; + public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string; public function loadLanguageModule(string $a_module): void; diff --git a/components/ILIAS/Language/src/LanguageIdentifier.php b/components/ILIAS/Language/src/LanguageIdentifier.php index 58081009010c..d0cffbd11456 100644 --- a/components/ILIAS/Language/src/LanguageIdentifier.php +++ b/components/ILIAS/Language/src/LanguageIdentifier.php @@ -22,8 +22,8 @@ /** * A language identifier as an enum case (backing value = identifier), generated per module from its - * `.pot` (see components/ILIAS/Language/tools/language-identifier-enum/). Language::txt() and - * \ilLanguage::ntxt() read it from exactly module(), regardless of which module was loaded last. + * `.pot` (see components/ILIAS/Language/tools/language-identifier-enum/). Language::txt() reads it + * from exactly module(), regardless of which module was loaded last. */ interface LanguageIdentifier extends \BackedEnum { diff --git a/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php b/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php index 31856706c826..f7ac89de8260 100644 --- a/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php +++ b/components/ILIAS/Language/src/LanguageLegacyInitialisationAdapter.php @@ -25,9 +25,9 @@ */ class LanguageLegacyInitialisationAdapter implements Language { - public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string { - return $this->getLegacyLanguageInstance()->txt($a_topic, $a_default_lang_fallback_mod); + return $this->getLegacyLanguageInstance()->txt($a_topic, $n); } public function loadLanguageModule(string $a_module): void diff --git a/components/ILIAS/Language/tests/FuzzyMarkerDefaultLanguageTest.php b/components/ILIAS/Language/tests/FuzzyMarkerDefaultLanguageTest.php new file mode 100644 index 000000000000..2126f7662200 --- /dev/null +++ b/components/ILIAS/Language/tests/FuzzyMarkerDefaultLanguageTest.php @@ -0,0 +1,86 @@ + */ + private array $queries = []; + + protected function setUp(): void + { + parent::setUp(); + + if (!defined('ILIAS_ABSOLUTE_PATH')) { + define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../')); + } + $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); + } + + /** @return array */ + private function markersWith(string $user_text, ?string $default_text): array + { + $lng = $this->createStub(ilLanguage::class); + $lng->method('txt')->willReturn($user_text); + $lng->method('getDefaultLanguage')->willReturn('en'); + $this->setGlobalVariable('lng', $lng); + + $statement = $this->createStub(ilDBStatement::class); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value): string => "'" . (string) $value . "'"); + $db->method('query')->willReturnCallback(function (string $sql) use ($statement) { + $this->queries[] = $sql; + + return $statement; + }); + $db->method('fetchAssoc')->willReturn($default_text === null ? null : ['value' => $default_text]); + $this->setGlobalVariable('ilDB', $db); + + $method = new ReflectionMethod(ilObjLanguageExt::class, 'withFuzzyMarkers'); + + return $method->invoke(null, ['lng.#:#.a' => 'note'], ['fuzzy' => ['lng.#:#.a', 'lng.#:#.b']]); + } + + public function testAMarkerMissingInTheUserLanguageComesFromTheDefaultLanguage(): void + { + $comments = $this->markersWith('-lng_not_translated-', 'Not translated en'); + + $this->assertSame(['lng.#:#.a' => 'note - Not translated en', 'lng.#:#.b' => 'Not translated en'], $comments); + $this->assertCount(1, $this->queries); + $this->assertStringContainsString("module = 'lng' AND lang_key = 'en' AND identifier = 'lng_not_translated'", $this->queries[0]); + } + + public function testAMarkerOfTheUserLanguageIsUsedWithoutFurtherQuery(): void + { + $comments = $this->markersWith('Nicht uebersetzt', 'Not translated en'); + + $this->assertSame(['lng.#:#.a' => 'note - Nicht uebersetzt', 'lng.#:#.b' => 'Nicht uebersetzt'], $comments); + $this->assertSame([], $this->queries); + } + + public function testAMarkerMissingEverywhereStaysTheMissingKeyMarker(): void + { + $comments = $this->markersWith('-lng_not_translated-', null); + + $this->assertSame('-lng_not_translated-', $comments['lng.#:#.b']); + $this->assertSame('note - -lng_not_translated-', $comments['lng.#:#.a']); + } +} diff --git a/components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php b/components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php index 6ba3695e0616..7657c3f9bec4 100644 --- a/components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php +++ b/components/ILIAS/Language/tests/IdentifierTxtSetupAndAdapterTest.php @@ -58,24 +58,34 @@ public function testSetupLanguageTxtServesTheValueOfAnEnum(string|LanguageIdenti $this->assertSame($expected, $language->txt($key)); } - public function testSetupLanguageNtxtOfAnEnumIsTxtOfItsValue(): void + public function testSetupLanguageIgnoresNForAnEnum(): void { $GLOBALS['log'] = $this->createStub(ilLogger::class); $language = new ilSetupLanguage('de'); $language->text = ['known' => 'Bekannt']; foreach ([0, 1, 5] as $n) { - $this->assertSame('Bekannt', $language->ntxt(IdentifierTxtSetupTestIdentifier::KNOWN, $n)); - $this->assertSame('-unknown-', $language->ntxt(IdentifierTxtSetupTestIdentifier::UNKNOWN, $n)); + $this->assertSame('Bekannt', $language->txt(IdentifierTxtSetupTestIdentifier::KNOWN, $n)); + $this->assertSame('-unknown-', $language->txt(IdentifierTxtSetupTestIdentifier::UNKNOWN, $n)); } - $this->assertSame('', $language->ntxt(IdentifierTxtSetupTestIdentifier::EMPTY, 2)); + $this->assertSame('', $language->txt(IdentifierTxtSetupTestIdentifier::EMPTY, 2)); } - public function testAdapterPassesEnumAndFallbackOnUnchanged(): void + public function testSetupLanguageServesAStringWithNFromThePluralPathOfIlLanguage(): void + { + $GLOBALS['log'] = $this->createStub(ilLogger::class); + $language = new ilSetupLanguage('de'); + $language->text = ['known' => 'Bekannt']; + + $this->assertSame('Bekannt', $language->txt('known', 3)); + $this->assertSame('-unknown-', $language->txt('unknown', 3)); + } + + public function testAdapterPassesEnumAndNOnUnchanged(): void { $key = IdentifierTxtSetupTestIdentifier::KNOWN; $inner = $this->createMock(Language::class); - $inner->expects($this->once())->method('txt')->with($this->identicalTo($key), 'fallback_mod')->willReturn('Bekannt'); + $inner->expects($this->once())->method('txt')->with($this->identicalTo($key), 3)->willReturn('Bekannt'); $inner->expects($this->never())->method('loadLanguageModule'); $adapter = new class ($inner) extends LanguageLegacyInitialisationAdapter { public function __construct(private readonly Language $language) @@ -88,6 +98,6 @@ protected function getLegacyLanguageInstance(): Language } }; - $this->assertSame('Bekannt', $adapter->txt($key, 'fallback_mod')); + $this->assertSame('Bekannt', $adapter->txt($key, 3)); } } diff --git a/components/ILIAS/Language/tests/IdentifierTxtTest.php b/components/ILIAS/Language/tests/IdentifierTxtTest.php index a3c8f5f1a333..66cd7451f521 100644 --- a/components/ILIAS/Language/tests/IdentifierTxtTest.php +++ b/components/ILIAS/Language/tests/IdentifierTxtTest.php @@ -82,8 +82,8 @@ public function module(): string } /** - * ilLanguage::txt()/ntxt() with a LanguageIdentifier. Own process per test: CLIENT_DATA_DIR and ILIAS_ABSOLUTE_PATH - * cannot be redefined (see NtxtTest). + * ilLanguage::txt() with a LanguageIdentifier. Own process per test: CLIENT_DATA_DIR and ILIAS_ABSOLUTE_PATH + * cannot be redefined (see PluralTxtTest). */ #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] @@ -261,21 +261,11 @@ public function testMigratedModuleIsReadDirectlyWithoutLoadingAnything(LanguageI $language = $this->language([], $lang, $default); $before = self::state($language); - $this->assertSame($expected, $n === null ? $language->txt($key) : $language->ntxt($key, $n)); + $this->assertSame($expected, $language->txt($key, $n)); $this->assertSame($before, self::state($language), 'no module is loaded, nothing is assigned to a module'); } - public function testTheFallbackModuleIsIgnoredForAnIdentifier(): void - { - $this->forbidDatabaseQueries(); - $language = $this->language(); - - $this->assertSame('-missing-', $language->txt(IdentifierTxtTestMigAIdentifier::MISSING, 'legacy')); - $this->assertSame('-missing-', $language->ntxt(IdentifierTxtTestMigAIdentifier::MISSING, 2, 'legacy')); - $this->assertSame('Mig A', $language->txt(IdentifierTxtTestMigAIdentifier::SHARED, 'mig_b'), 'its own module, not the named one'); - } - private function forbidDatabaseQueries(): void { $db = $this->createMock(ilDBInterface::class); @@ -290,8 +280,8 @@ public function testTheOverlayWinsOverTheBuildForTextAndPluralForm(): void $language = $this->language(); $this->assertSame('Overlay A', $language->txt(IdentifierTxtTestMigAIdentifier::SHARED)); - $this->assertSame('Overlay-Eintrag', $language->ntxt(IdentifierTxtTestMigAIdentifier::ITEM, 1)); - $this->assertSame('Overlay-Einträge', $language->ntxt(IdentifierTxtTestMigAIdentifier::ITEM, 2)); + $this->assertSame('Overlay-Eintrag', $language->txt(IdentifierTxtTestMigAIdentifier::ITEM, 1)); + $this->assertSame('Overlay-Einträge', $language->txt(IdentifierTxtTestMigAIdentifier::ITEM, 2)); $this->assertSame('Mig B', $language->txt(IdentifierTxtTestMigBIdentifier::SHARED), 'another module is not affected'); } @@ -459,9 +449,9 @@ public function testAnEmptyIdentifierIsEmptyAndNotRecorded(): void self::enableUsageLog($language); $this->assertSame('', $language->txt(IdentifierTxtTestMigAIdentifier::EMPTY)); - $this->assertSame('', $language->ntxt(IdentifierTxtTestMigAIdentifier::EMPTY, 2)); + $this->assertSame('', $language->txt(IdentifierTxtTestMigAIdentifier::EMPTY, 2)); $this->assertSame('', $language->txt(IdentifierTxtTestMigAIdentifier::NUL)); - $this->assertSame('', $language->ntxt(IdentifierTxtTestMigAIdentifier::NUL, 1)); + $this->assertSame('', $language->txt(IdentifierTxtTestMigAIdentifier::NUL, 1)); $this->assertSame([], $language->getUsedTopics()); $this->assertSame([], $language->getUsedModules()); diff --git a/components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php b/components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php index c644fded978f..c2feed5e7d76 100644 --- a/components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php +++ b/components/ILIAS/Language/tests/MigratedAndLegacyModulesTest.php @@ -26,7 +26,7 @@ /** * ilLanguage with migrated modules (native gettext) mixed with legacy modules (lng_modules): the module loaded last wins - * for an identifier. Every test in its own process (CLIENT_DATA_DIR and ILIAS_ABSOLUTE_PATH cannot be redefined, see NtxtTest). + * for an identifier. Own process per test, see PluralTxtTest. */ #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] diff --git a/components/ILIAS/Language/tests/NtxtTest.php b/components/ILIAS/Language/tests/PluralTxtTest.php similarity index 83% rename from components/ILIAS/Language/tests/NtxtTest.php rename to components/ILIAS/Language/tests/PluralTxtTest.php index 1f1cb6ded727..709718150fb9 100644 --- a/components/ILIAS/Language/tests/NtxtTest.php +++ b/components/ILIAS/Language/tests/PluralTxtTest.php @@ -27,12 +27,12 @@ use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; /** - * ilLanguage::ntxt(), the plural-aware sibling of txt(). Separate processes: CLIENT_DATA_DIR/ILIAS_ABSOLUTE_PATH cannot + * ilLanguage::txt() with $n, the plural-aware form. Separate processes: CLIENT_DATA_DIR/ILIAS_ABSOLUTE_PATH cannot * be redefined and the migrated-file cache is a static property. */ #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] -class NtxtTest extends ilLanguageBaseTestCase +class PluralTxtTest extends ilLanguageBaseTestCase { private ?string $fixture_directory = null; @@ -69,7 +69,7 @@ protected function tearDown(): void private function contributeModule(string $module, TranslationCatalog $catalog, ?TranslationCatalog $overlay = null): LanguageFileDirectory { MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this); - $this->fixture_directory ??= __DIR__ . '/tmp-ntxt-fixtures-' . bin2hex(random_bytes(4)); + $this->fixture_directory ??= __DIR__ . '/tmp-plural-txt-fixtures-' . bin2hex(random_bytes(4)); $relative_path = 'components/ILIAS/Language/tests/' . basename($this->fixture_directory) . '/'; if ($overlay !== null) { @@ -142,10 +142,10 @@ public function testSelectsTheFormThePluralRuleOfTheLanguagePicksForN(): void $language = $this->buildLanguage(); $language->loadLanguageModule('ntxt'); - $this->assertSame('Eintrag', $language->ntxt('item', 1)); - $this->assertSame('Einträge', $language->ntxt('item', 0)); - $this->assertSame('Einträge', $language->ntxt('item', 5)); - $this->assertSame('Einträge', $language->ntxt('item', -5), 'a negative n counts like its absolute value'); + $this->assertSame('Eintrag', $language->txt('item', 1)); + $this->assertSame('Einträge', $language->txt('item', 0)); + $this->assertSame('Einträge', $language->txt('item', 5)); + $this->assertSame('Einträge', $language->txt('item', -5), 'a negative n counts like its absolute value'); } public function testFallsBackToTxtForAnOrdinarySingularIdentifier(): void @@ -157,27 +157,15 @@ public function testFallsBackToTxtForAnOrdinarySingularIdentifier(): void $language = $this->buildLanguage(); $language->loadLanguageModule('ntxt'); - $this->assertSame('Einfach', $language->ntxt('plain', 1)); - $this->assertSame('Einfach', $language->ntxt('plain', 5)); + $this->assertSame('Einfach', $language->txt('plain', 1)); + $this->assertSame('Einfach', $language->txt('plain', 5)); } public function testFallsBackToTxtForATopicThatWasNeverLoadedAtAll(): void { $language = $this->buildLanguage(); - $this->assertSame($language->txt('unknown_topic'), $language->ntxt('unknown_topic', 5)); - } - - public function testServesThePluralFormFromTheFallbackModuleWithoutLoadingIt(): void - { - $catalog = self::pluralCatalog('ntxt', 'nplurals=2; plural=(n != 1);', 'item', ['Eintrag', 'Einträge']); - $this->stubUsageLogDependencies(); - $this->registerDirectoryManager($this->contributeModule('ntxt', $catalog)); - - $language = $this->buildLanguage(); - - $this->assertSame('Eintrag', $language->ntxt('item', 1, 'ntxt')); - $this->assertSame('Einträge', $language->ntxt('item', 5, 'ntxt')); + $this->assertSame($language->txt('unknown_topic'), $language->txt('unknown_topic', 5)); } /** Without a "Plural-Forms" header the Germanic rule (n==1 -> form 0, else 1) applies instead of failing. */ @@ -191,8 +179,8 @@ public function testUsesTheGermanicRuleWhenThePluralFormsHeaderIsMissing(): void $language = $this->buildLanguage(); $language->loadLanguageModule('ntxt'); - $this->assertSame('Eintrag', $language->ntxt('item', 1)); - $this->assertSame('Einträge', $language->ntxt('item', 5)); + $this->assertSame('Eintrag', $language->txt('item', 1)); + $this->assertSame('Einträge', $language->txt('item', 5)); } /** An empty selected form falls back to txt() rather than returning an empty string. */ @@ -205,12 +193,12 @@ public function testFallsBackToTxtWhenTheSelectedFormIsEmpty(): void $language = $this->buildLanguage(); $language->loadLanguageModule('ntxt'); - // an empty default form falls back to form 0, which txt() and the n=5 fallback of ntxt() both serve + // an empty default form falls back to form 0, which txt() and the n=5 fallback with $n both serve $this->assertSame('Eintrag', $language->txt('item')); - $this->assertSame('Eintrag', $language->ntxt('item', 5)); + $this->assertSame('Eintrag', $language->txt('item', 5)); } - /** A later module overwriting the identifier with a plain value must win for ntxt() too. */ + /** A later module overwriting the identifier with a plain value must win with $n too. */ public function testALaterModuleOverwritingTheIdentifierWinsOverTheEarlierPluralForms(): void { $plural = self::pluralCatalog('ntxt_a', 'nplurals=2; plural=(n != 1);', 'shared', ['Eintrag', 'Einträge']); @@ -225,7 +213,7 @@ public function testALaterModuleOverwritingTheIdentifierWinsOverTheEarlierPlural $language->loadLanguageModule('ntxt_a'); $language->loadLanguageModule('ntxt_b'); - $this->assertSame('Überschrieben', $language->ntxt('shared', 5)); + $this->assertSame('Überschrieben', $language->txt('shared', 5)); } public function testAnOverlayReplacingThePluralFormsIsUsedForFormSelection(): void @@ -238,7 +226,7 @@ public function testAnOverlayReplacingThePluralFormsIsUsedForFormSelection(): vo $language = $this->buildLanguage(); $language->loadLanguageModule('ntxt'); - $this->assertSame('Datensatz', $language->ntxt('item', 1)); - $this->assertSame('Datensätze', $language->ntxt('item', 5)); + $this->assertSame('Datensatz', $language->txt('item', 1)); + $this->assertSame('Datensätze', $language->txt('item', 5)); } } diff --git a/components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php b/components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php index 52cff0c96cd4..14c4255c9c84 100644 --- a/components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php +++ b/components/ILIAS/Language/tests/PoMigrationLoadLanguageModuleTest.php @@ -362,20 +362,6 @@ public function testTxtlngReadsTheMigratedMoFileForALanguageOtherThanTheCurrentS $this->assertSame('Nutzungsvereinbarung', $language->txtlng('tos', 'tos_agreement', 'de')); } - /** txt()'s fallback-module branch also goes through _lookupEntry(); lang_key === lang_default takes the "default language" branch. */ - public function testTxtsFallbackModuleParameterReadsTheMigratedMoFileTooWhenTheTopicIsNotAlreadyLoaded(): void - { - $this->ensureRealTosDeMoFileExists(); - $this->stubUsageLogDependencies(); - $this->registerDirectoryManager( - new ComponentLanguageFileDirectory(new \ILIAS\TermsOfService(), 'tos') - ); - - $language = $this->buildLanguageWithoutRunningConstructor('de'); - - $this->assertSame('Nutzungsvereinbarung', $language->txt('tos_agreement', 'tos')); - } - /** * The build is checked before $cached_modules: ilCachedLanguage is always active and a migrated module keeps its * lng_modules row (dual-write), so the old order never read the build in production. diff --git a/components/ILIAS/Language/tests/ilPluginLanguageTest.php b/components/ILIAS/Language/tests/ilPluginLanguageTest.php index 526f249ac6c1..ecdf5f1ba778 100644 --- a/components/ILIAS/Language/tests/ilPluginLanguageTest.php +++ b/components/ILIAS/Language/tests/ilPluginLanguageTest.php @@ -247,4 +247,36 @@ public function testABrokenPoSkipsOnlyThatLanguageAndOthersAreStillUpdated(): vo $this->assertSame('Hello', $captured['comp_slot_ptest#:#comp_slot_ptest_greeting'], 'the valid "en" .po is still applied'); $this->assertNotEmpty(array_filter($warnings, static fn(string $w): bool => str_contains($w, 'cannot be read'))); } + + public function testTxtFallsBackToTheDefaultLanguageForAKeyMissingInTheUserLanguage(): void + { + $plugin_language = new ilPluginLanguage($this->createPluginInfo('ptest')); + $lng = $this->createStub(ilLanguage::class); + $lng->method('txt')->willReturnCallback(static fn(string $key): string => $key === 'comp_slot_ptest_known' ? 'Bekannt' : '-' . $key . '-'); + $lng->method('getDefaultLanguage')->willReturn('en'); + $this->setGlobalVariable('lng', $lng); + $this->setGlobalVariable('ilClientIniFile', $this->createStub(ilIniFile::class)); + $statement = $this->createStub(ilDBStatement::class); + $db = $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn($value): string => "'" . (string) $value . "'"); + $queries = []; + $db->method('query')->willReturnCallback(static function (string $sql) use (&$queries, $statement) { + $queries[] = $sql; + + return $statement; + }); + // answers only the lookup of the default language for exactly this module and key + $db->method('fetchAssoc')->willReturnCallback(static function () use (&$queries): ?array { + return str_contains(end($queries), "module = 'comp_slot_ptest' AND lang_key = 'en' AND identifier = 'comp_slot_ptest_missing'") + ? ['value' => 'Missing en'] + : null; + }); + $this->setGlobalVariable('ilDB', $db); + + $this->assertSame('Bekannt', $plugin_language->txt('known')); + $this->assertSame([], $queries, 'a key present in the user language is not looked up again'); + $this->assertSame('Missing en', $plugin_language->txt('missing')); + $this->assertCount(1, $queries); + $this->assertSame('-comp_slot_ptest_none-', $plugin_language->txt('none')); + } } diff --git a/components/ILIAS/Language/tools/language-identifier-enum/README.md b/components/ILIAS/Language/tools/language-identifier-enum/README.md index 62fbc2876e05..be113591fdac 100644 --- a/components/ILIAS/Language/tools/language-identifier-enum/README.md +++ b/components/ILIAS/Language/tools/language-identifier-enum/README.md @@ -2,12 +2,12 @@ `generate_language_identifier_enum.php` generates the enum of the language identifiers of one module from the module's `.pot` - an implementation of `ILIAS\Language\LanguageIdentifier`, to be passed to -`txt()` or `ntxt()` (see the README of the Language component) - and checks an existing enum +`txt()` (see the README of the Language component) - and checks an existing enum against the `.pot` (`--check`, for CI). ```php -$this->lng->txt(PollLanguageIdentifier::POLL_ANSWERS); // no loadLanguageModule('poll') needed -$this->lng->ntxt(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count +$this->lng->txt(PollLanguageIdentifier::POLL_ANSWERS); // no loadLanguageModule('poll') needed +$this->lng->txt(PollLanguageIdentifier::POLL_ANSWERS, $count); // plural form for $count ``` The enum is generated, not written by hand: it lists exactly the msgids of the `.pot`, so a key diff --git a/docs/development/language.md b/docs/development/language.md index ec3e675f2a7e..c224d9931c3e 100755 --- a/docs/development/language.md +++ b/docs/development/language.md @@ -77,7 +77,7 @@ that merges local changes into the global language file writes the local changes into their shipped `.po` files. Each installation keeps its local changes of these modules in the client data directory, which is why the ILIAS setup MUST be run as the web server user. Plural forms are available for these modules through -`ilLanguage::ntxt()`. See +`txt($a_topic, $n)`. See [the Language component](../../components/ILIAS/Language/README.md#translations-maintained-in-po-files) for details. From b79e04958dd933b660d2544c4a9c3d2201bb7260 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:31 +0200 Subject: [PATCH 060/103] UI: Follow the new signature of `ilLanguage::txt()` in `LanguageMock` `ilLanguage::txt()` now takes `($a_topic, ?int $n = null)`; the mock overriding it must match. --- components/ILIAS/UI/tests/Base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/UI/tests/Base.php b/components/ILIAS/UI/tests/Base.php index 0412bff2ac42..8c8c2bdd0d01 100755 --- a/components/ILIAS/UI/tests/Base.php +++ b/components/ILIAS/UI/tests/Base.php @@ -179,7 +179,7 @@ public function __construct() { } - public function txt($a_topic, $a_default_lang_fallback_mod = ""): string + public function txt($a_topic, ?int $n = null): string { $this->requested[] = $a_topic; return $a_topic; From 99d60a4db0724cd758c8766dd1d3c8977a7e1582 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 14:03:32 +0200 Subject: [PATCH 061/103] Refinery: Follow the new signature of `Language::txt()` in the test doubles `Language::txt()` now takes `(string|LanguageIdentifier $a_topic, ?int $n = null)` without fallback module; the test doubles implementing the interface must match. --- .../ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php | 2 +- .../tests/Password/Constraint/PasswordContraintsTest.php | 2 +- components/ILIAS/Refinery/tests/TestCase.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php b/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php index 85125371e659..f7272d3bd394 100755 --- a/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php +++ b/components/ILIAS/Refinery/tests/Logical/Constraint/LogicalOrTest.php @@ -96,7 +96,7 @@ public function testWithProblemBuilder(LogicalOr $constraint, mixed $okValue, mi public static function constraintsProvider(): array { $language = new class () implements \ILIAS\Language\Language { - public function txt(string|\ILIAS\Language\LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|\ILIAS\Language\LanguageIdentifier $a_topic, ?int $n = null): string { return is_string($a_topic) ? $a_topic : (string) $a_topic->value; } diff --git a/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php b/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php index 8904e846dbc1..8e7ea1b6585a 100755 --- a/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php +++ b/components/ILIAS/Refinery/tests/Password/Constraint/PasswordContraintsTest.php @@ -32,7 +32,7 @@ class PasswordContraintsTest extends TestCase public static function constraintsProvider(): array { $lng = new class () implements \ILIAS\Language\Language { - public function txt(string|\ILIAS\Language\LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ""): string + public function txt(string|\ILIAS\Language\LanguageIdentifier $a_topic, ?int $n = null): string { return is_string($a_topic) ? $a_topic : (string) $a_topic->value; } diff --git a/components/ILIAS/Refinery/tests/TestCase.php b/components/ILIAS/Refinery/tests/TestCase.php index 7d7c0eb13058..438922b055dc 100755 --- a/components/ILIAS/Refinery/tests/TestCase.php +++ b/components/ILIAS/Refinery/tests/TestCase.php @@ -36,7 +36,7 @@ public function __construct() { } - public function txt(string|LanguageIdentifier $a_topic, string $a_default_lang_fallback_mod = ''): string + public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string { $this->requested[] = $a_topic; return is_string($a_topic) ? $a_topic : (string) $a_topic->value; From 08c7ff2fe724a31b7164a6583065d5495874621d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 13 Oct 2026 13:56:21 +0200 Subject: [PATCH 062/103] Language: Serve the translations from PO files Language contributes its lang directory and ships the `lng` module as .pot/.po files, converted with convert_module_to_po.php --remove-from-lang: it no longer exists in the global language files. --- components/ILIAS/Language/Language.php | 5 ++ components/ILIAS/Language/lang/lng.pot | 48 ++++++++++++++ components/ILIAS/Language/lang/lng_ar.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_bg.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_cs.po | 62 ++++++++++++++++++ components/ILIAS/Language/lang/lng_da.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_de.po | 51 +++++++++++++++ components/ILIAS/Language/lang/lng_el.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_en.po | 51 +++++++++++++++ components/ILIAS/Language/lang/lng_es.po | 60 +++++++++++++++++ components/ILIAS/Language/lang/lng_et.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_fa.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_fr.po | 61 +++++++++++++++++ components/ILIAS/Language/lang/lng_hr.po | 62 ++++++++++++++++++ components/ILIAS/Language/lang/lng_hu.po | 60 +++++++++++++++++ components/ILIAS/Language/lang/lng_it.po | 61 +++++++++++++++++ components/ILIAS/Language/lang/lng_ja.po | 60 +++++++++++++++++ components/ILIAS/Language/lang/lng_ka.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_lt.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_nl.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_pl.po | 62 ++++++++++++++++++ components/ILIAS/Language/lang/lng_pt.po | 62 ++++++++++++++++++ components/ILIAS/Language/lang/lng_ro.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_ru.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_sk.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_sl.po | 62 ++++++++++++++++++ components/ILIAS/Language/lang/lng_sq.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_sr.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_sv.po | 61 +++++++++++++++++ components/ILIAS/Language/lang/lng_tr.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_uk.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_vi.po | 65 +++++++++++++++++++ components/ILIAS/Language/lang/lng_zh.po | 65 +++++++++++++++++++ .../tests/LanguageComponentGraphTest.php | 23 ++++++- lang/ilias_ar.lang | 5 -- lang/ilias_bg.lang | 5 -- lang/ilias_cs.lang | 5 -- lang/ilias_da.lang | 5 -- lang/ilias_de.lang | 14 ---- lang/ilias_el.lang | 5 -- lang/ilias_en.lang | 14 ---- lang/ilias_es.lang | 5 -- lang/ilias_et.lang | 5 -- lang/ilias_fa.lang | 5 -- lang/ilias_fr.lang | 5 -- lang/ilias_hr.lang | 5 -- lang/ilias_hu.lang | 5 -- lang/ilias_it.lang | 5 -- lang/ilias_ja.lang | 5 -- lang/ilias_ka.lang | 5 -- lang/ilias_lt.lang | 5 -- lang/ilias_nl.lang | 5 -- lang/ilias_pl.lang | 5 -- lang/ilias_pt.lang | 5 -- lang/ilias_ro.lang | 5 -- lang/ilias_ru.lang | 5 -- lang/ilias_sk.lang | 5 -- lang/ilias_sl.lang | 5 -- lang/ilias_sq.lang | 5 -- lang/ilias_sr.lang | 5 -- lang/ilias_sv.lang | 5 -- lang/ilias_tr.lang | 5 -- lang/ilias_uk.lang | 5 -- lang/ilias_vi.lang | 5 -- lang/ilias_zh.lang | 5 -- 65 files changed, 2018 insertions(+), 176 deletions(-) create mode 100644 components/ILIAS/Language/lang/lng.pot create mode 100644 components/ILIAS/Language/lang/lng_ar.po create mode 100644 components/ILIAS/Language/lang/lng_bg.po create mode 100644 components/ILIAS/Language/lang/lng_cs.po create mode 100644 components/ILIAS/Language/lang/lng_da.po create mode 100644 components/ILIAS/Language/lang/lng_de.po create mode 100644 components/ILIAS/Language/lang/lng_el.po create mode 100644 components/ILIAS/Language/lang/lng_en.po create mode 100644 components/ILIAS/Language/lang/lng_es.po create mode 100644 components/ILIAS/Language/lang/lng_et.po create mode 100644 components/ILIAS/Language/lang/lng_fa.po create mode 100644 components/ILIAS/Language/lang/lng_fr.po create mode 100644 components/ILIAS/Language/lang/lng_hr.po create mode 100644 components/ILIAS/Language/lang/lng_hu.po create mode 100644 components/ILIAS/Language/lang/lng_it.po create mode 100644 components/ILIAS/Language/lang/lng_ja.po create mode 100644 components/ILIAS/Language/lang/lng_ka.po create mode 100644 components/ILIAS/Language/lang/lng_lt.po create mode 100644 components/ILIAS/Language/lang/lng_nl.po create mode 100644 components/ILIAS/Language/lang/lng_pl.po create mode 100644 components/ILIAS/Language/lang/lng_pt.po create mode 100644 components/ILIAS/Language/lang/lng_ro.po create mode 100644 components/ILIAS/Language/lang/lng_ru.po create mode 100644 components/ILIAS/Language/lang/lng_sk.po create mode 100644 components/ILIAS/Language/lang/lng_sl.po create mode 100644 components/ILIAS/Language/lang/lng_sq.po create mode 100644 components/ILIAS/Language/lang/lng_sr.po create mode 100644 components/ILIAS/Language/lang/lng_sv.po create mode 100644 components/ILIAS/Language/lang/lng_tr.po create mode 100644 components/ILIAS/Language/lang/lng_uk.po create mode 100644 components/ILIAS/Language/lang/lng_vi.po create mode 100644 components/ILIAS/Language/lang/lng_zh.po diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 01ef18a20387..256093c9f975 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -24,6 +24,7 @@ use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; use ILIAS\Language\Activities\InstallLanguage; use ILIAS\Language\Activities\UpdateLanguage; use ILIAS\Language\Activities\UninstallLanguage; @@ -217,5 +218,9 @@ public function init( $contribute[User\Settings\UserSettings::class] = fn() => new Language\UserSettings\Settings(); + $contribute[LanguageFileDirectory::class] = fn() => new ComponentLanguageFileDirectory( + $this, + 'lng' + ); } } diff --git a/components/ILIAS/Language/lang/lng.pot b/components/ILIAS/Language/lang/lng.pot new file mode 100644 index 000000000000..9deac9a3f9c6 --- /dev/null +++ b/components/ILIAS/Language/lang/lng.pot @@ -0,0 +1,48 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"MIME-Version: 1.0\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "" + +msgid "lng_disable_language_detection" +msgstr "" + +msgid "lng_download_deprecated" +msgstr "" + +msgid "lng_enable_language_detection" +msgstr "" + +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "" + +msgid "lng_markup_not_allowed" +msgstr "" + +msgid "lng_merge_database_not_updated" +msgstr "" + +msgid "lng_merge_po_not_written" +msgstr "" + +msgid "lng_merged_po_modules" +msgstr "" + +msgid "lng_not_translated" +msgstr "" + +msgid "lng_po_overlay_not_written" +msgstr "" + +msgid "lng_po_overlay_not_written_languages" +msgstr "" + +msgid "lng_shipped_po_unreadable_fallback" +msgstr "" + +msgid "lng_switch_language_detection" +msgstr "" diff --git a/components/ILIAS/Language/lang/lng_ar.po b/components/ILIAS/Language/lang/lng_ar.po new file mode 100644 index 000000000000..f741504c2f26 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_ar.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ar\n" +"Language-Team: Arabic\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_bg.po b/components/ILIAS/Language/lang/lng_bg.po new file mode 100644 index 000000000000..6f8b71c85b78 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_bg.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: bg\n" +"Language-Team: Bulgarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_cs.po b/components/ILIAS/Language/lang/lng_cs.po new file mode 100644 index 000000000000..78ad7121e4de --- /dev/null +++ b/components/ILIAS/Language/lang/lng_cs.po @@ -0,0 +1,62 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: cs\n" +"Language-Team: Czech\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +msgid "lng_disable_language_detection" +msgstr "Vypnout detekci jazyka" + +msgid "lng_download_deprecated" +msgstr "Stáhnout seznam nepoužívaných" + +msgid "lng_enable_language_detection" +msgstr "Zapnout detekci jazyka" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_da.po b/components/ILIAS/Language/lang/lng_da.po new file mode 100644 index 000000000000..521827f947ec --- /dev/null +++ b/components/ILIAS/Language/lang/lng_da.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: da\n" +"Language-Team: Danish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_de.po b/components/ILIAS/Language/lang/lng_de.po new file mode 100644 index 000000000000..ddbbcceebfa0 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_de.po @@ -0,0 +1,51 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: de\n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "Automatische Spracherkennung" + +msgid "lng_disable_language_detection" +msgstr "Automatische Spracherkennung deaktivieren" + +msgid "lng_download_deprecated" +msgstr "Veraltete Einträge herunterladen" + +msgid "lng_enable_language_detection" +msgstr "Automatische Spracherkennung aktivieren" + +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "Die lokalen Änderungen wurden nicht gelöscht: Die ausgelieferten PO-Dateien folgender Module können nicht gelesen werden: %s. Details stehen im Log." + +msgid "lng_markup_not_allowed" +msgstr "Nicht erlaubtes Markup in folgenden Einträgen: %s" + +msgid "lng_merge_database_not_updated" +msgstr "Die ausgelieferten PO-Dateien folgender Module wurden geschrieben, ihre Einträge in der Datenbank konnten aber nicht aktualisiert werden (siehe Log): %s" + +msgid "lng_merge_po_not_written" +msgstr "Die lokalen Änderungen folgender Module konnten nicht in ihre ausgelieferten PO-Dateien übernommen werden (siehe Log): %s" + +msgid "lng_merged_po_modules" +msgstr "Die lokalen Änderungen folgender Module wurden in ihre ausgelieferten PO-Dateien übernommen: %s" + +msgid "lng_not_translated" +msgstr "Nicht übersetzt" + +msgid "lng_po_overlay_not_written" +msgstr "Die Änderungen wurden in der Datenbank gespeichert, aber die PO/MO-Dateien folgender über PO-Dateien gepflegter Module konnten nicht geschrieben werden: %s. Für diese Module werden weiterhin die bisherigen Texte angezeigt. Bitte prüfen Sie, ob der Webserver-Nutzer Schreibrechte auf das Verzeichnis \"lang\" im Client-Datenverzeichnis hat. Details stehen im Log." + +msgid "lng_po_overlay_not_written_languages" +msgstr "Die Sprachdaten wurden in der Datenbank gespeichert, aber für folgende Sprachen konnten die PO/MO-Dateien der über PO-Dateien gepflegten Module nicht geschrieben werden: %s. Für diese Module werden weiterhin die bisherigen Texte angezeigt. Bitte prüfen Sie, ob der Webserver-Nutzer Schreibrechte auf das Verzeichnis \"lang\" im Client-Datenverzeichnis hat. Details stehen im Log." + +msgid "lng_shipped_po_unreadable_fallback" +msgstr "Die ausgelieferten PO-Dateien folgender Module können nicht gelesen werden: %s. Als Standardwerte werden für diese Module ersatzweise die möglicherweise veralteten Einträge der Sprachdatei (.lang) angezeigt. Details stehen im Log." + +msgid "lng_switch_language_detection" +msgstr "Automatische Spracherkennung umschalten" diff --git a/components/ILIAS/Language/lang/lng_el.po b/components/ILIAS/Language/lang/lng_el.po new file mode 100644 index 000000000000..0a5589300003 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_el.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: el\n" +"Language-Team: Greek\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_en.po b/components/ILIAS/Language/lang/lng_en.po new file mode 100644 index 000000000000..d704ede693b7 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_en.po @@ -0,0 +1,51 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" +"Language-Team: English\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "Language Detection" + +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +msgid "lng_not_translated" +msgstr "Not translated" + +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_es.po b/components/ILIAS/Language/lang/lng_es.po new file mode 100644 index 000000000000..c36b1285c3f3 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_es.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: es\n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "Detección de idioma" + +msgid "lng_disable_language_detection" +msgstr "Desactivar detección de idioma" + +msgid "lng_download_deprecated" +msgstr "Descargar lista de obsoletos" + +msgid "lng_enable_language_detection" +msgstr "Activar detección de idioma" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +msgid "lng_switch_language_detection" +msgstr "Cambiar detección de idioma" diff --git a/components/ILIAS/Language/lang/lng_et.po b/components/ILIAS/Language/lang/lng_et.po new file mode 100644 index 000000000000..a6523e8b3af8 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_et.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: et\n" +"Language-Team: Estonian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_fa.po b/components/ILIAS/Language/lang/lng_fa.po new file mode 100644 index 000000000000..2ab280e2ed65 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_fa.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fa\n" +"Language-Team: Persian (Farsi)\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_fr.po b/components/ILIAS/Language/lang/lng_fr.po new file mode 100644 index 000000000000..7352af9d4f83 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_fr.po @@ -0,0 +1,61 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fr\n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "Détection de la langue" + +msgid "lng_disable_language_detection" +msgstr "Désactiver la Détection de la Langue" + +msgid "lng_download_deprecated" +msgstr "Télécharger la liste obsolète" + +msgid "lng_enable_language_detection" +msgstr "Activer la détection de la langue" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_hr.po b/components/ILIAS/Language/lang/lng_hr.po new file mode 100644 index 000000000000..140fe7c854de --- /dev/null +++ b/components/ILIAS/Language/lang/lng_hr.po @@ -0,0 +1,62 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hr\n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +msgid "lng_disable_language_detection" +msgstr "Onemogući otkrivanje jezika" + +msgid "lng_download_deprecated" +msgstr "Preuzmi zastarjeli popis" + +msgid "lng_enable_language_detection" +msgstr "Omogući otkrivanje jezika" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_hu.po b/components/ILIAS/Language/lang/lng_hu.po new file mode 100644 index 000000000000..8ea6161193e0 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_hu.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: hu\n" +"Language-Team: Hungarian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "Nyelvfelismerés" + +msgid "lng_disable_language_detection" +msgstr "Nyelvfelismerés tiltása" + +msgid "lng_download_deprecated" +msgstr "Elavultak listájának letöltése" + +msgid "lng_enable_language_detection" +msgstr "Nyelvfelismerés engedélyezése" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +msgid "lng_switch_language_detection" +msgstr "Nyelvfelismerés váltása" diff --git a/components/ILIAS/Language/lang/lng_it.po b/components/ILIAS/Language/lang/lng_it.po new file mode 100644 index 000000000000..7dd29baa1743 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_it.po @@ -0,0 +1,61 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: it\n" +"Language-Team: Italian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "Riconoscimento della lingua" + +msgid "lng_disable_language_detection" +msgstr "Disattiva riconoscimento lingua" + +msgid "lng_download_deprecated" +msgstr "Scarica elenco deprecato" + +msgid "lng_enable_language_detection" +msgstr "Abilita il riconoscimento della lingua" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_ja.po b/components/ILIAS/Language/lang/lng_ja.po new file mode 100644 index 000000000000..947ae2b31718 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_ja.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ja\n" +"Language-Team: Japanese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "言語検知" + +msgid "lng_disable_language_detection" +msgstr "言語検知を無効化" + +msgid "lng_download_deprecated" +msgstr "ダウンロード非推奨リスト" + +msgid "lng_enable_language_detection" +msgstr "言語検知を有効化" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +msgid "lng_switch_language_detection" +msgstr "言語検知を切り替える" diff --git a/components/ILIAS/Language/lang/lng_ka.po b/components/ILIAS/Language/lang/lng_ka.po new file mode 100644 index 000000000000..093a2b03e7a0 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_ka.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ka\n" +"Language-Team: Georgian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_lt.po b/components/ILIAS/Language/lang/lng_lt.po new file mode 100644 index 000000000000..f38230228e0a --- /dev/null +++ b/components/ILIAS/Language/lang/lng_lt.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: lt\n" +"Language-Team: Lithuanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && (n % 100 < 11 || n % 100 > 19)) ? 0 : ((n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_nl.po b/components/ILIAS/Language/lang/lng_nl.po new file mode 100644 index 000000000000..2564e215835f --- /dev/null +++ b/components/ILIAS/Language/lang/lng_nl.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: nl\n" +"Language-Team: Dutch\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_pl.po b/components/ILIAS/Language/lang/lng_pl.po new file mode 100644 index 000000000000..e83680b60e88 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_pl.po @@ -0,0 +1,62 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pl\n" +"Language-Team: Polish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +msgid "lng_disable_language_detection" +msgstr "Wyłącz funkcję automatycznego rozpoznawania języka" + +msgid "lng_download_deprecated" +msgstr "Pobierz starsze wpisy" + +msgid "lng_enable_language_detection" +msgstr "Aktywuj funkcję automatycznego rozpoznawania języka" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_pt.po b/components/ILIAS/Language/lang/lng_pt.po new file mode 100644 index 000000000000..09e3e1af7d30 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_pt.po @@ -0,0 +1,62 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pt\n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +msgid "lng_disable_language_detection" +msgstr "Desativar deteção de idioma" + +msgid "lng_download_deprecated" +msgstr "Descarregar lista obsoleta" + +msgid "lng_enable_language_detection" +msgstr "Ativar deteção de idioma" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_ro.po b/components/ILIAS/Language/lang/lng_ro.po new file mode 100644 index 000000000000..9f2fce78f03b --- /dev/null +++ b/components/ILIAS/Language/lang/lng_ro.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ro\n" +"Language-Team: Romanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n == 0 || n != 1 && n % 100 >= 1 && n % 100 <= 19) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_ru.po b/components/ILIAS/Language/lang/lng_ru.po new file mode 100644 index 000000000000..60b7c9e69474 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_ru.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: ru\n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_sk.po b/components/ILIAS/Language/lang/lng_sk.po new file mode 100644 index 000000000000..902ec98cc250 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_sk.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sk\n" +"Language-Team: Slovak\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n >= 2 && n <= 4) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_sl.po b/components/ILIAS/Language/lang/lng_sl.po new file mode 100644 index 000000000000..463c628be6cb --- /dev/null +++ b/components/ILIAS/Language/lang/lng_sl.po @@ -0,0 +1,62 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sl\n" +"Language-Team: Slovenian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=4; plural=(n % 100 == 1) ? 0 : ((n % 100 == 2) ? 1 : ((n % 100 == 3 || n % 100 == 4) ? 2 : 3));\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +msgid "lng_disable_language_detection" +msgstr "Onemogoči samodejno zaznavanje jezika" + +msgid "lng_download_deprecated" +msgstr "Prenesi zastarele vnose" + +msgid "lng_enable_language_detection" +msgstr "Aktiviraj samodejno zaznavanje jezika" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_sq.po b/components/ILIAS/Language/lang/lng_sq.po new file mode 100644 index 000000000000..d249a43fc593 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_sq.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sq\n" +"Language-Team: Albanian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_sr.po b/components/ILIAS/Language/lang/lng_sr.po new file mode 100644 index 000000000000..edec680660f3 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_sr.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sr\n" +"Language-Team: Serbian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_sv.po b/components/ILIAS/Language/lang/lng_sv.po new file mode 100644 index 000000000000..0531bc2085ca --- /dev/null +++ b/components/ILIAS/Language/lang/lng_sv.po @@ -0,0 +1,61 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: sv\n" +"Language-Team: Swedish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +msgid "language_detection" +msgstr "Automatisk taligenkänning" + +msgid "lng_disable_language_detection" +msgstr "Avaktivera automatisk taligenkänning" + +msgid "lng_download_deprecated" +msgstr "Ladda ner obsoleta poster" + +msgid "lng_enable_language_detection" +msgstr "Aktivera automatisk taligenkänning" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_tr.po b/components/ILIAS/Language/lang/lng_tr.po new file mode 100644 index 000000000000..bd372dd37214 --- /dev/null +++ b/components/ILIAS/Language/lang/lng_tr.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: tr\n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_uk.po b/components/ILIAS/Language/lang/lng_uk.po new file mode 100644 index 000000000000..ae8cdcdb1e4d --- /dev/null +++ b/components/ILIAS/Language/lang/lng_uk.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: uk\n" +"Language-Team: Ukrainian\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_vi.po b/components/ILIAS/Language/lang/lng_vi.po new file mode 100644 index 000000000000..e34cb78caacc --- /dev/null +++ b/components/ILIAS/Language/lang/lng_vi.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: vi\n" +"Language-Team: Vietnamese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/lang/lng_zh.po b/components/ILIAS/Language/lang/lng_zh.po new file mode 100644 index 000000000000..7ba4b10ca53b --- /dev/null +++ b/components/ILIAS/Language/lang/lng_zh.po @@ -0,0 +1,65 @@ +msgid "" +msgstr "" +"Content-Transfer-Encoding: 8bit\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: zh\n" +"Language-Team: Chinese\n" +"MIME-Version: 1.0\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: lng\n" + +#, fuzzy +msgid "language_detection" +msgstr "Language Detection" + +#, fuzzy +msgid "lng_disable_language_detection" +msgstr "Disable Language Detection" + +#, fuzzy +msgid "lng_download_deprecated" +msgstr "Download Deprecated List" + +#, fuzzy +msgid "lng_enable_language_detection" +msgstr "Enable Language Detection" + +#, fuzzy +msgid "lng_error_clear_shipped_po_unreadable" +msgstr "The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details." + +#, fuzzy +msgid "lng_markup_not_allowed" +msgstr "Markup that is not allowed in the following entries: %s" + +#, fuzzy +msgid "lng_merge_database_not_updated" +msgstr "The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s" + +#, fuzzy +msgid "lng_merge_po_not_written" +msgstr "The local changes of the following modules could not be merged into their shipped PO files (see the log): %s" + +#, fuzzy +msgid "lng_merged_po_modules" +msgstr "The local changes of the following modules were merged into their shipped PO files: %s" + +#, fuzzy +msgid "lng_not_translated" +msgstr "Not translated" + +#, fuzzy +msgid "lng_po_overlay_not_written" +msgstr "The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_po_overlay_not_written_languages" +msgstr "The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory \"lang\" in the client data directory. See the log for details." + +#, fuzzy +msgid "lng_shipped_po_unreadable_fallback" +msgstr "The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details." + +#, fuzzy +msgid "lng_switch_language_detection" +msgstr "Switch Language Detection" diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index e0fce4b0c06e..02418ab79346 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -27,6 +27,7 @@ use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Activities\SetLanguageDetectionEnabled; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; @@ -174,9 +175,12 @@ public function testContributeEntriesResolveToExpectedConcreteClasses(): void { $this->initComponent(); - $directories = $this->contributionsOf(LanguageFileDirectory::class); - self::assertCount(1, $directories); - self::assertInstanceOf(MainLanguageFileDirectory::class, $directories[0]); + $directories = array_map( + static fn(object $directory): string => $directory::class, + $this->contributionsOf(LanguageFileDirectory::class) + ); + sort($directories); + self::assertSame([ComponentLanguageFileDirectory::class, MainLanguageFileDirectory::class], $directories); $this->contributedSetupAgent(); @@ -202,6 +206,19 @@ public function testContributeEntriesResolveToExpectedConcreteClasses(): void self::assertInstanceOf(UserSettingsSettings::class, $user_settings[0]); } + public function testTheLngModuleIsContributedAsComponentLanguageFileDirectory(): void + { + $this->initComponent(); + + $lng = array_values(array_filter( + $this->contributionsOf(LanguageFileDirectory::class), + static fn(object $d): bool => $d instanceof ComponentLanguageFileDirectory && $d->getPrefix() === 'lng' + )); + + self::assertCount(1, $lng, 'exactly one ComponentLanguageFileDirectory with prefix "lng" expected'); + self::assertSame('components/ILIAS/Language/lang/', $lng[0]->getPath()); + } + /** The Setup agent gets UpdateLanguage's Setup instance, not the GUI's: without provider it re-applies no plugin language files. */ public function testTheSetupAgentGetsTheSetupInstanceOfUpdateLanguage(): void { diff --git a/lang/ilias_ar.lang b/lang/ilias_ar.lang index 443d0e9f7993..35e5aff0cb04 100644 --- a/lang/ilias_ar.lang +++ b/lang/ilias_ar.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_bg.lang b/lang/ilias_bg.lang index bd56a92ec425..0a189f13ad83 100644 --- a/lang/ilias_bg.lang +++ b/lang/ilias_bg.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_cs.lang b/lang/ilias_cs.lang index a71b63e2e272..da1141fdfdb6 100644 --- a/lang/ilias_cs.lang +++ b/lang/ilias_cs.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titulky byly uloženy. -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Vypnout detekci jazyka -lng#:#lng_download_deprecated#:#Stáhnout seznam nepoužívaných -lng#:#lng_enable_language_detection#:#Zapnout detekci jazyka -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Protokol konzoly prohlížeče log#:#log_browser_users#:#Protokol konzoly použití uživatelských jmen log#:#log_cache_#:#Ukládání do mezipaměti diff --git a/lang/ilias_da.lang b/lang/ilias_da.lang index c111541c1cad..ab1cd5b0e3c9 100644 --- a/lang/ilias_da.lang +++ b/lang/ilias_da.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index 96075803f20e..9ebd72b9ccd9 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -11070,20 +11070,6 @@ lm#:#lm_page_added#:#Eine Seite wurde hinzugefügt. lm#:#lm_page_type_lm#:#Lernmodulseite lm#:#lm_pages_added#:#Die Seiten wurden hinzugefügt. lm#:#lm_save_titles#:#Titel wurden gespeichert. -lng#:#language_detection#:#Automatische Spracherkennung -lng#:#lng_disable_language_detection#:#Automatische Spracherkennung deaktivieren -lng#:#lng_download_deprecated#:#Veraltete Einträge herunterladen -lng#:#lng_enable_language_detection#:#Automatische Spracherkennung aktivieren -lng#:#lng_error_clear_shipped_po_unreadable#:#Die lokalen Änderungen wurden nicht gelöscht: Die ausgelieferten PO-Dateien folgender Module können nicht gelesen werden: %s. Details stehen im Log. -lng#:#lng_markup_not_allowed#:#Nicht erlaubtes Markup in folgenden Einträgen: %s -lng#:#lng_merge_database_not_updated#:#Die ausgelieferten PO-Dateien folgender Module wurden geschrieben, ihre Einträge in der Datenbank konnten aber nicht aktualisiert werden (siehe Log): %s -lng#:#lng_merge_po_not_written#:#Die lokalen Änderungen folgender Module konnten nicht in ihre ausgelieferten PO-Dateien übernommen werden (siehe Log): %s -lng#:#lng_merged_po_modules#:#Die lokalen Änderungen folgender Module wurden in ihre ausgelieferten PO-Dateien übernommen: %s -lng#:#lng_not_translated#:#Nicht übersetzt -lng#:#lng_po_overlay_not_written#:#Die Änderungen wurden in der Datenbank gespeichert, aber die PO/MO-Dateien folgender über PO-Dateien gepflegter Module konnten nicht geschrieben werden: %s. Für diese Module werden weiterhin die bisherigen Texte angezeigt. Bitte prüfen Sie, ob der Webserver-Nutzer Schreibrechte auf das Verzeichnis "lang" im Client-Datenverzeichnis hat. Details stehen im Log. -lng#:#lng_po_overlay_not_written_languages#:#Die Sprachdaten wurden in der Datenbank gespeichert, aber für folgende Sprachen konnten die PO/MO-Dateien der über PO-Dateien gepflegten Module nicht geschrieben werden: %s. Für diese Module werden weiterhin die bisherigen Texte angezeigt. Bitte prüfen Sie, ob der Webserver-Nutzer Schreibrechte auf das Verzeichnis "lang" im Client-Datenverzeichnis hat. Details stehen im Log. -lng#:#lng_shipped_po_unreadable_fallback#:#Die ausgelieferten PO-Dateien folgender Module können nicht gelesen werden: %s. Als Standardwerte werden für diese Module ersatzweise die möglicherweise veralteten Einträge der Sprachdatei (.lang) angezeigt. Details stehen im Log. -lng#:#lng_switch_language_detection#:#Automatische Spracherkennung umschalten log#:#log_component_btn_reset#:#Einstellungen zurücksetzen log#:#log_component_col_component#:#Komponente log#:#log_component_col_level#:#Log-Level diff --git a/lang/ilias_el.lang b/lang/ilias_el.lang index 4b2e8a7e813f..28efb706a149 100644 --- a/lang/ilias_el.lang +++ b/lang/ilias_el.lang @@ -10942,11 +10942,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 9a043ef79b3e..580b15c24e61 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -11043,20 +11043,6 @@ lm#:#lm_page_added#:#Page has been added. lm#:#lm_page_type_lm#:#LM Page lm#:#lm_pages_added#:#Pages have been added. lm#:#lm_save_titles#:#Titles saved. -lng#:#language_detection#:#Language Detection -lng#:#lng_disable_language_detection#:#Disable Language Detection -lng#:#lng_download_deprecated#:#Download Deprecated List -lng#:#lng_enable_language_detection#:#Enable Language Detection -lng#:#lng_error_clear_shipped_po_unreadable#:#The local changes were not cleared: the shipped PO files of the following modules cannot be read: %s. See the log for details. -lng#:#lng_markup_not_allowed#:#Markup that is not allowed in the following entries: %s -lng#:#lng_merge_database_not_updated#:#The shipped PO files of the following modules were written, but their database entries could not be updated (see the log): %s -lng#:#lng_merge_po_not_written#:#The local changes of the following modules could not be merged into their shipped PO files (see the log): %s -lng#:#lng_merged_po_modules#:#The local changes of the following modules were merged into their shipped PO files: %s -lng#:#lng_not_translated#:#Not translated -lng#:#lng_po_overlay_not_written#:#The changes were saved in the database, but the PO/MO files of the following modules maintained in PO files could not be written: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory "lang" in the client data directory. See the log for details. -lng#:#lng_po_overlay_not_written_languages#:#The language data was saved in the database, but the PO/MO files of the modules maintained in PO files could not be written for the following languages: %s. The previous texts are still shown for these modules. Please check whether the web server user may write to the directory "lang" in the client data directory. See the log for details. -lng#:#lng_shipped_po_unreadable_fallback#:#The shipped PO files of the following modules cannot be read: %s. The possibly outdated entries of the language file (.lang) are shown as default values for these modules instead. See the log for details. -lng#:#lng_switch_language_detection#:#Switch Language Detection log#:#log_component_btn_reset#:#Reset Settings log#:#log_component_col_component#:#Component log#:#log_component_col_level#:#Log-Level diff --git a/lang/ilias_es.lang b/lang/ilias_es.lang index 279ef2f81ed0..cd37254bb658 100644 --- a/lang/ilias_es.lang +++ b/lang/ilias_es.lang @@ -10994,11 +10994,6 @@ lm#:#lm_page_added#:#La página ha sido añadida. lm#:#lm_page_type_lm#:#Página de ML lm#:#lm_pages_added#:#Las páginas han sido añadidas. lm#:#lm_save_titles#:#Títulos guardados. -lng#:#language_detection#:#Detección de idioma -lng#:#lng_disable_language_detection#:#Desactivar detección de idioma -lng#:#lng_download_deprecated#:#Descargar lista de obsoletos -lng#:#lng_enable_language_detection#:#Activar detección de idioma -lng#:#lng_switch_language_detection#:#Cambiar detección de idioma log#:#log_browser#:#Registro de consola del navegador log#:#log_browser_users#:#Nombres de usuario utilizando el registro de consola log#:#log_cache_#:#Almacenamiento en caché diff --git a/lang/ilias_et.lang b/lang/ilias_et.lang index c6460b5e7169..16fc2e379513 100644 --- a/lang/ilias_et.lang +++ b/lang/ilias_et.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_fa.lang b/lang/ilias_fa.lang index d8ac642c14d7..4089d94b7727 100644 --- a/lang/ilias_fa.lang +++ b/lang/ilias_fa.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_fr.lang b/lang/ilias_fr.lang index 4f02412f16e5..79fc0e2836d0 100644 --- a/lang/ilias_fr.lang +++ b/lang/ilias_fr.lang @@ -10926,11 +10926,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titres sauvegardés. -lng#:#language_detection#:#Détection de la langue -lng#:#lng_disable_language_detection#:#Désactiver la Détection de la Langue -lng#:#lng_download_deprecated#:#Télécharger la liste obsolète -lng#:#lng_enable_language_detection#:#Activer la détection de la langue -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Journal de console du navigateur log#:#log_browser_users#:#Noms d’utilisateurs utilisant le journal de console log#:#log_cache_#:#Mise en cache diff --git a/lang/ilias_hr.lang b/lang/ilias_hr.lang index 574d679b1d05..fd2d581ccafd 100644 --- a/lang/ilias_hr.lang +++ b/lang/ilias_hr.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###04 06 2021 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Naslovi spremljeni. -lng#:#language_detection#:#Language Detection###04 06 2021 new variable -lng#:#lng_disable_language_detection#:#Onemogući otkrivanje jezika -lng#:#lng_download_deprecated#:#Preuzmi zastarjeli popis -lng#:#lng_enable_language_detection#:#Omogući otkrivanje jezika -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log log#:#log_browser_users#:#Usernames Using Console Log log#:#log_cache_#:#Caching diff --git a/lang/ilias_hu.lang b/lang/ilias_hu.lang index 24fd900c50c8..90e0e69a89b7 100644 --- a/lang/ilias_hu.lang +++ b/lang/ilias_hu.lang @@ -11017,11 +11017,6 @@ lm#:#lm_page_added#:#Az oldalt sikeresen hozzáadta. lm#:#lm_page_type_lm#:#Tananyagoldal lm#:#lm_pages_added#:#Az oldalakat sikeresen hozzáadta. lm#:#lm_save_titles#:#A címeket sikeresen mentette. -lng#:#language_detection#:#Nyelvfelismerés -lng#:#lng_disable_language_detection#:#Nyelvfelismerés tiltása -lng#:#lng_download_deprecated#:#Elavultak listájának letöltése -lng#:#lng_enable_language_detection#:#Nyelvfelismerés engedélyezése -lng#:#lng_switch_language_detection#:#Nyelvfelismerés váltása log#:#log_browser#:#Böngésző konzolnaplózása log#:#log_browser_users#:#Felhasználónevek használata a konzolnaplózásban log#:#log_cache_#:#Gyorsítótárazás diff --git a/lang/ilias_it.lang b/lang/ilias_it.lang index 9aa1add2de68..777daa6fc9c9 100644 --- a/lang/ilias_it.lang +++ b/lang/ilias_it.lang @@ -10944,11 +10944,6 @@ lm#:#lm_page_added#:#Page has been added.###28 11 2023 new variable lm#:#lm_page_type_lm#:#Pagina LM lm#:#lm_pages_added#:#Pages have been added.###28 11 2023 new variable lm#:#lm_save_titles#:#Titoli salvati. -lng#:#language_detection#:#Riconoscimento della lingua -lng#:#lng_disable_language_detection#:#Disattiva riconoscimento lingua -lng#:#lng_download_deprecated#:#Scarica elenco deprecato -lng#:#lng_enable_language_detection#:#Abilita il riconoscimento della lingua -lng#:#lng_switch_language_detection#:#Switch Language Detection###30 04 2024 new variable log#:#log_browser#:#Registro della console del browser log#:#log_browser_users#:#Nomi utente utilizzando il registro della console log#:#log_cache_#:#Caching diff --git a/lang/ilias_ja.lang b/lang/ilias_ja.lang index dc2a97bbd04f..6688e9f7d1a9 100644 --- a/lang/ilias_ja.lang +++ b/lang/ilias_ja.lang @@ -10971,11 +10971,6 @@ lm#:#lm_page_added#:#ページが追加されました。 lm#:#lm_page_type_lm#:#LMページ lm#:#lm_pages_added#:#ページが追加されました。 lm#:#lm_save_titles#:#タイトルは保存されました。 -lng#:#language_detection#:#言語検知 -lng#:#lng_disable_language_detection#:#言語検知を無効化 -lng#:#lng_download_deprecated#:#ダウンロード非推奨リスト -lng#:#lng_enable_language_detection#:#言語検知を有効化 -lng#:#lng_switch_language_detection#:#言語検知を切り替える log#:#log_browser#:#ブラウザコンソールログ log#:#log_browser_users#:#コンソールログ使用中のユーザ名 log#:#log_cache_#:#キャッシング diff --git a/lang/ilias_ka.lang b/lang/ilias_ka.lang index fdde15130e32..9383e4752550 100644 --- a/lang/ilias_ka.lang +++ b/lang/ilias_ka.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_lt.lang b/lang/ilias_lt.lang index f865ddbc33a8..3f248ba2cbb6 100644 --- a/lang/ilias_lt.lang +++ b/lang/ilias_lt.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_nl.lang b/lang/ilias_nl.lang index 589cf04eb330..0c855cbe5f94 100644 --- a/lang/ilias_nl.lang +++ b/lang/ilias_nl.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_pl.lang b/lang/ilias_pl.lang index a8c32a0f6794..2b7d1518b23d 100644 --- a/lang/ilias_pl.lang +++ b/lang/ilias_pl.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Zapisano nazwę. -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Wyłącz funkcję automatycznego rozpoznawania języka -lng#:#lng_download_deprecated#:#Pobierz starsze wpisy -lng#:#lng_enable_language_detection#:#Aktywuj funkcję automatycznego rozpoznawania języka -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Log konsoli przeglądarki log#:#log_browser_users#:#Aktywni użytkownicy dla logu przeglądarki log#:#log_cache_#:#Pamięć podręczna diff --git a/lang/ilias_pt.lang b/lang/ilias_pt.lang index 19c525fb5ac4..414afbac1bf6 100644 --- a/lang/ilias_pt.lang +++ b/lang/ilias_pt.lang @@ -11012,11 +11012,6 @@ lm#:#lm_page_added#:#Page has been added.###03 09 2026 new variable lm#:#lm_page_type_lm#:#LM Page###30 04 2021 new variable lm#:#lm_pages_added#:#Pages have been added.###03 09 2026 new variable lm#:#lm_save_titles#:#Títulos guardados. -lng#:#language_detection#:#Language Detection###30 04 2021 new variable -lng#:#lng_disable_language_detection#:#Desativar deteção de idioma -lng#:#lng_download_deprecated#:#Descarregar lista obsoleta -lng#:#lng_enable_language_detection#:#Ativar deteção de idioma -lng#:#lng_switch_language_detection#:#Switch Language Detection###03 09 2026 new variable log#:#log_component_btn_reset#:#Repor definições log#:#log_component_col_component#:#Componente log#:#log_component_col_level#:#Nível log diff --git a/lang/ilias_ro.lang b/lang/ilias_ro.lang index 242a9c00a82d..18a525084dc5 100644 --- a/lang/ilias_ro.lang +++ b/lang/ilias_ro.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_ru.lang b/lang/ilias_ru.lang index 9fc5c606afd9..853b63519c8b 100644 --- a/lang/ilias_ru.lang +++ b/lang/ilias_ru.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_sk.lang b/lang/ilias_sk.lang index df02bc233cbb..62a5b3278909 100644 --- a/lang/ilias_sk.lang +++ b/lang/ilias_sk.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_sl.lang b/lang/ilias_sl.lang index 22bff4f77d81..964552d1a449 100644 --- a/lang/ilias_sl.lang +++ b/lang/ilias_sl.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Naslovi so bili shranjeni. -lng#:#language_detection#:#Language Detection###19 08 2022 new variable -lng#:#lng_disable_language_detection#:#Onemogoči samodejno zaznavanje jezika -lng#:#lng_download_deprecated#:#Prenesi zastarele vnose -lng#:#lng_enable_language_detection#:#Aktiviraj samodejno zaznavanje jezika -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log log#:#log_browser_users#:#Usernames Using Console Log log#:#log_cache_#:#Caching diff --git a/lang/ilias_sq.lang b/lang/ilias_sq.lang index 34c57ed426f6..aebaa341c61a 100644 --- a/lang/ilias_sq.lang +++ b/lang/ilias_sq.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_sr.lang b/lang/ilias_sr.lang index 820e0dec6aef..549e0d844ab0 100644 --- a/lang/ilias_sr.lang +++ b/lang/ilias_sr.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_sv.lang b/lang/ilias_sv.lang index 3062d5fe0cd0..ea7681c75573 100644 --- a/lang/ilias_sv.lang +++ b/lang/ilias_sv.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###21 11 2023 new variable lm#:#lm_page_type_lm#:#sida för utbildningsmodul lm#:#lm_pages_added#:#Pages have been added.###21 11 2023 new variable lm#:#lm_save_titles#:#Titlar har sparats. -lng#:#language_detection#:#Automatisk taligenkänning -lng#:#lng_disable_language_detection#:#Avaktivera automatisk taligenkänning -lng#:#lng_download_deprecated#:#Ladda ner obsoleta poster -lng#:#lng_enable_language_detection#:#Aktivera automatisk taligenkänning -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Konsol Logg log#:#log_browser_users#:#Aktiverade användare för webbläsarlogg log#:#log_cache_#:#Caching diff --git a/lang/ilias_tr.lang b/lang/ilias_tr.lang index fd0b0b58c0d6..db88d722bfed 100644 --- a/lang/ilias_tr.lang +++ b/lang/ilias_tr.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_uk.lang b/lang/ilias_uk.lang index be5b8d0f2b25..8ad224bf1898 100644 --- a/lang/ilias_uk.lang +++ b/lang/ilias_uk.lang @@ -10941,11 +10941,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_vi.lang b/lang/ilias_vi.lang index e3b72b8c1116..a829e23701b3 100644 --- a/lang/ilias_vi.lang +++ b/lang/ilias_vi.lang @@ -10943,11 +10943,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable diff --git a/lang/ilias_zh.lang b/lang/ilias_zh.lang index 768496f79ec5..dbf346347a77 100644 --- a/lang/ilias_zh.lang +++ b/lang/ilias_zh.lang @@ -10940,11 +10940,6 @@ lm#:#lm_page_added#:#Page has been added.###26 08 2024 new variable lm#:#lm_page_type_lm#:#LM Page###29 07 2022 new variable lm#:#lm_pages_added#:#Pages have been added.###26 08 2024 new variable lm#:#lm_save_titles#:#Titles saved.###23 10 2017 new variable -lng#:#language_detection#:#Language Detection###29 07 2022 new variable -lng#:#lng_disable_language_detection#:#Disable Language Detection###30 08 2015 new variable -lng#:#lng_download_deprecated#:#Download Deprecated List###07 02 2017 new variable -lng#:#lng_enable_language_detection#:#Enable Language Detection###30 08 2015 new variable -lng#:#lng_switch_language_detection#:#Switch Language Detection###26 08 2024 new variable log#:#log_browser#:#Browser Console Log###30 08 2015 new variable log#:#log_browser_users#:#Usernames Using Console Log###30 08 2015 new variable log#:#log_cache_#:#Caching###30 08 2015 new variable From 9915cf00cf5fb722575c2bbd108bc273e031bd25 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 17:52:30 +0200 Subject: [PATCH 063/103] Language: Keep serving a module whose shipped `.po` cannot be compiled A `.po` that cannot be compiled no longer drops its module to "-key-": `ShippedTranslationsBuild` takes over the catalog of the served or previous build when it was built by compatible code and passes the markup policy, otherwise it warns and does not serve that module and language; the build never aborts, like broken `.lang` files. `current.json` now only holds build, previous and modules, hashes, warnings and collisions stay in the manifest. `ShippedLanguageFilesCompiledObjective` requires the gettext extension. --- .../Language/classes/class.ilLanguage.php | 6 +- .../Catalog/TranslationCatalog.php | 26 +- .../MigratedTranslations.php | 2 +- .../ComponentTranslation/NativeGettext.php | 2 +- .../ShippedTranslationsBuild.php | 172 +++++++++-- .../ShippedLanguageFilesCompiledObjective.php | 8 +- .../Catalog/TranslationCatalogMoTest.php | 103 +++++++ .../ShippedTranslationsBuildTest.php | 284 +++++++++++++++++- ...ppedLanguageFilesCompiledObjectiveTest.php | 58 +++- 9 files changed, 605 insertions(+), 56 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index 5619fc39225a..132d8c55c32e 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -203,7 +203,8 @@ public function txtlng(string $a_module, string $a_topic, string $a_language): s * A LanguageIdentifier is read from exactly its module without loadLanguageModule() and without side effect, * falling back to the default language. * - * $n selects a plural form of a migrated module; the quantity is not inserted, callers do that. + * $n selects a plural form of a migrated module; the quantity is not inserted, callers do that. Without a + * form for $n in the language the value without $n is served, never another language's form. */ public function txt(string|LanguageIdentifier $a_topic, ?int $n = null): string { @@ -500,7 +501,8 @@ private static function migratedKeysOf(string $a_module, string $lang_key): ?arr return $keys; } MigratedTranslations::reportProblem(sprintf( - 'Module "%s" ships a language file for "%s" that is not built - run "php cli/setup.php build".', + 'Module "%s" ships a language file for "%s" that is not built - if "php cli/setup.php build" warns' + . ' that the file cannot be compiled, fix the file; otherwise run the build.', $a_module, $lang_key )); diff --git a/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationCatalog.php b/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationCatalog.php index e344843d2828..d69f7643c232 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationCatalog.php +++ b/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationCatalog.php @@ -23,6 +23,7 @@ use ErrorException; use Gettext\Generator\MoGenerator; use Gettext\Generator\PoGenerator; +use Gettext\Loader\MoLoader; use Gettext\Loader\StrictPoLoader; use Gettext\Translation; use Gettext\Translations; @@ -34,7 +35,7 @@ /** * An in-memory gettext catalog (header block plus messages) and the only place of the component that reads * or writes the gettext file formats (a `.mo` is read at runtime by native gettext, see NativeGettext). - * A narrow adapter around gettext/gettext (Translations, StrictPoLoader, PoGenerator, MoGenerator): nothing + * A narrow adapter around gettext/gettext (Translations, StrictPoLoader, MoLoader, PoGenerator, MoGenerator): nothing * else imports the Gettext namespace, so a library change stays local to this class and TranslationEntry. * The library's Scanner is not used. * @@ -135,6 +136,29 @@ static function () use ($content): Translations { return $catalog; } + /** + * Reads a `.mo` of toMoString() for reading only: the library drops empty plural forms and forms "0", so + * later forms move up. Never compile the result again. + * + * @throws RuntimeException if $content is not valid `.mo` data + */ + public static function fromMoString(string $content): self + { + $translations = self::withLibraryErrorsAsExceptions( + static fn(): Translations => (new MoLoader())->loadString($content) + ); + foreach ($translations->getTranslations() as $translation) { + if ($translation->getTranslation() === "0\0") { + $translation->translate('0'); + } + } + + $catalog = new self(); + $catalog->translations = $translations; + + return $catalog; + } + /** * @return array header name => value, sorted by name */ diff --git a/components/ILIAS/Language/src/ComponentTranslation/MigratedTranslations.php b/components/ILIAS/Language/src/ComponentTranslation/MigratedTranslations.php index 349cad2f7a0d..675c55d19a3b 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/MigratedTranslations.php +++ b/components/ILIAS/Language/src/ComponentTranslation/MigratedTranslations.php @@ -39,7 +39,7 @@ * Static: ilLanguage::_lookupEntry() is static, and native gettext's state is per process anyway. * * @phpstan-type State array{keys: array, ok: bool, domain: string, overlay_domain: ?string, overlay_keys: array} - * @phpstan-type Pointer array{build: ?string, previous: ?string, modules: array>, hashes: array>, warnings: list, collisions: list} + * @phpstan-import-type Pointer from ShippedTranslationsBuild */ final class MigratedTranslations { diff --git a/components/ILIAS/Language/src/ComponentTranslation/NativeGettext.php b/components/ILIAS/Language/src/ComponentTranslation/NativeGettext.php index 3a8f9e2cdf60..8cec2317d990 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/NativeGettext.php +++ b/components/ILIAS/Language/src/ComponentTranslation/NativeGettext.php @@ -103,7 +103,7 @@ public static function useThreadSafeForTests(?bool $thread_safe): void public static function activate(string $locale_directory): bool { if (!self::isExtensionLoaded()) { - self::$problem = 'the PHP extension "gettext" is not loaded'; + self::$problem = 'the PHP extension "gettext" is not loaded in this PHP (the web server needs it like the CLI that ran the build)'; return false; } if (self::$locale_directory === $locale_directory && self::isActive()) { diff --git a/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslationsBuild.php b/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslationsBuild.php index 09265d5da36d..be6adf1d72da 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslationsBuild.php +++ b/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslationsBuild.php @@ -41,9 +41,10 @@ * - `/keys/.php`: all identifiers of the module's `.po` files and template, the same for every * language, as a PHP file for the opcache; * - `/locale/`: the locale native gettext is activated with (see NativeGettext), a copy of "C.utf8"; - * - `/manifest.json`: size and hash of every file, to tell an intact build; - * - `current.json`: the build the runtime serves, its languages per module and the hash of each built `.po` - * (see servesShippedPo()). + * - `/manifest.json`: size and hash of every file, to tell an intact build, the hash of each built `.po` + * (see servesShippedPo()), the warnings and collisions; + * - `current.json`: the build the runtime serves and its languages per module - read on every request, so + * nothing else. * * The build id fingerprints everything that determines the build: the shipped files, the compiling code (see * FORMAT_VERSION) and the locale. An intact build with the same id (its manifest also keeps its warnings) is @@ -52,14 +53,16 @@ * keep their build. The previous build is kept, older ones (and the files of the former layout * `/.mo`, `index.json`) are removed. Builds are serialized by `build.lock`. * - * A `.po` that cannot be compiled is reported and left out (its module is not served for that language); - * values with disallowed markup are compiled cleaned and reported; identifiers with different values in - * several modules (txt() serves the one loaded last) are listed. All of that repeats on every build until fixed. + * A `.po` that cannot be compiled is reported and served with the catalog of the served (or else the previous) + * build, so a broken file never turns a working language into `-identifier-`; without such a catalog the module is + * not served for that language. Values with disallowed markup are compiled cleaned and reported; identifiers with different values in several modules (txt() serves the one loaded last) are + * listed. All of that repeats on every build until fixed. * * Files are written without fsync(): a build can be repeated any time, and the manifest reveals a build * damaged by a crash. * - * @phpstan-type Manifest array{files: array, sample: array{module: string, lang: string, key: string, value: string}, languages: array>, hashes: array>, warnings: list, collisions: list} + * @phpstan-type Manifest array{files: array, sample: array{module: string, lang: string, key: string, value: string}, languages: array>, hashes: array>, warnings: list, collisions: list, compatibility: string} + * @phpstan-type Pointer array{build: ?string, previous: ?string, modules: array>} */ final class ShippedTranslationsBuild { @@ -73,6 +76,13 @@ final class ShippedTranslationsBuild private const string LOCK_FILE = 'build.lock'; + /** + * Per build directory: a build never changes once written. + * + * @var array + */ + private static array $manifests = []; + /** * Where the C library's "C.utf8" locale is looked for (Debian/Ubuntu, Fedora/RHEL, Arch, ...). */ @@ -99,8 +109,8 @@ public function __construct( * @return array{build: ?string, compiled: int, unchanged: bool, removed: int, warnings: list, collisions: list} * build: the id now served (`null` without any migrated module); compiled: `.po` files * compiled (0 for an unchanged build); removed: files and directories of former builds - * @throws RuntimeException if the build cannot be written, the locale is missing, or native - * gettext does not serve it - `current.json` is left unchanged then + * @throws RuntimeException if the build cannot be written, the locale is missing, or native gettext + * does not serve it - `current.json` is left unchanged then */ public function run(callable $inform): array { @@ -130,7 +140,7 @@ private function runLocked(string $artifact_directory, callable $inform): array $modules = $this->findShippedFiles($inform); if ($modules === []) { - $this->writePointer($artifact_directory, ['build' => null, 'previous' => null, 'modules' => [], 'hashes' => [], 'warnings' => [], 'collisions' => []]); + $this->writePointer($artifact_directory, ['build' => null, 'previous' => null, 'modules' => []]); return [ 'build' => null, 'compiled' => 0, @@ -144,10 +154,14 @@ private function runLocked(string $artifact_directory, callable $inform): array $locale_source = $this->findLocaleSource(); $build_id = $this->buildId($modules, $locale_source); $build_directory = MigratedLanguageFilePaths::buildDirectory($artifact_directory, $build_id); - $manifest = $this->readManifest($build_directory); + $manifest = self::readManifest($build_directory); $compiled = 0; if ($manifest === null || !$this->isIntact($build_directory, $manifest)) { - [$manifest, $compiled] = $this->write($artifact_directory, $build_directory, $build_id, $modules, $locale_source); + $former_builds = array_values(array_diff( + array_filter([$pointer['build'] ?? null, $pointer['previous'] ?? null], 'is_string'), + [$build_id] + )); + [$manifest, $compiled] = $this->write($artifact_directory, $build_directory, $build_id, $modules, $locale_source, $former_builds); } try { @@ -167,9 +181,6 @@ private function runLocked(string $artifact_directory, callable $inform): array 'build' => $build_id, 'previous' => $previous, 'modules' => $manifest['languages'], - 'hashes' => $manifest['hashes'], - 'warnings' => $manifest['warnings'], - 'collisions' => $manifest['collisions'], ]; if ($new_pointer !== $pointer) { $this->writePointer($artifact_directory, $new_pointer); @@ -188,8 +199,7 @@ private function runLocked(string $artifact_directory, callable $inform): array /** * `current.json` of $artifact_directory, `null` if it is missing or not valid. * - * @return array{build: ?string, previous: ?string, modules: array>, hashes: array>, warnings: list, collisions: list}|null - * modules: module => the languages it is built for + * @return Pointer|null modules: module => the languages it is built for */ public static function readPointer(string $artifact_directory): ?array { @@ -222,9 +232,6 @@ public static function readPointer(string $artifact_directory): ?array 'build' => $build, 'previous' => $previous, 'modules' => $modules, - 'hashes' => self::hashes($data['hashes'] ?? []), - 'warnings' => self::stringList($data['warnings'] ?? []), - 'collisions' => self::stringList($data['collisions'] ?? []), ]; } @@ -232,6 +239,7 @@ public static function readPointer(string $artifact_directory): ?array * Writes the build $build_id into a temporary directory and renames that to $build_directory. * * @param array, template: ?string}> $modules + * @param list $former_builds where the catalog of a `.po` that cannot be compiled is taken from * @return array{0: Manifest, 1: int} the manifest of the build and the number of compiled `.po` files */ private function write( @@ -239,7 +247,8 @@ private function write( string $build_directory, string $build_id, array $modules, - string $locale_source + string $locale_source, + array $former_builds ): array { $temporary = $artifact_directory . '/.' . $build_id . '.' . bin2hex(random_bytes(6)); $this->ensureDirectory($temporary . '/' . NativeGettext::CATALOG_DIRECTORY); @@ -268,9 +277,30 @@ static function (string $message) use (&$warnings, $source, $module): void { $warnings[] = sprintf('%s (module "%s"): %s', $source, $module, $message); } ); + $compiled++; } catch (\Throwable $t) { - $warnings[] = sprintf('Could not compile "%s", module "%s" is not served for "%s": %s', $source, $module, $lang_key, $t->getMessage()); - continue; + $former = $this->formerCatalog($artifact_directory, $former_builds, $module, $lang_key); + if ($former === null) { + $warnings[] = sprintf( + 'Could not compile "%s", module "%s" is not served for "%s" until the file is fixed (no former build has it): %s', + $source, + $module, + $lang_key, + $t->getMessage() + ); + continue; + } + $warnings[] = sprintf( + 'Could not compile "%s", module "%s" is served for "%s" as in build %s until the file is fixed: %s', + $source, + $module, + $lang_key, + $former['build'], + $t->getMessage() + ); + $catalog = $former; + // the build does not serve this .po, see servesShippedPo() + $po_hash = $former['hash']; } $file = MigratedLanguageFilePaths::buildMoFile($temporary, $module, $lang_key); $this->writeFile($file, $catalog['mo']); @@ -279,7 +309,6 @@ static function (string $message) use (&$warnings, $source, $module): void { $hashes[$module][$lang_key] = $po_hash; $keys += array_fill_keys(array_map('strval', array_keys($catalog['values'])), true); $values_per_language[$lang_key][$module] = $catalog['values']; - $compiled++; foreach ($catalog['values'] as $identifier => $value) { if ($sample === null && $value !== (string) $identifier) { $sample = ['module' => $module, 'lang' => $lang_key, 'key' => (string) $identifier, 'value' => $value]; @@ -309,6 +338,7 @@ static function (string $message) use (&$warnings, $source, $module): void { 'hashes' => $hashes, 'warnings' => $warnings, 'collisions' => $this->findCollisions($values_per_language), + 'compatibility' => self::catalogCompatibility(), ]; $this->writeFile($temporary . '/' . self::MANIFEST_FILE, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n"); @@ -325,6 +355,81 @@ static function (string $message) use (&$warnings, $source, $module): void { return [$manifest, $compiled]; } + /** + * The catalog of $module/$lang_key in the first former build that has it intact and compiled the same way + * (see catalogCompatibility()). + * + * The bytes are taken over, not recompiled: reading a `.mo` back would shift plural forms (see + * TranslationCatalog::fromMoString()). Values are checked against the markup policy again. + * + * @param list $former_builds build ids + * @return array{mo: string, values: array, build: string, hash: string}|null values: see + * ShippedTranslations::compileCatalog(); hash: of the `.po` it was compiled from + */ + private function formerCatalog(string $artifact_directory, array $former_builds, string $module, string $lang_key): ?array + { + $policy = new TranslationMarkupPolicy(); + foreach ($former_builds as $former_build) { + $build_directory = MigratedLanguageFilePaths::buildDirectory($artifact_directory, $former_build); + $manifest = self::readManifest($build_directory); + if ($manifest === null || $manifest['compatibility'] !== self::catalogCompatibility()) { + continue; + } + $file = MigratedLanguageFilePaths::buildMoFile($build_directory, $module, $lang_key); + [$size, $hash] = $manifest['files'][substr($file, strlen($build_directory) + 1)] ?? [null, null]; + $resolved_file = realpath($file); + $resolved_directory = realpath($build_directory); + $mo = $size === null + || $resolved_file === false + || $resolved_directory === false + || !str_starts_with($resolved_file, $resolved_directory . '/') + ? false + : @file_get_contents($resolved_file); + if ($mo === false || strlen($mo) !== $size || hash('sha256', $mo) !== $hash) { + continue; + } + try { + $catalog = TranslationCatalog::fromMoString($mo); + } catch (RuntimeException) { + continue; + } + $values = []; + foreach ($catalog->getEntries() as $entry) { + foreach ([$entry->getTranslation(), ...$entry->getPluralTranslations()] as $value) { + if (!$policy->isAllowed($value)) { + continue 3; + } + } + if ($entry->getContext() === null && !$entry->isPlural() && $entry->getTranslation() !== '') { + $values[$entry->getId()] = $entry->getTranslation(); + } + } + + return [ + 'mo' => $mo, + 'values' => $values, + 'build' => $former_build, + 'hash' => $manifest['hashes'][$module][$lang_key] ?? '', + ]; + } + + return null; + } + + /** + * Fingerprint of the compiling and cleaning code: formerCatalog() only takes over catalogs with the same one. + */ + private static function catalogCompatibility(): string + { + $parts = [(string) self::FORMAT_VERSION]; + foreach ([ShippedTranslations::class, TranslationMarkupPolicy::class] as $class) { + $file = (new \ReflectionClass($class))->getFileName(); + $parts[] = is_string($file) ? (string) @hash_file('sha256', $file) : ''; + } + + return hash('sha256', implode('|', $parts)); + } + /** * The identifiers of $module in its template (`.pot`), `[]` if there is none or it cannot be read * (reported). @@ -574,7 +679,7 @@ private function copyLocale(string $source, string $target, string $build_direct /** * @return Manifest|null */ - private function readManifest(string $build_directory): ?array + private static function readManifest(string $build_directory): ?array { $content = is_link($build_directory) ? false : @file_get_contents($build_directory . '/' . self::MANIFEST_FILE); $data = $content === false ? null : json_decode($content, true); @@ -617,6 +722,7 @@ private function readManifest(string $build_directory): ?array 'warnings' => self::stringList($data['warnings'] ?? []), 'hashes' => self::hashes($data['hashes'] ?? []), 'collisions' => self::stringList($data['collisions'] ?? []), + 'compatibility' => is_string($data['compatibility'] ?? null) ? $data['compatibility'] : '', ]; } @@ -646,7 +752,7 @@ private function isIntact(string $build_directory, array $manifest): bool } /** - * @param array{build: ?string, previous: ?string, modules: array>, warnings: list, collisions: list} $pointer + * @param Pointer $pointer */ private function writePointer(string $artifact_directory, array $pointer): void { @@ -776,7 +882,19 @@ private static function hashes(mixed $value): array */ public static function servesShippedPo(string $artifact_directory, string $module, string $lang_key, string $shipped_po): bool { - $hash = self::readPointer($artifact_directory)['hashes'][$module][$lang_key] ?? null; + $build = self::readPointer($artifact_directory)['build'] ?? null; + if ($build === null) { + return false; + } + $build_directory = MigratedLanguageFilePaths::buildDirectory($artifact_directory, $build); + if (!isset(self::$manifests[$build_directory])) { + $manifest = self::readManifest($build_directory); + if ($manifest === null) { + return false; + } + self::$manifests[$build_directory] = $manifest; + } + $hash = self::$manifests[$build_directory]['hashes'][$module][$lang_key] ?? null; return $hash !== null && $hash === @hash_file('sha256', $shipped_po); } diff --git a/components/ILIAS/Language/src/Setup/ShippedLanguageFilesCompiledObjective.php b/components/ILIAS/Language/src/Setup/ShippedLanguageFilesCompiledObjective.php index 95dd86000b3e..fd252c99b6d5 100644 --- a/components/ILIAS/Language/src/Setup/ShippedLanguageFilesCompiledObjective.php +++ b/components/ILIAS/Language/src/Setup/ShippedLanguageFilesCompiledObjective.php @@ -31,9 +31,9 @@ * Build step (`php cli/setup.php build`) for the migrated modules: builds what native gettext serves * from every shipped `.po` into `artifacts/language/`, see ShippedTranslationsBuild. * - * Disallowed markup, uncompilable `.po` files and identifier collisions are warnings on every build. - * The build is aborted (Setup\UnachievableException) if it cannot be written or native gettext does - * not serve it. + * Disallowed markup, uncompilable `.po` files (served from a former build if possible, else not served) and + * identifier collisions are warnings on every build. The build is aborted (Setup\UnachievableException) if it + * cannot be written or native gettext does not serve it. */ final class ShippedLanguageFilesCompiledObjective implements Setup\Objective { @@ -67,7 +67,7 @@ public function isNotable(): bool public function getPreconditions(Setup\Environment $environment): array { - return []; + return [new Setup\Condition\PHPExtensionLoadedCondition('gettext')]; } public function isApplicable(Setup\Environment $environment): bool diff --git a/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php index b434ece61c5a..5ff907cb0643 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/Catalog/TranslationCatalogMoTest.php @@ -455,4 +455,107 @@ public function testThrowablesOfTheLibraryBecomeRuntimeExceptions(): void throw new \TypeError('type error'); }); } + + /** + * fromMoString() reads back what toMoString() wrote: values incl. "0" and multi-line, context, plural forms, headers. + */ + public function testFromMoStringReadsBackWhatToMoStringWrote(): void + { + $catalog = self::sampleCatalog(); + $plural = new TranslationEntry('mod', 'item'); + $plural->setPlural('items', ['Eintrag', 'Einträge']); + $catalog->add($plural); + + $read = TranslationCatalog::fromMoString($catalog->toMoString()); + + $this->assertSame('de', $read->getHeader('Language')); + $this->assertSame('nplurals=2; plural=(n != 1);', $read->getHeader('Plural-Forms')); + $this->assertSame('0', $read->find('mod', 'zero')?->getTranslation(), 'the value "0" is not "0" plus its NUL'); + $this->assertSame("Mehr\nzeilig", $read->find('mod', 'alpha')?->getTranslation()); + $this->assertSame('Z', $read->find('mod', 'zeta')?->getTranslation()); + $this->assertSame('Hello', $read->find('mod', 'fuzzy')?->getTranslation(), 'fuzzy messages are compiled'); + $this->assertSame('ohne Kontext', $read->find(null, 'no_context')?->getTranslation()); + $this->assertNull($read->find('mod', 'untranslated'), 'left out of the .mo'); + $item = $read->find('mod', 'item'); + $this->assertNotNull($item); + $this->assertTrue($item->isPlural()); + $this->assertSame('items', $item->getPluralId()); + $this->assertSame(['Eintrag', 'Einträge'], $item->getPluralTranslations()); + $this->assertCount(6, $read->getEntries()); + } + + /** For singular messages (also "0") and plurals without empty or "0" forms the read catalog compiles to the same values. */ + public function testAReadMoWithoutEmptyOrZeroPluralFormsCompilesToTheSameValuesAgain(): void + { + $catalog = self::sampleCatalog(); + $plural = new TranslationEntry('mod', 'item'); + $plural->setPlural('items', ['Eintrag', 'Einträge']); + $catalog->add($plural); + $mo = $catalog->toMoString(); + + $this->assertSame( + $this->read($mo), + $this->read(TranslationCatalog::fromMoString($mo)->toMoString()) + ); + $this->assertSame( + \MigratedPoFixture::readMoPluralForms($mo), + \MigratedPoFixture::readMoPluralForms(TranslationCatalog::fromMoString($mo)->toMoString()) + ); + } + + /** + * @return array, list}> forms written, forms read back + */ + public static function pluralFormsTheLibraryDrops(): array + { + return [ + 'empty intermediate form' => [['eins', '', 'viele'], ['eins', 'viele']], + 'form "0"' => [['eins', 'zwei', '0'], ['eins', 'zwei']], + ]; + } + + /** + * Documents why fromMoString() is for reading only: the library drops empty plural forms and forms "0". + * + * @param list $written + * @param list $read + */ + #[DataProvider('pluralFormsTheLibraryDrops')] + public function testFromMoStringLosesEmptyAndZeroPluralForms(array $written, array $read): void + { + $catalog = new TranslationCatalog(); + $catalog->setHeader('Plural-Forms', 'nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);'); + $entry = new TranslationEntry('mod', 'item'); + $entry->setPlural('items', $written); + $catalog->add($entry); + $mo = $catalog->toMoString(); + $this->assertSame(['item' => $written], \MigratedPoFixture::readMoPluralForms($mo), 'precondition: the .mo holds every form'); + + $item = TranslationCatalog::fromMoString($mo)->find('mod', 'item'); + + $this->assertSame($read, $item?->getPluralTranslations()); + } + + /** + * @return array + */ + public static function invalidMoData(): array + { + $mo = self::sampleCatalog()->toMoString(); + + return [ + 'empty' => [''], + 'no .mo' => ['this is no gettext catalog'], + 'cut in the header' => [substr($mo, 0, 20)], + 'cut in the tables' => [substr($mo, 0, 30)], + ]; + } + + #[DataProvider('invalidMoData')] + public function testFromMoStringRejectsInvalidOrTruncatedData(string $content): void + { + $this->expectException(RuntimeException::class); + + TranslationCatalog::fromMoString($content); + } } diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsBuildTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsBuildTest.php index f2ca30e80ca0..cbeb583d1391 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsBuildTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsBuildTest.php @@ -26,8 +26,8 @@ /** * ShippedTranslationsBuild: on success a collision list, the previous build kept, older ones removed, an unchanged run - * touching nothing, one build at a time; on failure `current.json` (what running requests are served from) stays as it - * was and no half-written build remains. + * touching nothing, one build at a time, a `.po` that cannot be compiled served from a former build; on failure + * `current.json` stays as it was and no half-written build remains. */ class ShippedTranslationsBuildTest extends TestCase { @@ -97,6 +97,27 @@ private function buildDirectory(string $build): string return MigratedLanguageFilePaths::buildDirectory($this->artifacts, $build); } + /** + * @return array + */ + private function manifest(string $build): array + { + return json_decode((string) file_get_contents($this->buildDirectory($build) . '/manifest.json'), true, 512, JSON_THROW_ON_ERROR); + } + + private function breakPo(string $module, string $lang_key): void + { + file_put_contents($this->root . '/' . $module . '/' . $module . '_' . $lang_key . '.po', "msgid \"kaputt\n"); + } + + /** + * @return array + */ + private function servedValues(string $build, string $module, string $lang_key): array + { + return MigratedPoFixture::readMo(MigratedLanguageFilePaths::buildMoFile($this->buildDirectory($build), $module, $lang_key)); + } + /** * A locale directory the C library cannot load (see findLocaleSource(): it only looks for the file). */ @@ -126,7 +147,7 @@ public function testIdentifiersWithDifferentValuesInSeveralModulesAreListedPerLa ]; $this->assertSame($expected, $first['collisions'], '"same" has one value everywhere, "only_a" is in one module only'); $this->assertSame($expected, $second['collisions'], 'listed again by an unchanged build, not only by the one that wrote it'); - $this->assertSame($expected, ShippedTranslationsBuild::readPointer($this->artifacts)['collisions'] ?? null); + $this->assertSame($expected, $this->manifest((string) $first['build'])['collisions'] ?? null, 'kept in the manifest of the build'); } public function testOnlyFilesOfNoModuleInTheSameDirectoryAreReportedAsWithoutLanguageKey(): void @@ -364,14 +385,261 @@ public function testServesShippedPoIsTrueOnlyForThePoTheBuildWasMadeFrom(): void $this->assertTrue($serves('msp', 'de', $po)); } - public function testServesShippedPoIsFalseWhenCurrentJsonHasNoHashes(): void + /** The manifests are cached per build directory: each variant changes the file before the first call. */ + public function testServesShippedPoIsFalseWhenTheManifestOfTheCurrentBuildHasNoHashes(): void { $this->ship('msp', 'de', ['greeting' => 'Hallo']); - $this->build(['msp']); - $pointer = json_decode((string) file_get_contents($this->pointerFile()), true); - unset($pointer['hashes']); - file_put_contents($this->pointerFile(), json_encode($pointer)); + $build = (string) $this->build(['msp'])['build']; + $manifest = $this->manifest($build); + unset($manifest['hashes']); + file_put_contents($this->buildDirectory($build) . '/manifest.json', json_encode($manifest)); $this->assertFalse(ShippedTranslationsBuild::servesShippedPo($this->artifacts, 'msp', 'de', $this->root . '/msp/msp_de.po')); } + + public function testServesShippedPoIsFalseWhenTheManifestOfTheCurrentBuildIsGone(): void + { + $this->ship('msp', 'de', ['greeting' => 'Hallo']); + $build = (string) $this->build(['msp'])['build']; + unlink($this->buildDirectory($build) . '/manifest.json'); + + $this->assertFalse(ShippedTranslationsBuild::servesShippedPo($this->artifacts, 'msp', 'de', $this->root . '/msp/msp_de.po')); + } + + public function testCurrentJsonHoldsExactlyTheBuildThePreviousBuildAndTheModules(): void + { + $this->ship('mpa', 'de', ['greeting' => 'Hallo']); + $this->ship('mpa', 'en', ['greeting' => 'Hello']); + $first = (string) $this->build(['mpa'])['build']; + $this->ship('mpa', 'de', ['greeting' => 'Moin']); + $second = (string) $this->build(['mpa'])['build']; + + $data = json_decode((string) file_get_contents($this->pointerFile()), true, 512, JSON_THROW_ON_ERROR); + + $keys = array_keys($data); + sort($keys); + $this->assertSame(['build', 'modules', 'previous'], $keys); + sort($data['modules']['mpa']); + $this->assertSame(['build' => $second, 'previous' => $first, 'modules' => ['mpa' => ['de', 'en']]], $data); + } + + public function testReadPointerIgnoresFieldsAFormerVersionWroteIntoCurrentJson(): void + { + $this->ship('mpa', 'de', ['greeting' => 'Hallo']); + $build = (string) $this->build(['mpa'])['build']; + $pointer = ShippedTranslationsBuild::readPointer($this->artifacts); + $data = json_decode((string) file_get_contents($this->pointerFile()), true, 512, JSON_THROW_ON_ERROR); + $data['hashes'] = ['mpa' => ['de' => str_repeat('a', 64)]]; + $data['warnings'] = ['old warning']; + $data['collisions'] = ['old collision']; + file_put_contents($this->pointerFile(), json_encode($data)); + + $this->assertSame(['build' => $build, 'previous' => null, 'modules' => ['mpa' => ['de']]], ShippedTranslationsBuild::readPointer($this->artifacts)); + $this->assertSame($pointer, ShippedTranslationsBuild::readPointer($this->artifacts)); + $this->assertTrue($this->build(['mpa'])['unchanged'], 'a current.json of the former layout is no reason to rebuild'); + } + + public function testABrokenPoIsServedFromTheServedBuildAndIsNotCountedAsCompiled(): void + { + $this->ship('mfa', 'de', ['greeting' => 'Hallo']); + $this->ship('mfb', 'de', ['greeting' => 'Servus']); + $good = (string) $this->build(['mfa', 'mfb'])['build']; + $this->breakPo('mfa', 'de'); + + $result = $this->build(['mfa', 'mfb']); + + $this->assertNotSame($good, $result['build']); + $this->assertSame(['greeting' => 'Hallo'], $this->servedValues((string) $result['build'], 'mfa', 'de')); + $this->assertSame(['greeting' => 'Servus'], $this->servedValues((string) $result['build'], 'mfb', 'de')); + $this->assertSame(1, $result['compiled'], 'only "mfb"; the catalog of "mfa" is taken over, not compiled'); + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('mfa_de.po', $result['warnings'][0]); + $this->assertStringContainsString($good, $result['warnings'][0], 'names the build it is served from'); + $this->assertSame($result['warnings'], $this->manifest((string) $result['build'])['warnings']); + $this->assertSame(['mfa' => ['de'], 'mfb' => ['de']], ShippedTranslationsBuild::readPointer($this->artifacts)['modules'] ?? null); + $this->assertSame($good, ShippedTranslationsBuild::readPointer($this->artifacts)['previous'] ?? null); + $this->assertFalse(ShippedTranslationsBuild::servesShippedPo($this->artifacts, 'mfa', 'de', $this->root . '/mfa/mfa_de.po'), 'the broken .po is not what is served'); + $this->assertTrue(ShippedTranslationsBuild::servesShippedPo($this->artifacts, 'mfb', 'de', $this->root . '/mfb/mfb_de.po')); + } + + public function testTheFallbackChainsOverSeveralBuildsAndWarnsOnEveryRun(): void + { + $this->ship('mfa', 'de', ['greeting' => 'Hallo']); + $this->ship('mfb', 'de', ['greeting' => 'Servus']); + $this->build(['mfa', 'mfb']); + $this->breakPo('mfa', 'de'); + $second = $this->build(['mfa', 'mfb']); + // another module changes, the broken file stays broken: the catalog of "mfa" comes from the second build + $this->ship('mfb', 'de', ['greeting' => 'Moin']); + + $third = $this->build(['mfa', 'mfb']); + + $this->assertNotSame($second['build'], $third['build']); + $this->assertSame(['greeting' => 'Hallo'], $this->servedValues((string) $third['build'], 'mfa', 'de')); + $this->assertSame(['greeting' => 'Moin'], $this->servedValues((string) $third['build'], 'mfb', 'de')); + $this->assertCount(1, $third['warnings']); + $this->assertFalse(ShippedTranslationsBuild::servesShippedPo($this->artifacts, 'mfa', 'de', $this->root . '/mfa/mfa_de.po')); + // an unchanged run repeats the warning + $this->assertSame($third['warnings'], $this->build(['mfa', 'mfb'])['warnings']); + } + + public function testABrokenPoIsServedFromThePreviousBuildWhenTheServedOneIsDamaged(): void + { + $this->ship('mfa', 'de', ['greeting' => 'Hallo']); + $this->ship('mfb', 'de', ['greeting' => 'Servus']); + $first = (string) $this->build(['mfa', 'mfb'])['build']; + $this->ship('mfb', 'de', ['greeting' => 'Moin']); + $second = (string) $this->build(['mfa', 'mfb'])['build']; + // the C library keeps what it read from a path: damage a file of a path it has not seen + MigratedPoFixture::moveArtifactsToFreshDirectory(); + $this->artifacts = MigratedLanguageFilePaths::shippedArtifactDirectory($this->root); + $damaged = MigratedLanguageFilePaths::buildMoFile($this->buildDirectory($second), 'mfa', 'de'); + $content = (string) file_get_contents($damaged); + file_put_contents($damaged, substr($content, 0, -1) . ($content[-1] === "\x00" ? "\x01" : "\x00")); + $this->breakPo('mfa', 'de'); + + $result = $this->build(['mfa', 'mfb']); + + $this->assertSame(['greeting' => 'Hallo'], $this->servedValues((string) $result['build'], 'mfa', 'de')); + $this->assertStringContainsString($first, $result['warnings'][0], 'the intact previous build, not the damaged served one'); + } + + public function testADamagedCatalogOfTheFormerBuildIsNotTakenOver(): void + { + $this->ship('mfa', 'de', ['greeting' => 'Hallo']); + $good = (string) $this->build(['mfa'])['build']; + MigratedPoFixture::moveArtifactsToFreshDirectory(); + $this->artifacts = MigratedLanguageFilePaths::shippedArtifactDirectory($this->root); + $damaged = MigratedLanguageFilePaths::buildMoFile($this->buildDirectory($good), 'mfa', 'de'); + $content = (string) file_get_contents($damaged); + file_put_contents($damaged, substr($content, 0, -1) . ($content[-1] === "\x00" ? "\x01" : "\x00")); + $this->assertSame(strlen($content), strlen((string) file_get_contents($damaged)), 'precondition: same size, other hash'); + $this->breakPo('mfa', 'de'); + $this->ship('mfb', 'de', ['greeting' => 'Servus']); + + $result = $this->build(['mfa', 'mfb']); + + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('"mfa" is not served for "de"', $result['warnings'][0]); + $this->assertSame(['mfb' => ['de']], ShippedTranslationsBuild::readPointer($this->artifacts)['modules'] ?? null, 'the damaged catalog is not taken over'); + } + + public function testABrokenPoWithoutAFormerBuildIsNotServedAndTheBuildGoesOn(): void + { + $this->ship('mfa', 'de', ['greeting' => 'Hallo']); + $this->ship('mfb', 'de', ['greeting' => 'Servus']); + $this->breakPo('mfb', 'de'); + + $result = $this->build(['mfa', 'mfb']); + + $this->assertNotNull($result['build']); + $this->assertSame(1, $result['compiled']); + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('mfb_de.po', $result['warnings'][0]); + $this->assertStringContainsString('"mfb" is not served for "de"', $result['warnings'][0]); + $this->assertSame(['mfa' => ['de']], ShippedTranslationsBuild::readPointer($this->artifacts)['modules'] ?? null); + $this->assertSame(['greeting' => 'Hallo'], $this->servedValues((string) $result['build'], 'mfa', 'de')); + $this->assertFalse(ShippedTranslationsBuild::servesShippedPo($this->artifacts, 'mfb', 'de', $this->root . '/mfb/mfb_de.po')); + } + + public function testABrokenPoOfANewModuleIsNotServedAndTheBuildIsSwitched(): void + { + $this->ship('mfa', 'de', ['greeting' => 'Hallo']); + $good = (string) $this->build(['mfa'])['build']; + $this->ship('mfb', 'de', ['greeting' => 'Servus']); + $this->breakPo('mfb', 'de'); + $this->ship('mfa', 'de', ['greeting' => 'Moin']); + + $result = $this->build(['mfa', 'mfb']); + + $this->assertNotSame($good, $result['build']); + $this->assertStringContainsString('"mfb" is not served for "de"', $result['warnings'][0] ?? ''); + $pointer = ShippedTranslationsBuild::readPointer($this->artifacts); + $this->assertSame($result['build'], $pointer['build'] ?? null); + $this->assertSame($good, $pointer['previous'] ?? null); + $this->assertSame(['mfa' => ['de']], $pointer['modules'] ?? null); + $this->assertSame(['greeting' => 'Moin'], $this->servedValues((string) $result['build'], 'mfa', 'de')); + } + + /** + * Builds "mfa"/"mfb" and moves the artifacts to a path the C library has not seen (it caches per path). + * + * @return array{string, string} build id, path of the `.mo` + */ + private function formerBuildWithMovedArtifacts(): array + { + $this->ship('mfa', 'de', ['greeting' => 'Hallo']); + $this->ship('mfb', 'de', ['greeting' => 'Servus']); + $good = (string) $this->build(['mfa', 'mfb'])['build']; + MigratedPoFixture::moveArtifactsToFreshDirectory(); + $this->artifacts = MigratedLanguageFilePaths::shippedArtifactDirectory($this->root); + + return [$good, MigratedLanguageFilePaths::buildMoFile($this->buildDirectory($good), 'mfa', 'de')]; + } + + /** + * @param array $changes + */ + private function changeManifest(string $build, array $changes): void + { + file_put_contents( + $this->buildDirectory($build) . '/manifest.json', + json_encode($changes + $this->manifest($build), JSON_THROW_ON_ERROR) + ); + } + + private function assertMfaIsNotServed(): void + { + $result = $this->build(['mfa', 'mfb']); + + $this->assertCount(1, $result['warnings']); + $this->assertStringContainsString('"mfa" is not served for "de"', $result['warnings'][0]); + $this->assertSame(['mfb' => ['de']], ShippedTranslationsBuild::readPointer($this->artifacts)['modules'] ?? null); + } + + public function testACatalogOfABuildCompiledByOtherCodeIsNotTakenOver(): void + { + [$good] = $this->formerBuildWithMovedArtifacts(); + $this->changeManifest($good, ['compatibility' => 'compiled by other code']); + $this->breakPo('mfa', 'de'); + + $this->assertMfaIsNotServed(); + } + + public function testACatalogOfABuildWithoutACompatibilityValueIsNotTakenOver(): void + { + [$good] = $this->formerBuildWithMovedArtifacts(); + $manifest = $this->manifest($good); + unset($manifest['compatibility']); + file_put_contents($this->buildDirectory($good) . '/manifest.json', json_encode($manifest, JSON_THROW_ON_ERROR)); + $this->breakPo('mfa', 'de'); + + $this->assertMfaIsNotServed(); + } + + public function testACatalogWithMarkupThePolicyDisallowsIsNotTakenOver(): void + { + [$good, $mo_file] = $this->formerBuildWithMovedArtifacts(); + // an intact file as far as the manifest tells (size and hash fit), but with a value no build would write + $mo = MigratedPoFixture::catalog('mfa', ['greeting' => 'Hallo'])->toMoString(); + file_put_contents($mo_file, $mo); + $manifest = $this->manifest($good); + $manifest['files'][substr($mo_file, strlen($this->buildDirectory($good)) + 1)] = [strlen($mo), hash('sha256', $mo)]; + file_put_contents($this->buildDirectory($good) . '/manifest.json', json_encode($manifest, JSON_THROW_ON_ERROR)); + $this->breakPo('mfa', 'de'); + + $this->assertMfaIsNotServed(); + } + + public function testACatalogReachedOnlyThroughASymlinkOutOfTheBuildIsNotTakenOver(): void + { + [, $mo_file] = $this->formerBuildWithMovedArtifacts(); + // size and hash fit: only the location tells + $outside = $this->root . '/outside.mo'; + rename($mo_file, $outside); + symlink($outside, $mo_file); + $this->breakPo('mfa', 'de'); + + $this->assertMfaIsNotServed(); + } } diff --git a/components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php b/components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php index b6ab39c740d7..a6564a666f2c 100644 --- a/components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php +++ b/components/ILIAS/Language/tests/Setup/ShippedLanguageFilesCompiledObjectiveTest.php @@ -28,8 +28,8 @@ /** * ShippedLanguageFilesCompiledObjective builds every shipped `.po` for native gettext (see ShippedTranslationsBuild): - * an intact build is kept, a damaged one rebuilt, obsolete files removed, markup cleanup or a failed `.po` only - * warns, and nothing is ever written outside the artifact directory. + * an intact build is kept, a damaged one rebuilt, obsolete files removed, markup cleanup only warns, a `.po` that cannot be compiled is served from + * the former build, and nothing is ever written outside the artifact directory. */ class ShippedLanguageFilesCompiledObjectiveTest extends TestCase { @@ -235,9 +235,34 @@ public function testRepeatsTheMarkupWarningOnASubsequentUnchangedRun(): void } /** - * A syntactically broken `.po` is reported and its artifact removed; other modules are still compiled. + * A broken `.po` is reported and served from the former build; the fallback is not counted as compiled. */ - public function testABrokenPoIsReportedAndDoesNotAbortTheBuild(): void + public function testABrokenPoIsReportedAndServedFromTheFormerBuild(): void + { + $this->writeShippedPo('broken/', 'broken', 'de', ['greeting' => 'Funktioniert']); + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); + $manager = $this->directoryManager( + MigratedPoFixture::directory('broken', 'broken/'), + MigratedPoFixture::directory('itest', 'itest/') + ); + $this->achieve($manager); + file_put_contents($this->root . '/broken/broken_de.po', "msgid \"kaputt\n"); + $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo, neu']); + + $messages = []; + $io = $this->informer($messages); + $this->achieve($manager, $io); + + $warning = implode("\n", $messages); + $this->assertStringContainsString('WARNING: Could not compile', $warning); + $this->assertStringContainsString('broken', $warning); + $this->assertSame(['greeting' => 'Funktioniert'], MigratedPoFixture::readMo($this->artifactPath('de', 'broken'))); + $this->assertSame(['greeting' => 'Hallo, neu'], MigratedPoFixture::readMo($this->artifactPath('de', 'itest'))); + $this->assertStringContainsString(', 1 compiled', $messages[array_key_last($messages)]); + } + + /** Without a former build a broken `.po` is only reported and its module not served for that language. */ + public function testABrokenPoWithoutAFormerBuildIsReportedAndNotServed(): void { mkdir($this->root . '/broken', 0775, true); file_put_contents($this->root . '/broken/broken_de.po', "msgid \"kaputt\n"); @@ -253,9 +278,9 @@ public function testABrokenPoIsReportedAndDoesNotAbortTheBuild(): void $warning = implode("\n", $messages); $this->assertStringContainsString('WARNING: Could not compile', $warning); - $this->assertStringContainsString('broken', $warning); + $this->assertStringContainsString('"broken" is not served for "de"', $warning); $this->assertFileDoesNotExist($this->artifactPath('de', 'broken')); - $this->assertFileExists($this->artifactPath('de', 'itest')); + $this->assertSame(['greeting' => 'Hallo'], MigratedPoFixture::readMo($this->artifactPath('de', 'itest'))); } /** @@ -402,20 +427,21 @@ public function testATouchedButOtherwiseIdenticalPoKeepsTheBuild(): void } /** - * A .po that cannot even be hashed (unreadable) is a generic \Throwable and must not abort the build either. + * A .po that cannot even be read is treated like a broken one. */ - public function testAnUnreadablePoIsReportedAndDoesNotAbortTheBuild(): void + public function testAnUnreadablePoIsReportedAndServedFromTheFormerBuild(): void { if (posix_getuid() === 0) { $this->markTestSkipped('Cannot make a file unreadable while running as root; skipping.'); } $this->writeShippedPo('itest/', 'itest', 'de', ['greeting' => 'Hallo']); - $this->writeShippedPo('unreadable/', 'unreadable', 'de', ['greeting' => 'Hallo']); - chmod($this->root . '/unreadable/unreadable_de.po', 0000); + $this->writeShippedPo('unreadable/', 'unreadable', 'de', ['greeting' => 'Lesbar']); $manager = $this->directoryManager( MigratedPoFixture::directory('itest', 'itest/'), MigratedPoFixture::directory('unreadable', 'unreadable/') ); + $this->achieve($manager); + chmod($this->root . '/unreadable/unreadable_de.po', 0000); try { $messages = []; @@ -428,6 +454,7 @@ public function testAnUnreadablePoIsReportedAndDoesNotAbortTheBuild(): void $warning = implode("\n", $messages); $this->assertStringContainsString('WARNING: Could not compile', $warning); $this->assertStringContainsString('unreadable', $warning); + $this->assertSame(['greeting' => 'Lesbar'], MigratedPoFixture::readMo($this->artifactPath('de', 'unreadable'))); $this->assertFileExists($this->artifactPath('de', 'itest')); } @@ -570,15 +597,22 @@ public function testAnInvalidShippedFileNamePatternWarnsAndDoesNotAbortTheBuild( $this->assertFileExists($this->artifactPath('de', 'itest')); } - public function testIsNotableAndHasNoPreconditions(): void + public function testIsNotableAndNeedsTheGettextExtension(): void { + // read by the message of the condition only + if (!defined('ILIAS_VERSION_NUMERIC')) { + define('ILIAS_VERSION_NUMERIC', '12.0'); + } $objective = new ShippedLanguageFilesCompiledObjective( $this->directoryManager(), $this->root ); $this->assertTrue($objective->isNotable()); - $this->assertSame([], $objective->getPreconditions($this->createStub(Environment::class))); $this->assertTrue($objective->isApplicable($this->createStub(Environment::class))); + $preconditions = $objective->getPreconditions($this->createStub(Environment::class)); + $this->assertCount(1, $preconditions); + $this->assertInstanceOf(\ILIAS\Setup\Condition\PHPExtensionLoadedCondition::class, $preconditions[0]); + $this->assertStringContainsString('gettext', $preconditions[0]->getLabel()); } } From 301b111c345c5682196912d8b1e8331e3ec12d52 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 17:52:30 +0200 Subject: [PATCH 064/103] Language: Keep the remarks of the overlay in line with the imported file An import without a remark keeps the stored remark of a singular entry of a module maintained in PO files instead of clearing it in `lng_data` only, and the "delete" mode drops the former overlay remarks while keeping the imported ones (`ilObjLanguageExt`). --- .../classes/class.ilObjLanguageExt.php | 29 ++++++++---- ...UsesShippedValuesOfMigratedModulesTest.php | 44 +++++++++++++++++++ .../tests/SaveValuesMergesOntoOverlayTest.php | 3 +- .../tests/SaveValuesOverlayRemarksTest.php | 24 ++++++++++ ...MigratedFilesAfterDeleteModeImportTest.php | 32 +++++++++++++- 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php index d6ab66f48bc2..8a8fb4378270 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php @@ -738,17 +738,18 @@ public function importLanguageFile( } } // "delete" wiped every module: $to_save is each module's complete content, not merged onto the overlay - $modules_with_unwritten_overlay = self::saveValues( + $saved = self::saveValues( $this->key, $to_save, $file_comments, $a_mode_existing !== "delete" ); + $modules_with_unwritten_overlay = $saved['unwritten']; if ($a_mode_existing === "delete") { $modules_with_unwritten_overlay = array_merge( $modules_with_unwritten_overlay, - $this->syncMigratedFilesAfterDeleteModeImport($modules_before_delete, $to_save) + $this->syncMigratedFilesAfterDeleteModeImport($modules_before_delete, $to_save, $saved['remarks']) ); } @@ -762,11 +763,13 @@ public function importLanguageFile( * * @param list $modules_before_delete every module this language had any lng_data row for, captured before the DELETE * @param array $to_save module.separator.topic => value, the complete final DB content now + * @param array> $remarks module => identifier => remark, the complete remarks now * @return list the modules whose overlay could not be written */ private function syncMigratedFilesAfterDeleteModeImport( array $modules_before_delete, - array $to_save + array $to_save, + array $remarks ): array { global $DIC; @@ -793,7 +796,10 @@ private function syncMigratedFilesAfterDeleteModeImport( $this->key, (string) $module, $entries_by_module[$module] ?? [], - $client_data_dir + $client_data_dir, + false, + null, + $remarks[$module] ?? [] ); } catch (\Throwable $t) { $DIC->logger()->forComponent('lang')->warning(sprintf( @@ -1160,13 +1166,14 @@ public static function _saveValues( array $a_values = array(), array $a_remarks = array() ): array { - return self::saveValues($a_lang_key, $a_values, $a_remarks, true); + return self::saveValues($a_lang_key, $a_values, $a_remarks, true)['unwritten']; } /** * With $merge_onto_current_content `false` the saved entries are each module's complete new content ("delete"-mode import). * - * @return list see _saveValues() + * @return array{unwritten: list, remarks: array>} unwritten: see + * _saveValues(); remarks: the non-empty remarks written into the overlays */ private static function saveValues( string $a_lang_key, @@ -1289,7 +1296,8 @@ private static function saveValues( ? MigratedLanguageFileSync::databaseValues([$key => $value], $shipped_migrated['values'])[$key] : $value, $local_change, - $a_remarks[$key] ?? null + // PO module: without a requested remark the stored one stays, as in its overlay + $a_remarks[$key] ?? ($is_po_module ? ($db_comments[$key] ?? null) : null) ); } } @@ -1308,6 +1316,11 @@ private static function saveValues( } } + $remarks = array_filter(array_map( + static fn(array $changes): array => array_filter($changes, static fn(string $remark): bool => $remark !== ''), + $remark_changes + )); + // save the serialized module entries in lng_modules $modules_with_unwritten_overlay = []; foreach ($save_array as $module => $entries) { @@ -1335,7 +1348,7 @@ static function () use ($a_lang_key, $module, $entries, $merge_onto_current_cont ilCachedLanguage::getInstance($a_lang_key)->flush(); - return $modules_with_unwritten_overlay; + return ['unwritten' => $modules_with_unwritten_overlay, 'remarks' => $remarks]; } /** diff --git a/components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php b/components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php index 80ba0b6cec57..745e061fb05b 100644 --- a/components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php +++ b/components/ILIAS/Language/tests/ImportUsesShippedValuesOfMigratedModulesTest.php @@ -386,6 +386,50 @@ public function testAnImportedCommentEqualToTheShippedNoteIsNoLocalRemark(): voi $this->assertNotContains('Notiz des Entwicklers', $this->replaced_remarks); } + /** "Delete" mode wipes lng_data and with it the overlay's remarks: only the imported ones remain. */ + public function testDeleteModeImportDropsTheFormerOverlayRemarksAndKeepsTheImportedOnes(): void + { + $this->seedGlobalLanguageFile([]); + $overlay = MigratedPoFixture::catalog('itest', ['greeting' => 'Lokal', 'farewell' => 'Tschüss aus der PO']); + \ILIAS\Language\ComponentTranslation\LocalChangeComments::setRemark($overlay->find('itest', 'greeting'), 'Alte Bemerkung'); + \ILIAS\Language\ComponentTranslation\LocalChangeComments::setRemark($overlay->find('itest', 'farewell'), 'Alte Bemerkung 2'); + MigratedPoFixture::writePair($this->overlayDirectory() . '/itest_' . self::LANG, $overlay); + $this->assertSame('Alte Bemerkung 2', $this->overlayRemark('farewell'), 'precondition'); + file_put_contents( + $this->upload_file, + "\nitest#:#greeting#:#Lokal###Importierte Bemerkung\nitest#:#farewell#:#Tschüss aus der PO\n" + ); + + $this->languageObject()->importLanguageFile($this->upload_file, 'delete'); + + $this->assertSame('Importierte Bemerkung', $this->overlayRemark('greeting')); + $this->assertNull($this->overlayRemark('farewell'), 'not imported, so gone with the wiped lng_data'); + } + + /** The remark of a plural message is one for the whole message: imported with its forms, it replaces the former one. */ + public function testDeleteModeImportReplacesTheOverlayRemarkOfAPluralMessage(): void + { + $this->seedGlobalLanguageFile([]); + $catalog = MigratedPoFixture::catalog('itest', []); + $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);'); + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry('itest', 'item'); + $entry->setPlural('items', ['Eintrag', 'Einträge']); + $catalog->add($entry); + MigratedPoFixture::writePo($this->fixture_directory . '/itest_' . self::LANG . '.po', $catalog); + $overlay = clone $catalog; + \ILIAS\Language\ComponentTranslation\LocalChangeComments::setRemark($overlay->find('itest', 'item'), 'Alte Bemerkung'); + MigratedPoFixture::writePair($this->overlayDirectory() . '/itest_' . self::LANG, $overlay); + $this->assertSame('Alte Bemerkung', $this->overlayRemark('item'), 'precondition'); + file_put_contents( + $this->upload_file, + "\nitest#:#item [0]#:#Eintrag###Neue Bemerkung\nitest#:#item [1]#:#Einträge###Neue Bemerkung\n" + ); + + $this->languageObject()->importLanguageFile($this->upload_file, 'delete'); + + $this->assertSame('Neue Bemerkung', $this->overlayRemark('item')); + } + /** "Delete" mode wiped lng_data: every file line is written for a migrated module too, a deviation from the shipped value is a local change. */ public function testDeleteModeImportWritesEveryRowOfAMigratedModule(): void { diff --git a/components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php b/components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php index 6842c2d4ba8b..a0bbda5403c0 100644 --- a/components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php +++ b/components/ILIAS/Language/tests/SaveValuesMergesOntoOverlayTest.php @@ -196,6 +196,7 @@ private function seedEmptyGlobalLanguageFile(string $lang_key): void $cache->setValue(null, $existing + [$lang_key => $fake_global_file]); } + /** @return list the modules whose overlay could not be written */ private function callSaveValues( string $lang_key, array $values, @@ -204,7 +205,7 @@ private function callSaveValues( ): array { return (new ReflectionClass(ilObjLanguageExt::class)) ->getMethod('saveValues') - ->invoke(null, $lang_key, $values, $remarks, $merge_onto_current_content); + ->invoke(null, $lang_key, $values, $remarks, $merge_onto_current_content)['unwritten']; } /** `->never()` on query(): once a readable overlay is found the database must not be queried at all. */ diff --git a/components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php b/components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php index 1ee23dd867c3..0b58d4202493 100644 --- a/components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php +++ b/components/ILIAS/Language/tests/SaveValuesOverlayRemarksTest.php @@ -215,6 +215,30 @@ public function testPureRemarkChangeOnASingularIdentifierUpdatesRemarksAloneAndW $this->assertSame('Bitte prüfen', $this->overlayRemark('greeting'), 'mirrored into the overlay'); } + /** Saving only a new value of a PO module's singular message (no remark requested) keeps the stored remark in lng_data. */ + public function testChangingOnlyTheValueOfASingularIdentifierKeepsItsStoredRemark(): void + { + $this->seedShippedModule(); + $lng = $this->stubLng(); + $this->seedEmptyGlobalLanguageFile(self::LANG_KEY); + $replace_calls = []; + $manipulate_calls = []; + $this->mockDatabaseForWrites($replace_calls, $manipulate_calls, [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'Gespeicherte Bemerkung'], + ]); + + ilObjLanguageExt::_saveValues( + self::LANG_KEY, + [self::MODULE . $lng->separator . 'greeting' => 'Hallo, neu'], + [] + ); + + $greeting_replace_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'greeting')); + $this->assertCount(1, $greeting_replace_calls); + $this->assertSame('Hallo, neu', $greeting_replace_calls[0][1]['value']); + $this->assertSame('Gespeicherte Bemerkung', $greeting_replace_calls[0][1]['remarks']); + } + /** A remark that is not valid UTF-8 is dropped with a warning; the save still succeeds. */ public function testSaveValuesDropsAnInvalidUtf8RemarkWithoutSavingItAnywhere(): void { diff --git a/components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php b/components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php index 1b601907b7d3..9cfe714f1603 100644 --- a/components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php +++ b/components/ILIAS/Language/tests/SyncMigratedFilesAfterDeleteModeImportTest.php @@ -236,15 +236,16 @@ private function overlayMoExists(string $module, string $lang_key): bool /** * @param array $to_save module.separator.topic => value, the shape importLanguageFile() passes to _saveValues() + * @param array> $remarks module => identifier => remark */ - private function invoke(array $modules_before_delete, array $to_save): void + private function invoke(array $modules_before_delete, array $to_save, array $remarks = []): void { $instance = (new ReflectionClass(ilObjLanguageExt::class))->newInstanceWithoutConstructor(); $instance->key = 'de'; $instance->separator = '#:#'; $method = new ReflectionMethod(ilObjLanguageExt::class, 'syncMigratedFilesAfterDeleteModeImport'); - $method->invoke($instance, $modules_before_delete, $to_save); + $method->invoke($instance, $modules_before_delete, $to_save, $remarks); } /** A module that existed before the wipe but is absent from $to_save is synced with an empty map; the shipped `.po` stays untouched. */ @@ -281,6 +282,33 @@ public function testSyncsAModulePresentInTheImportWithItsToSaveSubset(): void $this->assertSame('Hallo', $shipped_translation->getTranslation(), 'the shipped .po must never be touched'); } + /** @return array identifier => remark in the overlay of $module */ + private function overlayRemarks(string $module): array + { + return \ILIAS\Language\ComponentTranslation\MigratedLanguageFileSync::loadRemarks( + $GLOBALS['DIC'][LanguageFileDirectoryManager::class], + 'de', + $module, + CLIENT_DATA_DIR + ) ?? []; + } + + /** The remarks of a module are exactly the passed ones: those of the import are written, former ones of a module without are dropped. */ + public function testWritesThePassedRemarksOfEachModuleAndDropsTheFormerOnesOfTheOthers(): void + { + $with = $this->seedFixtureModule('dtest', 'de', ['greeting' => 'Hallo']); + $without = $this->seedFixtureModule('ktest', 'de', ['greeting' => 'Hallo']); + $this->registerDirectoryManager($with, $without); + $to_save = ['dtest#:#greeting' => 'Hallo, neu', 'ktest#:#greeting' => 'Hallo, neu']; + $this->invoke(['dtest', 'ktest'], $to_save, ['dtest' => ['greeting' => 'Alt'], 'ktest' => ['greeting' => 'Alt']]); + $this->assertSame(['greeting' => 'Alt'], $this->overlayRemarks('ktest'), 'precondition'); + + $this->invoke(['dtest', 'ktest'], $to_save, ['dtest' => ['greeting' => 'Importiert']]); + + $this->assertSame(['greeting' => 'Importiert'], $this->overlayRemarks('dtest')); + $this->assertSame([], $this->overlayRemarks('ktest')); + } + public function testDoesNotThrowForANonMigratedModuleMissingFromTheImport(): void { $this->registerDirectoryManager(); From 395ecce2fad22c19a17c8458a9390d23589ca5e5 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 17:52:30 +0200 Subject: [PATCH 065/103] Language: Fix the setup warning about the missing client data directory Without the client data directory `ilLanguagesInstalledAndUpdatedObjective` cannot write the overlay of local changes; the web then serves the shipped texts, not values from the database as the warning claimed. --- ...class.ilLanguagesInstalledAndUpdatedObjective.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php index 6bca297684bd..66d309c26a6d 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php @@ -217,8 +217,8 @@ protected function informAboutInvalidMarkupInLocalChanges(Setup\Environment $env } /** - * Typically during "setup install", before the client directory exists: the PO/MO files cannot be - * written and the texts come from the database until the next "setup update". + * Typically during "setup install", before the client directory exists: the overlay of local changes + * cannot be written, the web serves the shipped values (the build) until the next "setup update". * * @param list $language_keys */ @@ -234,10 +234,10 @@ protected function informAboutMissingClientDataDirectory(Setup\Environment $envi $this->inform( $environment, - 'WARNING: The client data directory is not known or does not exist yet, so the PO/MO language ' - . 'files of modules maintained in PO files are not written in this run - these modules are served ' - . 'from the database until then. Run "setup update" (as the web server user) once the client data ' - . 'directory exists.' + 'WARNING: The client data directory is not known or does not exist yet, so the local changes ' + . '(e.g. lang/*.lang.local) of modules maintained in PO files are not written to their overlay in ' + . 'this run - these modules show their shipped texts without local changes until then. Run "setup ' + . 'update" (as the web server user) once the client data directory exists.' ); } From d4bf6e68a7bcc3c6e8e3446f9ea3a8d56e54f69e Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 17:52:30 +0200 Subject: [PATCH 066/103] Language: Make `convert_module_to_po.php` write all files or none The converter checks the encoding, reads back every file before writing and checks the lines to remove from the `.lang` files up front, so a failing run writes nothing. `` is required, and like the installer only the part after the start marker of a `.lang` file is read. --- .../tests/ConvertModuleToPoToolTest.php | 108 +++++++++- .../Language/tools/po-migration/README.md | 69 ++++-- .../po-migration/convert_module_to_po.php | 201 +++++++++++++----- 3 files changed, 300 insertions(+), 78 deletions(-) diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php index dfeab05ce859..806ee989c6bf 100644 --- a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -102,8 +102,12 @@ private function writePluralsConfig(string $fixture_root, array $plural_forms, a ); } + /** The marker is put in front if $content has none: like the installer, the tool reads only such files. */ private function writeLangFile(string $fixture_root, string $lang_key, string $content): void { + if (!str_contains($content, self::START)) { + $content = self::START . "\n" . $content; + } file_put_contents($fixture_root . '/lang/ilias_' . $lang_key . '.lang', $content); } @@ -678,7 +682,7 @@ public function testRemoveFromLangRemovesExactlyTheModulesLinesAndKeepsEveryOthe $this->assertSame("# header\n$start\ncrsr#:#a#:#Other\nxcrs#:#a#:#X\nmeta#:#crs#:#crs#:#M\n", $after['ilias_de.lang'][0]); $this->assertSame("# header\r\n$start\r\ncrsr#:#a#:#R\r\n", $after['ilias_fr.lang'][0]); $this->assertSame("$start\ncrsr#:#a#:#R", $after['ilias_en.lang'][0], 'no final line break stays none'); - $this->assertSame("keep#:#k#:#K\n", $after['ilias_es.lang'][0]); + $this->assertSame("$start\nkeep#:#k#:#K\n", $after['ilias_es.lang'][0]); $this->assertSame($before['ilias_it.lang'], $after['ilias_it.lang']); $this->assertSame($before['ilias_ABC.lang'], $after['ilias_ABC.lang']); $this->assertSame(0640, $after['ilias_de.lang'][1] & 07777); @@ -715,22 +719,114 @@ public function testRemoveFromLangAlsoRemovesTheLeftOutLines(): void $this->assertSame(['a', 'b'], self::idsOf($out . '/crs_fr.po')); } - /** A module line above the start marker is never removed: the run then changes no .lang file at all. */ - public function testRemoveFromLangChangesNoFileWhenOneFileHoldsALineThatWouldNotBeRemoved(): void + /** Like the installer, the tool reads only behind the start marker: a line above it is neither converted nor removed. */ + public function testALineAboveTheStartMarkerIsNeitherConvertedNorRemoved(): void { $root = $this->buildFixtureRepo(); $out = $this->addRegisteredComponent($root, 'crs'); $start = self::START; + // the header line would fail twice if it was read: a key the reference language lacks, a Latin-1 byte + $header = "crs#:#x#:#Kopfzeile \xFC\n"; $this->writeLangFile($root, 'de', "$start\ncrs#:#a#:#A\ncrs#:#b#:#B\n"); - $this->writeLangFile($root, 'fr', "crs#:#a#:#FA\n$start\ncrs#:#b#:#FB\n"); - $this->writeLangFile($root, 'en', "$start\ncrs#:#a#:#EA\n"); + $this->writeLangFile($root, 'fr', "$header$start\ncrs#:#b#:#FB\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $after = self::langSnapshot($root); + $this->assertSame("$header$start\n", $after['ilias_fr.lang'][0]); + $this->assertSame("$start\n", $after['ilias_de.lang'][0]); + $ids = self::idsOf($out . '/crs_fr.po'); + sort($ids); + $this->assertSame(['a', 'b'], $ids, '"x" of the header line is no key'); + $this->assertSame('FB', TranslationCatalog::fromPoFile($out . '/crs_fr.po')->find(null, 'b')?->getTranslation()); + } + + /** Like the installer, a file without the start marker is not read; --remove-from-lang refuses to run then. */ + public function testAFileWithoutTheStartMarkerIsSkippedAndRefusedByRemoveFromLang(): void + { + $root = $this->buildFixtureRepo(); + $out = $this->addRegisteredComponent($root, 'crs'); + $this->writeLangFile($root, 'de', self::START . "\ncrs#:#a#:#A\n"); + file_put_contents($root . '/lang/ilias_fr.lang', "crs#:#a#:#FA\ncrs#:#x#:#X\n"); $before = self::langSnapshot($root); [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out); $this->assertSame(1, $exit_code, $stdout . $stderr); - $this->assertStringContainsString('ilias_fr.lang: 2 line(s) of the module were read, but 1 would be removed', $stderr); + $this->assertStringContainsString('ilias_fr.lang', $stderr); + $this->assertStringContainsString('nothing was written', $stderr); $this->assertSame($before, self::langSnapshot($root)); + $this->assertSame([], glob($out . '/*') ?: []); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'crs', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('WARNING: skipping ' . $root . '/lang/ilias_fr.lang', $stderr); + $this->assertFileDoesNotExist($root . '/out/crs_fr.po', '"x" would have failed the run if the file was read'); + $this->assertSame($before, self::langSnapshot($root)); + } + + /** A .lang file with bytes that are no UTF-8 fails the run before anything is written: no .po/.pot replaced, no .lang changed. */ + public function testALatinOneLangFileFailsTheRunAndKeepsPoAndLangFilesByteIdentical(): void + { + $root = $this->buildFixtureRepo(); + $out = $this->addRegisteredComponent($root, 'crs'); + $start = self::START; + $this->writeLangFile($root, 'de', "$start\ncrs#:#a#:#Gr\xC3\xBC\xC3\x9Fe\n"); + $this->writeLangFile($root, 'fr', "$start\ncrs#:#a#:#Gr\xFC\xDFe\n"); + file_put_contents($out . '/crs_de.po', "existing de\n"); + file_put_contents($out . '/crs_fr.po', "existing fr\n"); + file_put_contents($out . '/crs.pot', "existing pot\n"); + $lang_before = self::langSnapshot($root); + $out_before = self::files($out); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('not valid UTF-8', $stderr); + $this->assertStringContainsString('fr: a', $stderr); + $this->assertSame($lang_before, self::langSnapshot($root)); + $this->assertSame($out_before, self::files($out)); + } + + /** An existing target .po that cannot be read only costs its kept headers: a WARNING, the run goes on and replaces it. */ + public function testAnUnreadableExistingPoIsWarnedAboutAndReplaced(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "crs#:#a#:#A\n"); + file_put_contents($root . '/out/crs_de.po', "msgid \"kaputt\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'crs', 'de', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('WARNING: the existing ' . $root . '/out/crs_de.po is not readable', $stderr); + $this->assertSame(['a'], self::idsOf($root . '/out/crs_de.po')); + } + + /** @return array}> */ + public static function runsWithoutAModule(): array + { + return [ + 'no argument' => [[]], + 'only options' => [['--skip-unmigratable-keys']], + ]; + } + + /** There is no default module any more: a run without one fails and writes nothing. */ + #[DataProvider('runsWithoutAModule')] + public function testARunWithoutAModuleFailsAndWritesNothing(array $arguments): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'en', self::START . "\ntos#:#a#:#A\n"); + $before = self::langSnapshot($root); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), ...$arguments); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString('the module is required', $stderr); + $this->assertSame($before, self::langSnapshot($root)); + $this->assertSame([], glob($root . '/out/*') ?: []); } /** A second run must not overwrite the now only-source .po/.pot with empty ones. */ diff --git a/components/ILIAS/Language/tools/po-migration/README.md b/components/ILIAS/Language/tools/po-migration/README.md index 9bea19c31818..abe48450659c 100644 --- a/components/ILIAS/Language/tools/po-migration/README.md +++ b/components/ILIAS/Language/tools/po-migration/README.md @@ -18,11 +18,25 @@ no named classes or functions, so they never end up in Composer's classmap. Generates the initial shipped `.pot` and `.po` files of one module from the lines of that module in `lang/ilias_*.lang` (never write them by hand). After -the migration the `.po`/`.pot` files are maintained directly. A new run -regenerates them from the `.lang` lines and overwrites values merged into the -`.po` by the language administration ("merge"), keys added there and their -`#.` lines; after `--remove-from-lang` it fails, since the module has no -`.lang` lines any more. +the migration the `.po`/`.pot` files are maintained directly. A migration +removes the module's `.lang` lines (`--remove-from-lang`), so a new run fails +("Nothing was written") and the `.po`/`.pot` files stay as they are. Without +that option a new run would regenerate them from the `.lang` lines and +overwrite values merged into the `.po` by the language administration +("merge"), keys added there and their `#.` lines. + +Order of a migration (see the +[Language component's README](../../README.md#migrating-a-module)): + +1. contribute `$contribute[LanguageFileDirectory::class]` in the component's + `.php`, +2. run this tool with `--remove-from-lang` and the component's `lang/` + directory as `outputDir`, +3. `composer dump-autoload` (or `php cli/setup.php build`) and + `php cli/setup.php update`. + +Rollback: revert the migration with git (it restores the `.lang` lines), then +step 3 again. ``` php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php \ @@ -32,7 +46,8 @@ php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php \ Arguments: -* ``: the language module (`lng_data.module`), `[A-Za-z0-9_]` only. +* `` (required): the language module (`lng_data.module`), + `[A-Za-z0-9_]` only. * `referenceLanguage`: default `en`. It is the template of every language: it gives the `msgid`s, their order and the `.pot`. * `outputDir`: default `tools/po-migration/output//` (ignored by git). @@ -55,9 +70,13 @@ Options: before anything is read: `outputDir` must be an existing `components///lang` whose `.php` already contributes the module's `ComponentLanguageFileDirectory` (literal arguments, - same pattern), and no `lang/ilias_.lang` may be a symbolic link. If one - file cannot be processed, no `.lang` file is changed. Afterwards the `.po` - files are the only source of the module; a second run for it fails. + same pattern), and no `lang/ilias_.lang` may be a symbolic link or lack + the start marker. The new content of every `.lang` file is computed and + checked while reading, before anything is written. After the `.po` files are + written, each `.lang` file is only checked to be unchanged since it was read; + if one was changed meanwhile, no `.lang` file is changed (the `.po` files + stay written). Afterwards the `.po` files are the only source of the module; + a second run for it fails. Unknown options fail, so a typo is never taken as module name or directory. @@ -70,8 +89,11 @@ php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php --remo ### Input -* Every `lang/ilias_.lang` of the ILIAS root, lines trimmed like the - installer does. Component and customizing `.lang` files are not read. +* Every `lang/ilias_.lang` of the ILIAS root, like the installer only + behind the line `` and with trimmed lines. A file + without that line is skipped with a `WARNING` (the installer ignores it); + with `--remove-from-lang` the run fails. The module's lines must be valid + UTF-8. Component and customizing `.lang` files are not read. * `plurals.json` next to the tool (see below). Without it no `Plural-Forms` header and no plural message is written (a note is printed). * For the `Language-Team` header: `meta_l_` of `lang/ilias_en.lang`, or of @@ -97,20 +119,25 @@ php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php --remo ### Checks -Nothing is written if a language has a key the reference language lacks, an -empty key, a key with ` [` or a duplicate key (unless -`--skip-unmigratable-keys`), if a plural definition of `plurals.json` does not -fit the module, or if the module has no line in any `.lang` file. After writing, -every `.po` is read again with the strict loader and compared with its source: -every value, every plural form and the default form of a plural message (exit -code 1 on any difference). The tool reads and writes through the same adapter -as the language component (`TranslationCatalog`), so it writes exactly what -Setup and the administration read. +Nothing is written (exit code 1) if a language has a key the reference +language lacks, an empty key, a key with ` [` or a duplicate key (unless +`--skip-unmigratable-keys`), if a line of the module is not valid UTF-8, if a +plural definition of `plurals.json` does not fit the module, or if the module +has no line in any `.lang` file. Before writing, every `.po` is read back in +memory with the strict loader and compared with its source: every value, +every plural form and the default form of a plural message; on any difference +nothing is written either. Only a failure while writing (e.g. no permission) +or a `.lang` file changed by someone else during the run can leave the +`.po`/`.pot` files written without the `.lang` lines removed; the message +names the files. An existing target `.po` that cannot be read only loses its +headers (`WARNING`). The tool reads and writes through the same adapter as the +language component (`TranslationCatalog`), so it writes exactly what Setup and +the administration read. stdout lists per language the module's own lines (`entries`), those with a "new variable" marker (`fuzzy`), the entries filled from the reference language (`filled`) and the result of the round trip (`po_ok`). Exit code 0 means every -file was written and verified. +file was verified and written. Before committing generated files, compare the placeholders (`%s`, `[x]`) of each language with the reference language. diff --git a/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php index bef4fbe84c5e..623bdb5c6f0c 100644 --- a/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php +++ b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php @@ -63,11 +63,16 @@ * left out keys are not part of the round-trip check * --remove-from-lang only after a completely successful run (every file written and round-tripped): remove every * line of the module - the left out ones included - from every lang/ilias_.lang read (atomically - * per file, all other bytes unchanged; if one file cannot be processed, none is changed). Requires + * per file, all other bytes unchanged; computed and checked before anything is written). Requires * (checked first) an existing outputDir components///lang whose .php - * already contributes the module's ComponentLanguageFileDirectory, and no symbolic link in lang/ - * Unknown options (anything starting with "-") fail; module and reference language must match [A-Za-z0-9_]+. - * A module without any line in lang/ fails without writing anything (most likely migrated already). + * already contributes the module's ComponentLanguageFileDirectory, no symbolic link in lang/ and the + * start marker in every lang/ilias_.lang + * is required. Unknown options (anything starting with "-") fail; module and reference language must match + * [A-Za-z0-9_]+. Like the installer, a `.lang` file is only read behind its line ""; a + * file without that line is skipped with a WARNING (a FAILURE with --remove-from-lang). A module without any line in + * lang/ fails without writing anything (most likely migrated already). Every check runs before anything is + * written: a failing run writes no `.po`/`.pot` and changes no `.lang` file (except on write errors or a `.lang` + * file changed during the run, both reported). * Example: php convert_module_to_po.php tos en ../../../TermsOfService/lang */ @@ -105,14 +110,40 @@ return $content; }; +/** + * The lines of $content the installer reads (see LanguageFileParsing::cutHeader()). + * + * @return array line number (0-based) => line, split at "\n" only like file() + */ +$body_lines = static function (string $content): array { + $lines = explode("\n", $content); + foreach ($lines as $number => $line) { + if (trim($line) === '') { + return array_slice($lines, $number + 1, null, true); + } + } + + return []; +}; + +$has_start_marker = static function (string $content): bool { + foreach (explode("\n", $content) as $line) { + if (trim($line) === '') { + return true; + } + } + + return false; +}; + /** * @param string $content of the `.lang` file $file (only named in warnings) * @return list [module, key, raw value] of every entry line */ -$read_lines = static function (string $file, string $content): array { +$read_lines = static function (string $file, string $content) use ($body_lines): array { $entries = []; - // split at "\n" only, like file() (a "\r" before it is trimmed below) - foreach (explode("\n", $content) as $number => $line) { + // a "\r" before the "\n" is trimmed below + foreach ($body_lines($content) as $number => $line) { // trimmed like ILIAS' own .lang reader, or a value would keep trailing whitespace the database never had $parts = explode('#:#', trim($line)); if (count($parts) < 3) { @@ -208,8 +239,8 @@ /** * $content without the lines of $module - exactly those $read_lines reports, and only behind the line - * "" (the installer ignores everything before it; without that line the whole file - * counts as body). Every other byte stays: header, comments, line endings, order, a missing final line break. + * "" (the installer ignores everything before it; only called for files with that line). + * Every other byte stays: header, comments, line endings, order, a missing final line break. * * @return array{0: string, 1: int} the new content and the number of removed lines */ @@ -499,7 +530,8 @@ $existing_headers = static function (string $file): array { try { return is_file($file) ? TranslationCatalog::fromPoFile($file)->getHeaders() : []; - } catch (RuntimeException) { + } catch (RuntimeException $e) { + fwrite(STDERR, "WARNING: the existing $file is not readable, its headers are not kept: {$e->getMessage()}\n"); return []; } }; @@ -538,7 +570,12 @@ } $arguments[] = $argument; } -$module = $arguments[0] ?? 'tos'; +if (!isset($arguments[0])) { + fwrite(STDERR, "FAILURE: the module is required. Usage: php convert_module_to_po.php [--pattern=]" + . " [--skip-unmigratable-keys] [--remove-from-lang] [referenceLangKey] [outputDir]\n"); + exit(1); +} +$module = $arguments[0]; $reference_lang_key = $arguments[1] ?? 'en'; // a module name is a lng_data module: it names the output directory and the files if (preg_match('/\A[A-Za-z0-9_]+\z/', $module) !== 1 || preg_match('/\A[A-Za-z0-9_]+\z/', $reference_lang_key) !== 1) { @@ -601,8 +638,10 @@ } $per_language = []; -// lang_key => path, hash and number of the module's lines of every file read (for --remove-from-lang) +// lang_key => path, hash, number of the module's lines and the content without them of every file read $read_files = []; +$files_without_marker = []; +$removal_problems = []; // lang_key => identifier => whether all occurrences are the same, see $find_duplicate_keys $duplicates = []; $unsafe_files = []; @@ -624,9 +663,24 @@ } $lang_key = $m[1]; $content = $read_file($file); + if (!$has_start_marker($content)) { + $files_without_marker[] = $file; + continue; + } $lines = $module_lines($file, $content, $module); + [$new_content, $removed] = $without_module_lines($content, $module); + // a safeguard: exactly the lines read are removed + if ($remove_from_lang && $removed !== count($lines)) { + $removal_problems[] = "$file: " . count($lines) . " line(s) of the module were read, but $removed would be removed"; + } $per_language[$lang_key] = $entries_of($lines); - $read_files[$lang_key] = ['path' => $file, 'hash' => hash('sha256', $content), 'lines' => count($lines)]; + $read_files[$lang_key] = [ + 'path' => $file, + 'hash' => hash('sha256', $content), + 'lines' => count($lines), + 'new_content' => $new_content, + 'removed' => $removed, + ]; $duplicates[$lang_key] = $find_duplicate_keys($lines); } if ($unsafe_files !== []) { @@ -634,6 +688,35 @@ . implode("\n ", $unsafe_files) . "\n"); exit(1); } +if ($files_without_marker !== [] && $remove_from_lang) { + fwrite(STDERR, "FAILURE: --remove-from-lang: these files have no line \"\", the installer" + . " does not read them (nothing was written):\n " . implode("\n ", $files_without_marker) . "\n"); + exit(1); +} +foreach ($files_without_marker as $file) { + fwrite(STDERR, "WARNING: skipping $file - no line \"\", the installer does not read it.\n"); +} +if ($removal_problems !== []) { + fwrite(STDERR, "FAILURE: --remove-from-lang (nothing was written):\n " . implode("\n ", $removal_problems) . "\n"); + exit(1); +} + +// the .po files are UTF-8 and are read back as such: a value in another encoding (e.g. Latin-1) is no text there +$invalid_encoding = []; +foreach ($per_language as $lang_key => $entries) { + foreach ($entries as $key => $entry) { + if (!mb_check_encoding((string) $key . $entry['value'] . ($entry['comment'] ?? ''), 'UTF-8')) { + $invalid_encoding[$lang_key][] = mb_check_encoding((string) $key, 'UTF-8') ? (string) $key : bin2hex((string) $key) . ' (hex)'; + } + } +} +if ($invalid_encoding !== []) { + fwrite(STDERR, "FAILURE: module '$module' has lines that are not valid UTF-8 in the source .lang files (nothing was written):\n"); + foreach ($invalid_encoding as $lang_key => $keys) { + fwrite(STDERR, " $lang_key: " . $format_keys($keys) . "\n"); + } + exit(1); +} // a module removed with --remove-from-lang before (or a misspelled module name): nothing to convert, // and the existing .po/.pot - the module's only source now - must stay as they are @@ -792,40 +875,46 @@ } } -if (!is_dir($output_dir) && !mkdir($output_dir, 0775, true) && !is_dir($output_dir)) { - throw new RuntimeException("Cannot create $output_dir"); -} - +// every file is built and read back in memory first: a failing run writes nothing $pot_path = $output_dir . '/' . MigratedLanguageFilePaths::templateFileName($pattern, $module); -$write($pot_path, $build_catalog($module, '', $reference_entries, null, $existing_headers($pot_path), null, $plural_definitions)->toPoString()); - +$contents = []; $report = []; +$pot_headers = $existing_headers($pot_path); +try { + $contents[$pot_path] = $build_catalog($module, '', $reference_entries, null, $pot_headers, null, $plural_definitions)->toPoString(); +} catch (RuntimeException $e) { + fwrite(STDERR, "FAILURE: the template of module '$module' cannot be written: {$e->getMessage()} Nothing was written.\n"); + exit(1); +} foreach ($per_language as $lang_key => $entries) { $po_path = $output_dir . '/' . sprintf($pattern, $lang_key) . '.po'; - $write( - $po_path, - $build_catalog( + $po_mismatches = []; + $po_headers = $existing_headers($po_path); + try { + $contents[$po_path] = $build_catalog( $module, $lang_key, $reference_entries, $entries, - $existing_headers($po_path), + $po_headers, $plural_rules[$lang_key] ?? null, $plural_definitions, $language_names[$lang_key] ?? null, $filled[$lang_key] ?? [], in_array($lang_key, $source_lang_keys, true) - )->toPoString() - ); + )->toPoString(); + $parsed = TranslationCatalog::fromPoString($contents[$po_path]); + } catch (RuntimeException $e) { + $parsed = null; + $po_mismatches[] = '(not readable back) ' . $e->getMessage(); + } - // self-check: read the file back; every value (also one taken from the reference language, flagged fuzzy) must match and nothing else may be in it - $parsed = TranslationCatalog::fromPoFile($po_path); - $po_mismatches = []; + // self-check: every value (also one taken from the reference language, flagged fuzzy) must match and nothing else may be in it $expected_keys = array_unique(array_merge( array_map('strval', array_keys($reference_entries)), array_map('strval', array_keys($entries)) )); - foreach ($expected_keys as $key) { + foreach ($parsed !== null ? $expected_keys : [] as $key) { $expected = $entries[$key]['value'] ?? ''; $parsed_entry = $parsed->find(null, $key); // find(null, ...) also finds msgctxt "" (same library id) - only an entry without msgctxt counts @@ -856,7 +945,7 @@ $po_mismatches[] = $key . ' (plural)'; } } - foreach ($parsed->getEntries() as $parsed_entry) { + foreach ($parsed?->getEntries() ?? [] as $parsed_entry) { if ($parsed_entry->getContext() !== null || !in_array($parsed_entry->getId(), $expected_keys, true)) { $po_mismatches[] = '(unexpected) ' . $parsed_entry->getId(); } @@ -892,39 +981,49 @@ } echo "\n"; -echo $all_ok - ? "OK: all " . count($report) . " languages round-trip identically through PO (" - . array_sum(array_column($report, 'filled')) . " entries filled from '$reference_lang_key', flagged fuzzy).\n" - : "FAILURE: at least one language did not round-trip correctly, see stderr above.\n"; +if (!$all_ok) { + fwrite(STDERR, "FAILURE: at least one language did not round-trip correctly, see stderr above. Nothing was written.\n"); + exit(1); +} -if (!$all_ok || !$remove_from_lang) { - if (!$all_ok && $remove_from_lang) { - fwrite(STDERR, "--remove-from-lang: no .lang file was changed.\n"); +if (!is_dir($output_dir) && !@mkdir($output_dir, 0775, true) && !is_dir($output_dir)) { + fwrite(STDERR, "FAILURE: cannot create $output_dir. Nothing was written.\n"); + exit(1); +} +$written = []; +foreach ($contents as $path => $content) { + try { + $write($path, $content); + } catch (RuntimeException $e) { + fwrite(STDERR, "FAILURE: {$e->getMessage()} - written already: " . ($written === [] ? 'nothing' : implode(', ', $written)) + . ". No .lang file was changed.\n"); + exit(1); } - exit($all_ok ? 0 : 1); + $written[] = basename($path); +} +echo "OK: all " . count($report) . " languages round-trip identically through PO (" + . array_sum(array_column($report, 'filled')) . " entries filled from '$reference_lang_key', flagged fuzzy).\n"; + +if (!$remove_from_lang) { + exit(0); } -// --remove-from-lang: the module's .po files become its only source; all new contents are computed and checked first +// --remove-from-lang: the module's .po files become its only source; the new contents were computed while reading $new_lang_contents = []; -$removal_problems = []; +$changed_files = []; foreach ($read_files as $lang_key => $read) { $content = file_get_contents($read['path']); if ($content === false || hash('sha256', $content) !== $read['hash']) { - $removal_problems[] = "{$read['path']} could not be read again or changed during the run"; + $changed_files[] = $read['path']; continue; } - [$new_content, $removed] = $without_module_lines($content, $module); - // e.g. a line of the module above "" - read, but never removed - if ($removed !== $read['lines']) { - $removal_problems[] = "{$read['path']}: {$read['lines']} line(s) of the module were read, but $removed would be removed"; - continue; - } - if ($removed > 0) { - $new_lang_contents[$lang_key] = [$new_content, $removed]; + if ($read['removed'] > 0) { + $new_lang_contents[$lang_key] = [$read['new_content'], $read['removed']]; } } -if ($removal_problems !== []) { - fwrite(STDERR, "FAILURE: --remove-from-lang: no .lang file was changed (the .po/.pot files are written):\n " . implode("\n ", $removal_problems) . "\n"); +if ($changed_files !== []) { + fwrite(STDERR, "FAILURE: --remove-from-lang: no .lang file was changed (the .po/.pot files are written), these were" + . " changed during the run or cannot be read again:\n " . implode("\n ", $changed_files) . "\n"); exit(1); } From 2a820cde26cb8368df6b0ab951f9206a7778f5f9 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 14 Oct 2026 17:52:30 +0200 Subject: [PATCH 067/103] Language: Update the documentation of modules maintained in PO files The `.lang` lines of a migrated module are removed, the contribution has to exist before the converter runs, and a rollback goes through git. Documents the fallback of the build for a broken `.po`, the slim `current.json` and the remarks on import. --- components/ILIAS/Language/README.md | 73 ++++++++++++++++++----------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 6ff2e51e7fab..25900d19e321 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -76,9 +76,11 @@ and `LanguageFileDirectoryManager` aggregates all of them, contributed by other A module can be shipped by its component as gettext files instead of lines in `lang/ilias_.lang`. Currently this applies to the modules `tos` (`TermsOfService`) and `poll` (`Poll`). For such a module the shipped `.po` is -the only source of its shipped values: its lines in `lang/ilias_.lang` -are ignored when installing or updating a language and are not the default the -administration compares with. They are kept as long as a rollback is possible. +the only source of its shipped values: its lines are removed from +`lang/ilias_.lang` when it is migrated (`--remove-from-lang`, see +[Migrating a Module](#migrating-a-module)); a rollback restores them from git +(see [Rollback](#rollback)). Lines left there would be ignored when installing +or updating a language. 1. [Shipped Files](#shipped-files) 2. [Build](#build) @@ -109,8 +111,9 @@ administration compares with. They are kept as long as a rollback is possible. translations are not shipped (`-identifier-`, as before). * The initial files are generated with [`tools/po-migration/convert_module_to_po.php`](tools/po-migration/README.md), - later changes are made in the `.po`/`.pot` files directly (a new run of the - tool would overwrite them with the `.lang` lines). + later changes are made in the `.po`/`.pot` files directly. The module has no + `.lang` lines any more, so a new run of the tool fails ("Nothing was + written") and leaves the files unchanged. * A module counts as migrated for a language while its shipped `.po` exists (Setup, administration) and the build contains it (runtime). @@ -119,16 +122,24 @@ administration compares with. They are kept as long as a rollback is possible. `composer dump-autoload`, see `ShippedTranslationsBuild`) compiles every shipped `.po` into `artifacts/language//`: `messages/LC_MESSAGES/..mo`, `keys/.php`, a copy of the C -library's `C.utf8` locale and a manifest. `artifacts/language/current.json` -names the build that is served; it is switched only after the new build is -complete and passed a real lookup through native gettext, and the previous -build is kept for running requests. - -* **The build aborts** if the PHP extension `gettext` or `C.utf8` is missing. +library's `C.utf8` locale and a manifest (also the hashes of the compiled +`.po` files, warnings and collisions). `artifacts/language/current.json` names +the build that is served and its languages per module, nothing else, as every +request reads it; it is switched only after the new build is complete and +passed a real lookup through native gettext, and the previous build is kept +for running requests. + +* **The build aborts** if the PHP extension `gettext` (a precondition of + `setup build`) or `C.utf8` is missing. * Not aborting, but reported on every build: values with markup that is not allowed (compiled cleaned, see [Markup Policy](#markup-policy)), a `.po` that - cannot be compiled (the module is not served for that language) and - identifiers that two migrated modules ship with different values. + cannot be compiled (e.g. merge conflict markers) and identifiers that two + migrated modules ship with different values. For a `.po` that cannot be + compiled the language keeps the catalog of the served (or previous) build, + if that build was made by the same compiler and its catalog is intact and + passes the markup policy; otherwise the module is not served for that + language (`-identifier-`, like a `.lang` line that cannot be read) until the + file is fixed; the runtime logs it when the module is used. * **Deployment requirement:** run `php cli/setup.php build` after every change of a shipped `.po`. The runtime never compiles; a file that is not in the build is not served. @@ -162,7 +173,8 @@ module that is not migrated). `msgstr[n]`) that the `Plural-Forms` rule of the language selects for `$n`. The quantity is not inserted; callers do that, e.g. with `sprintf()`. In every other case (module not migrated, plugin, no plural message, empty form) it -returns exactly what `txt()` without `$n` returns. +returns exactly what `txt()` without `$n` returns in that language, never the +form of another language. * `txt()` without `$n` returns the default form of a plural message: the last form (CLDR "other"), or the last non-empty one before it. The database holds this @@ -270,8 +282,10 @@ Where the rules apply: per key, also for plural messages. * Import of a language file: for a migrated module, imported values that differ from the shipped ones become local changes in the overlay, in every - mode. "delete" first deletes the language's database rows and then writes - every module anew; markup is checked before anything is deleted. + mode. A line without `###` comment keeps the stored remark. "delete" first + deletes the language's database rows and then writes every module anew; a + remark that is not in the file is removed from the overlay as well. Markup is + checked before anything is deleted. * "Remove local changes" and the maintenance action "clear" are the same activity (`RemoveLocalLanguageChanges`): values, locally added keys and remarks are removed from database and overlay, the shipped state is @@ -314,7 +328,8 @@ shipped `.po` is removed unless it carries a real local change. * Warnings are printed for skipped customizing entries, local changes with markup that is not allowed, overlay directories that are not writable, a Setup not running as owner of the client data directory, and a missing - client data directory (the overlay is then written by the next update). + client data directory (the overlay is then written by the next update; until + then migrated modules show their shipped values without local changes). ### Plugins Plugins cannot contribute a `LanguageFileDirectory` yet. A plugin may ship @@ -327,11 +342,13 @@ of a plugin `.po` with markup that is not allowed are cleaned and logged; values of a plugin `.lang` are only logged and taken over unchanged. ### Migrating a Module -1. Generate the `.pot`/`.po` files into the component's `lang/` directory with - [`convert_module_to_po.php`](tools/po-migration/README.md) and compare the - placeholders with the reference language. -2. Contribute the module's `ComponentLanguageFileDirectory` in the - component's `.php`. +1. Contribute the module's `ComponentLanguageFileDirectory` in the + component's `.php` (`$contribute[LanguageFileDirectory::class]`); + `--remove-from-lang` refuses to run without it. +2. Generate the `.pot`/`.po` files into the component's `lang/` directory and + remove the module's lines from `lang/ilias_.lang` with + [`convert_module_to_po.php --remove-from-lang`](tools/po-migration/README.md), + then compare the placeholders with the reference language. 3. Run `composer dump-autoload` (or `php cli/setup.php build`) and `php cli/setup.php update`. @@ -339,12 +356,12 @@ values of a plugin `.lang` are only logged and taken over unchanged. The database tables are written on every path, so a rollback removes code or files, it never writes data back. -* Per module: remove the contribution, run `composer dump-autoload` (rebuilds - the component graph and the build) and `php cli/setup.php update`; the - module's `.lang` lines are used again (restore them from git if they were - removed with `--remove-from-lang`). Local changes of plural forms other than - the default form are lost, values merged into the `.po` must be added to the - `.lang` files. +* Per module: revert the migration with git (restores the module's `.lang` + lines, removes the contribution and the `.po`/`.pot`, e.g. `git revert` of + the migration commit), run `composer dump-autoload` (rebuilds the component + graph and the build) and `php cli/setup.php update`. Local changes of plural + forms other than the default form are lost, values merged into the `.po` must + be added to the `.lang` files. * Per language: remove its shipped `.po` and run `php cli/setup.php build`. The overlay is kept unchanged and reconciled again once the `.po` is back. * `artifacts/language/` can be deleted and rebuilt with From 20c023767a46217b15c288a9a49b217fc68acfe4 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 15 Oct 2026 09:07:07 +0200 Subject: [PATCH 068/103] Language: Recognise the "new variable" marker behind further `#` `LegacyFuzzyMarker` now tolerates leading `#`, so a `.lang` line ending in `#### new variable` marks the entry as not translated (fuzzy) in the installation and the converter instead of becoming a note. --- .../LegacyFuzzyMarker.php | 4 +- .../LegacyFuzzyMarkerTest.php | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/LegacyFuzzyMarkerTest.php diff --git a/components/ILIAS/Language/src/ComponentTranslation/LegacyFuzzyMarker.php b/components/ILIAS/Language/src/ComponentTranslation/LegacyFuzzyMarker.php index c777849fe23f..a094c5bcdd5c 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/LegacyFuzzyMarker.php +++ b/components/ILIAS/Language/src/ComponentTranslation/LegacyFuzzyMarker.php @@ -24,11 +24,11 @@ * The dated "not translated yet" marker of the legacy `.lang` files ("28 08 2012 new variable") in * a `###` comment or a lng_data remark. The conversion to PO turns it into the `fuzzy` flag, the * installation does not take it over as a remark. Only the dated marker counts - a note merely - * mentioning "new variable" is a note. + * mentioning "new variable" is a note. Leading "#" are tolerated ("####30 08 2015 new variable"). */ final class LegacyFuzzyMarker { - private const string PATTERN = '/^\s*(\d{1,2}\s+\d{1,2}\s+\d{4}|\d{1,2}\s+[A-Za-z]{3}\s+\d{4}|\d{4}-\d{1,2}-\d{1,2})\b.*\bnew variable\b/i'; + private const string PATTERN = '/^[\s#]*(\d{1,2}\s+\d{1,2}\s+\d{4}|\d{1,2}\s+[A-Za-z]{3}\s+\d{4}|\d{4}-\d{1,2}-\d{1,2})\b.*\bnew variable\b/i'; public static function matches(string $comment): bool { diff --git a/components/ILIAS/Language/tests/ComponentTranslation/LegacyFuzzyMarkerTest.php b/components/ILIAS/Language/tests/ComponentTranslation/LegacyFuzzyMarkerTest.php new file mode 100644 index 000000000000..900b09e79dfd --- /dev/null +++ b/components/ILIAS/Language/tests/ComponentTranslation/LegacyFuzzyMarkerTest.php @@ -0,0 +1,51 @@ + */ + public static function comments(): array + { + return [ + 'dated marker' => ['30 08 2015 new variable', true], + 'month name' => ['10 Jul 2006 new variable', true], + 'iso date' => ['2022-05-04 new variable', true], + 'one leading # ("####" in the .lang file)' => ['#04 05 2022 new variable', true], + 'several leading # and spaces' => ['## 04 05 2022 new variable', true], + 'note mentioning new variable' => ['bitte pruefen (new variable)', false], + 'new variable without any date' => ['new variable', false], + 'new variable before the date' => ['new variable 04 05 2022', false], + 'text before the date' => ['siehe 04 05 2022 new variable', false], + 'year only' => ['2024 new variable', false], + 'date without marker' => ['#04 05 2022 changed', false], + ]; + } + + #[DataProvider('comments')] + public function testMatches(string $comment, bool $expected): void + { + $this->assertSame($expected, LegacyFuzzyMarker::matches($comment)); + } +} From 96b853c7a3254428895b02758dd9ad79f74caff7 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 15 Oct 2026 09:07:07 +0200 Subject: [PATCH 069/103] Language: Keep the notes of a language as translator comments msgmerge replaces extracted comments (`#.`) by those of the `.pot`, so the own note of a language is a translator comment (`# `). `MigratedLanguageFileSync` reads it before the extracted comment, `ShippedPoMerger` writes merged remarks as translator comments and compares with the shown note, and `LanguageInstallationManager` uses the same note when it drops merged remarks from the overlay. --- components/ILIAS/Language/README.md | 16 +++--- .../classes/class.ilObjLanguageExt.php | 13 +++-- .../classes/class.ilObjLanguageExtGUI.php | 2 +- .../Catalog/TranslationEntry.php | 19 +++++-- .../MigratedLanguageFileSync.php | 28 +++++++---- .../ComponentTranslation/ShippedPoMerger.php | 14 ++++-- .../src/Setup/LanguageInstallationManager.php | 16 +++--- .../MigratedLanguageFileSyncReadersTest.php | 19 +++++++ .../ShippedPoMergerTest.php | 50 +++++++++++++++++-- ...InstallationManagerMigratedRemarksTest.php | 25 +++++++++- 10 files changed, 155 insertions(+), 47 deletions(-) diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 25900d19e321..67ad777da8ab 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -105,10 +105,12 @@ or updating a language. entry without context belongs to the module of its directory, an entry with the module as context is read as well, every other context (also `msgctxt ""`) is ignored. Only UTF-8 files are accepted. -* An extracted comment (`#.`) is the counterpart of a `###` comment in a - `.lang` file. `#, fuzzy` marks an entry that is not translated yet (it holds - the English text); fuzzy entries are served like all others, empty - translations are not shipped (`-identifier-`, as before). +* A `###` comment of a `.lang` file is a note: the reference language's note + is an extracted comment (`#.`, also in the `.pot`), a language's own note a + translator comment (`# `, kept by `msgmerge`), which wins. `#, fuzzy` marks + an entry that is not translated yet (it holds the English text); fuzzy + entries are served like all others, empty translations are not shipped + (`-identifier-`, as before). * The initial files are generated with [`tools/po-migration/convert_module_to_po.php`](tools/po-migration/README.md), later changes are made in the `.po`/`.pot` files directly. The module has no @@ -275,7 +277,7 @@ Where the rules apply: ### Language Administration * The edit table compares with the shipped `.po`. A rejected save shows the table again with the input. The remark field of a module maintained in PO - files shows the `#.` note of the shipped entry as long as there is no local + files shows the note of the shipped entry as long as there is no local remark; saving it unchanged writes nothing. Fuzzy entries are marked "Not translated" (`lng_not_translated`) in the comment of the default value. * Remarks of migrated modules are stored in the overlay (and `lng_data`), one @@ -296,7 +298,7 @@ Where the rules apply: * "merge" (translation mode only, checked on the server) writes the global `.lang` file for modules that are not migrated, and the local changes of migrated modules into their shipped `.po` (new keys also into the `.pot`; - remarks become an additional `#.` line). The previous files are copied to + remarks become an additional `# ` line). The previous files are copied to `lang/customizing/` (ignored by git), the written files belong to the web server user. Values with markup that is not allowed are not taken over. The overlay keeps serving the merged values until `php cli/setup.php build` and @@ -336,7 +338,7 @@ Plugins cannot contribute a `LanguageFileDirectory` yet. A plugin may ship `lang/ilias_.po` instead of `lang/ilias_.lang` (the `.po` wins if both exist): `msgid` is the key without the plugin prefix, entries with a `msgctxt` are ignored and logged. The values go into the database as before -(no build, no overlay), `#.` comments are not stored, plural messages are +(no build, no overlay), comments are not stored, plural messages are stored with their default form (`$n` has no effect for plugins). Values of a plugin `.po` with markup that is not allowed are cleaned and logged; values of a plugin `.lang` are only logged and taken over unchanged. diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php index 8a8fb4378270..98b2f4bb2b53 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php @@ -132,8 +132,8 @@ public function getAllRemarks(): array } /** - * The remark as the admin table shows it: the local remark, else the `#.` note of the shipped .po (plural - * notes are listed per form, see PluralFormKey). saveValues() compares against the same remarks. + * The remark as the admin table shows it: the local remark, else the note of the shipped .po (`# `, else `#.`; + * plural notes are listed per form, see PluralFormKey). saveValues() compares against the same remarks. * * @return array module.separator.topic => remark, no empty ones */ @@ -338,7 +338,7 @@ public static function _getShippedMigratedComments(string $a_lang_key, array $a_ } /** - * Appends SHIPPED_FUZZY_MARKER_TOPIC to the `#.` note of every fuzzy entry. + * Appends SHIPPED_FUZZY_MARKER_TOPIC to the shipped note of every fuzzy entry. * * @param array $comments * @param array{fuzzy: list} $shipped see readShippedMigratedModules() @@ -392,7 +392,7 @@ public function getUnreadableShippedPoModules(): array * files stay as read (see ilLanguageFile::keepOriginalLines()); * - modules maintained in PO files with local changes go into their shipped .po (new entries into the .pot, see * ShippedPoMerger), after a backup into the customizing directory. The lng_data rows of taken over entries lose - * local_change, and their remarks where these became `#.` comments (see mergeIntoDatabase()). + * local_change, and their remarks where these became comments of the .po (see mergeIntoDatabase()). * The overlay keeps serving the taken over values until "setup build" (the web server does not build) and * "setup update" ran. * @@ -518,7 +518,7 @@ static function (string $module, array $identifiers, array $remark_identifiers) * Database part of the merge into the shipped .po: the lng_data rows of $identifiers lose local_change - unlike * the global language file, since the next update compares local changes with the shipped value (see * LanguageInstallationManager::resolveMigratedModule()) and a remaining one would come back once the shipped value - * changes. The remarks of $remark_identifiers became `#.` comments and are removed. lng_modules is unchanged. + * changes. The remarks of $remark_identifiers became comments of the .po and are removed. lng_modules is unchanged. * * @param list $identifiers * @param list $remark_identifiers @@ -1271,8 +1271,7 @@ private static function saveValues( $remark_changes[$module][$topic] = (string) $a_remarks[$key]; } - // PO module: only compared with the stored remark; the `#.` note is not shown, so comparing with it would - // make every unchanged entry with a note a local change + // PO module: the shipped note is not shown, comparing with it would flag every entry with a note $are_comments_set = !$is_po_module && array_key_exists($key, $global_comments) && array_key_exists($key, $a_remarks); $is_comment_changed = $are_comments_set ? $global_comments[$key] != $a_remarks[$key] : $are_comments_set; $are_changes_made = (isset($global_values[$key]) ? $global_values[$key] != $value : true) || (isset($db_values[$key]) ? $db_values[$key] != $value : true); diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php index c29085ff74b7..e2fb635cfe4f 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php @@ -443,7 +443,7 @@ public function viewObject( } /** - * Remarks (lng_data and overlay) plus, for PO modules without a remark, the `#.` notes and "not translated yet" + * Remarks (lng_data and overlay) plus, for PO modules without a remark, the shipped notes and "not translated yet" * marker of the shipped .po (see ilObjLanguageExt::_getShippedMigratedComments()). * * @param list $modules the displayed modules, all if empty diff --git a/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationEntry.php b/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationEntry.php index a29997fa2816..9fa2f09bfe5b 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationEntry.php +++ b/components/ILIAS/Language/src/ComponentTranslation/Catalog/TranslationEntry.php @@ -140,6 +140,18 @@ public function addTranslatorComment(string $comment): void $this->translation->getComments()->add(self::singleLine($comment)); } + /** Translator comment (`# `) survives msgmerge, an extracted one (`#.`) is taken from the template. */ + public function addTranslatorNote(string $note): void + { + $this->translation->getComments()->add(self::plainLine($note)); + } + + /** $comment as addTranslatorNote()/addExtractedComment() store it. */ + public static function plainComment(string $comment): string + { + return self::plainLine($comment); + } + public function removeTranslatorCommentsStartingWith(string $prefix): void { $comments = $this->translation->getComments(); @@ -212,10 +224,9 @@ private static function singleLine(string $comment): string } /** - * singleLine() for an extracted comment (`#.`), which is shown to translators and may come from an - * administrator's remark (see ShippedPoMerger): also control characters, line/paragraph separators and - * bidirectional embedding/override/isolate characters (could make it read differently) become a space. - * Translator comments keep only singleLine(): they carry values that must stay byte-identical + * singleLine() for an extracted comment (`#.`) or a translator note, possibly an administrator's remark: also + * control characters, line/paragraph separators and bidirectional characters become a space. + * Other translator comments keep only singleLine(): they carry values that must stay byte-identical * (LocalChangeComments "original"). */ private static function plainLine(string $comment): string diff --git a/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php b/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php index 2d6ba57d17cd..2fb0cadfed1f 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php +++ b/components/ILIAS/Language/src/ComponentTranslation/MigratedLanguageFileSync.php @@ -1090,7 +1090,7 @@ private static function syncLocked( // LanguageInstallationManager::resolveMigratedModule() if ($shipped_entry !== null && ($existing_entry === null || $refresh_original_from_shipped)) { LocalChangeComments::setOriginal($entry, $shipped_entry->getTranslation()); - $entry->setExtractedComments($shipped_entry->getExtractedComments()); + $entry->setExtractedComments(self::shippedNotesOf($shipped_entry)); } $entry->translate($value); @@ -1352,7 +1352,7 @@ private static function overlayPluralEntry( foreach ($shipped_forms as $form => $value) { LocalChangeComments::setOriginal($entry, $value, $form); } - $entry->setExtractedComments($shipped_entry->getExtractedComments()); + $entry->setExtractedComments(self::shippedNotesOf($shipped_entry)); } $entry->setPlural( @@ -1432,7 +1432,7 @@ public static function findShippedModuleFiles( /** * The entries of $module in the shipped `.po` $shipped_po (see findShippedModuleFiles(); a plural message - * as its forms, see the class docblock), with their extracted comments (`#.`, the counterpart of a `.lang` + * as its forms, see the class docblock), with their notes (see shippedNotesOf(), the counterpart of a `.lang` * file's "###" comment) joined into one line (`null` if none). * * @return array identifier => entry @@ -1444,7 +1444,7 @@ public static function loadShippedModuleEntries(string $shipped_po, string $modu $catalog = self::readShippedPo($shipped_po); $plural_forms = self::pluralFormsOf($catalog); foreach (self::moduleEntries($catalog, $module) as $entry) { - $comment = trim(preg_replace('/\s*[\r\n]+\s*/', ' ', implode(' ', $entry->getExtractedComments())) ?? ''); + $comment = trim(preg_replace('/\s*[\r\n]+\s*/', ' ', implode(' ', self::shippedNotesOf($entry))) ?? ''); foreach (self::shippedValuesOf($entry, $plural_forms) as $identifier => $value) { $entries[$identifier] = [ 'value' => $value, @@ -1457,20 +1457,30 @@ public static function loadShippedModuleEntries(string $shipped_po, string $modu } /** - * The extracted comments (`#.`) of every entry of $module in the shipped `.po` $shipped_po, each trimmed on + * The notes (see shippedNotesOf()) of every entry of $module in the shipped `.po` $shipped_po, each trimmed on * its own, by identifier (a plural message under its identifier). * * @return array> * @throws RuntimeException if the file cannot be read or parsed */ - public static function loadShippedExtractedComments(string $shipped_po, string $module): array + public static function loadShippedNotes(string $shipped_po, string $module): array { - $comments = []; + $notes = []; foreach (self::moduleEntries(self::readShippedPo($shipped_po), $module) as $identifier => $entry) { - $comments[(string) $identifier] = array_values(array_map('trim', $entry->getExtractedComments())); + $notes[(string) $identifier] = array_values(array_map('trim', self::shippedNotesOf($entry))); } - return $comments; + return $notes; + } + + /** + * The language's own note (translator comments), else the reference note (extracted comments). + * + * @return list + */ + public static function shippedNotesOf(TranslationEntry $entry): array + { + return $entry->getTranslatorComments() ?: $entry->getExtractedComments(); } /** diff --git a/components/ILIAS/Language/src/ComponentTranslation/ShippedPoMerger.php b/components/ILIAS/Language/src/ComponentTranslation/ShippedPoMerger.php index 19a617236f56..d500c4a2c18e 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/ShippedPoMerger.php +++ b/components/ILIAS/Language/src/ComponentTranslation/ShippedPoMerger.php @@ -36,9 +36,9 @@ * of a plural message is its default form (PluralForms). An entry the shipped `.po` lacks (added locally) * is inserted in id order, also into the template (`.pot`) with an empty msgstr (plural: msgid_plural of * the template or "_plural"); the `.po` of other languages stay unchanged; - * - a remark (LocalChangeComments::setRemark()) becomes an extracted comment (`#.`) of its entry, appended - * unless identical, also for an entry that only carries a remark. "original"/"local_change" never go into - * a shipped file; + * - a remark (LocalChangeComments::setRemark()) becomes a translator comment (`# `, kept by msgmerge) of its + * entry, appended unless identical to one of its notes (MigratedLanguageFileSync::shippedNotesOf()), also + * for an entry that only carries a remark. "original"/"local_change" never go into a shipped file; * - a value with disallowed markup (TranslationMarkupPolicy), a plural message that cannot be compiled (empty * msgstr[0] next to other forms, or a plural overlay entry for a singular shipped one) and a new identifier * shaped " []" (it would read as a plural form, see PluralFormKey) are not taken over: they @@ -56,7 +56,7 @@ * build does not serve the written `.po` (see MigratedLanguageFileSync::sync()); a failed marking is * reported and made up for by the next merge. `php cli/setup.php build` and then `update` clean them up * (its three-way reconciliation drops overlay entries equal to the new shipped value and remarks equal to - * an extracted comment). + * a comment of the shipped entry). * * lng_data/lng_modules are not written here (the database belongs to the legacy classes): the caller gets * the taken-over identifiers per module, see merge(). @@ -350,7 +350,11 @@ private static function mergeModule( // the remark of an entry without value that is not shipped - nothing to put it on continue; } - $shipped_entry->addExtractedComment($remark); + // as stored: compare the cleaned remark + $notes = array_map('trim', MigratedLanguageFileSync::shippedNotesOf($shipped_entry)); + if (!in_array(trim(TranslationEntry::plainComment($remark)), $notes, true)) { + $shipped_entry->addTranslatorNote($remark); + } $outcome['merged_remarks'][] = $identifier; } } diff --git a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php index 76c0ff7c26e2..458d535ab5ec 100644 --- a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php +++ b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php @@ -20,6 +20,7 @@ namespace ILIAS\Language\Setup; +use ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry; use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; use ILIAS\Language\ComponentTranslation\LegacyFuzzyMarker; @@ -1007,8 +1008,8 @@ private function invalidateLanguageCache(string $lang_key): void * cannot be read is left out, so its sync() keeps the overlay's remarks. * * Before the migration lng_data.remarks held the shipped "###" comment of the `.lang` line: a lng_data - * remark is only taken over if it differs from that comment ($shipped_line_comments), from the `#.` - * note of the shipped `.po` and is no dated "new variable" marker, i.e. an administrator's remark. + * remark is only taken over if it differs from that comment ($shipped_line_comments), from the note of the + * shipped `.po` and is no dated "new variable" marker, i.e. an administrator's remark. * * @param array> $shipped_migrated_modules * @param array> $customized_remarks @@ -1083,18 +1084,19 @@ private function migratedModuleRemarks( // reported by loadOverlay() - the sync keeps what the overlay holds continue; } - // a remark taken over by "merge" is a shipped extracted comment now (see ShippedPoMerger): it leaves the overlay + // a remark taken over by "merge" is a shipped note now (see ShippedPoMerger): it leaves the overlay try { - $extracted_comments = isset($shipped_files[$module]) - ? MigratedLanguageFileSync::loadShippedExtractedComments($shipped_files[$module], $module) + $po_notes_by_message = isset($shipped_files[$module]) + ? MigratedLanguageFileSync::loadShippedNotes($shipped_files[$module], $module) : []; } catch (\Throwable) { - $extracted_comments = []; + $po_notes_by_message = []; } foreach ($overlay_remarks as $identifier => $remark) { $identifier = (string) $identifier; $message = MigratedLanguageFileSync::pluralMessageOf($identifier, $shipped_entries) ?? $identifier; - if (in_array(trim((string) $remark), $extracted_comments[$message] ?? [], true)) { + $stored_remark = trim(TranslationEntry::plainComment((string) $remark)); + if (in_array($stored_remark, $po_notes_by_message[$message] ?? [], true)) { unset($overlay_remarks[$identifier]); } } diff --git a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php index 1ae6b3ab2ffe..37a40fbc1fa0 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/MigratedLanguageFileSyncReadersTest.php @@ -289,6 +289,25 @@ public function testLoadShippedModuleEntriesReturnsValueAndJoinedExtractedCommen ); } + /** The own note (translator comment) wins over the reference note ("#."); loadShippedNotes() agrees. */ + public function testTheTranslatorCommentIsTheNoteAndBothKindsAreShippedComments(): void + { + $catalog = MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hej', 'farewell' => 'Hejdå']); + $catalog->find(self::MODULE, 'greeting')->addExtractedComment('en note'); + $catalog->find(self::MODULE, 'greeting')->addTranslatorNote('egen anteckning'); + $catalog->find(self::MODULE, 'farewell')->addExtractedComment('en note'); + MigratedPoFixture::writePo($this->shippedPo(), $catalog); + + $entries = MigratedLanguageFileSync::loadShippedModuleEntries($this->shippedPo(), self::MODULE); + + $this->assertSame('egen anteckning', $entries['greeting']['comment']); + $this->assertSame('en note', $entries['farewell']['comment']); + $this->assertSame( + ['greeting' => ['egen anteckning'], 'farewell' => ['en note']], + MigratedLanguageFileSync::loadShippedNotes($this->shippedPo(), self::MODULE) + ); + } + public function testLoadShippedModuleEntriesThrowsForACorruptPo(): void { file_put_contents($this->shippedPo(), "msgid \"kaputt\n"); diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ShippedPoMergerTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ShippedPoMergerTest.php index 3ec929ce27e0..5792c46de459 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/ShippedPoMergerTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/ShippedPoMergerTest.php @@ -241,11 +241,8 @@ public function testASecondMergeRunWithoutFurtherLocalChangesDoesNothing(): void $this->assertSame($shipped_after_first_merge, file_get_contents($this->shippedPo())); } - /** - * An admin remark becomes an extracted comment - added after an existing one, an identical one - * is not added twice. - */ - public function testMergeAddsAnAdminRemarkAsExtractedCommentWithoutDuplicatingAnIdenticalOne(): void + /** An admin remark becomes a translator comment, unless identical to the note the entry shows. */ + public function testMergeAddsAnAdminRemarkAsTranslatorCommentWithoutDuplicatingAnIdenticalOne(): void { $shipped = MigratedPoFixture::catalog(self::MODULE, ['farewell' => 'Tschüss']); $shipped->find(self::MODULE, 'farewell')->addExtractedComment('Old note'); @@ -258,9 +255,52 @@ public function testMergeAddsAnAdminRemarkAsExtractedCommentWithoutDuplicatingAn // nothing new to take over: the shipped .po is not written at all $this->assertSame([], $result['written']); $this->assertSame(['Old note'], $this->readShippedPo()->find(self::MODULE, 'farewell')?->getExtractedComments()); + $this->assertSame([], $this->readShippedPo()->find(self::MODULE, 'farewell')?->getTranslatorComments()); $this->assertTrue(is_file($this->overlayBase() . '.po'), 'the overlay is left as it is'); } + /** A new remark becomes "# " next to a kept "#."; one equal to the shown note adds nothing. */ + public function testMergeWithAChangedValueAddsOnlyADifferingRemarkAsTranslatorComment(): void + { + $shipped = MigratedPoFixture::catalog(self::MODULE, ['farewell' => 'Tschüss', 'greeting' => 'Hallo']); + $shipped->find(self::MODULE, 'farewell')->addExtractedComment('Old note'); + $shipped->find(self::MODULE, 'greeting')->addTranslatorNote('Own note'); + MigratedPoFixture::writePo($this->shippedPo(), $shipped); + $this->seedOverlay( + ['farewell' => 'Servus', 'greeting' => 'Moin'], + ['farewell' => 'New note', 'greeting' => 'Own note'] + ); + + $this->merge(); + + $farewell = $this->readShippedPo()->find(self::MODULE, 'farewell'); + $this->assertSame('Servus', $farewell?->getTranslation()); + $this->assertSame(['Old note'], $farewell->getExtractedComments()); + $this->assertSame(['New note'], $farewell->getTranslatorComments()); + $greeting = $this->readShippedPo()->find(self::MODULE, 'greeting'); + $this->assertSame('Moin', $greeting?->getTranslation()); + $this->assertSame(['Own note'], $greeting->getTranslatorComments()); + } + + /** With an own note the reference note is hidden, so an equal remark is written; compared as stored. */ + public function testARemarkEqualToAHiddenReferenceNoteIsWrittenAndOneEqualToTheStoredNoteIsNot(): void + { + $shipped = MigratedPoFixture::catalog(self::MODULE, ['farewell' => 'Tschüss', 'greeting' => 'Hallo']); + $shipped->find(self::MODULE, 'farewell')->addExtractedComment('en note'); + $shipped->find(self::MODULE, 'farewell')->addTranslatorNote('eigene Notiz'); + $shipped->find(self::MODULE, 'greeting')->addTranslatorNote('a b'); + MigratedPoFixture::writePo($this->shippedPo(), $shipped); + $this->seedOverlay(['farewell' => 'Tschüss', 'greeting' => 'Hallo'], ['farewell' => 'en note', 'greeting' => "a\tb"]); + + $result = $this->merge(); + + $this->assertArrayHasKey(self::MODULE, $result['written']); + $farewell = $this->readShippedPo()->find(self::MODULE, 'farewell'); + $this->assertSame(['eigene Notiz', 'en note'], $farewell?->getTranslatorComments()); + $this->assertSame(['en note'], $farewell->getExtractedComments()); + $this->assertSame(['a b'], $this->readShippedPo()->find(self::MODULE, 'greeting')?->getTranslatorComments()); + } + public function testMergeSkipsAValueWithDisallowedMarkupAndKeepsItInTheOverlay(): void { $this->seedShipped(['greeting' => 'Hallo']); diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php index 2e26ea129ce9..4c21ec4ab56a 100644 --- a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerMigratedRemarksTest.php @@ -160,8 +160,29 @@ public function testMigratedModuleRemarksExcludesALngDataRemarkThatMatchesTheShi $this->assertArrayNotHasKey('greeting', $remarks[self::MODULE] ?? []); } + /** A lng_data remark equal to the shipped translator note ("# ") is no remark. */ + public function testMigratedModuleRemarksExcludesALngDataRemarkThatMatchesTheShippedTranslatorNote(): void + { + $catalog = new TranslationCatalog(); + $entry = new \ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry(self::MODULE, 'greeting'); + $entry->translate('Hallo'); + $entry->addTranslatorNote('Eigene Notiz'); + $catalog->add($entry); + MigratedPoFixture::writePo($this->root . '/components/pilot/lang/pilot_de.po', $catalog); + $this->collation_rows = [ + ['module' => self::MODULE, 'identifier' => 'greeting', 'remarks' => 'Eigene Notiz'], + ]; + $overlay = MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Hallo']); + LocalChangeComments::setRemark($overlay->find(self::MODULE, 'greeting'), ' Eigene Notiz '); + MigratedPoFixture::writePair($this->overlayBase(), $overlay); + + $remarks = $this->callMigratedModuleRemarks([self::MODULE => ['greeting' => 'Hallo']]); + + $this->assertArrayNotHasKey('greeting', $remarks[self::MODULE] ?? []); + } + /** - * A remark taken over by "merge" is an extracted comment (`#.`) of the shipped entry now: an overlay remark equal + * A remark taken over by "merge" is a note of the shipped entry now: an overlay remark equal * to it (trimmed) is dropped, a differing one is kept. */ public function testMigratedModuleRemarksDropsAnOverlayRemarkTheShippedPoHasAsNoteNow(): void @@ -204,7 +225,7 @@ public function testAMergedValueAndRemarkLeaveTheOverlayWithTheUpdateAfterTheBui $this->assertFileDoesNotExist($this->overlayBase() . '.po'); $shipped = MigratedPoFixture::readPo($this->root . '/components/pilot/lang/pilot_de.po')->find(self::MODULE, 'greeting'); $this->assertSame('Servus', $shipped?->getTranslation()); - $this->assertContains('Merk', array_map('trim', $shipped->getExtractedComments())); + $this->assertSame(['Merk'], $shipped->getTranslatorComments()); $this->assertSame('Servus', $this->lngModules()[self::MODULE]['greeting']); $this->assertSame('Servus', \ILIAS\Language\ComponentTranslation\MigratedTranslations::text(self::MODULE, 'de', 'greeting', $this->root . '/client-data')); } From bb5650e1765d3351e95268057d7fc8152032553e Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 15 Oct 2026 09:07:07 +0200 Subject: [PATCH 070/103] Language: Check the converted values like the build in `convert_module_to_po.php` The converter reports values the markup policy of the build would clean and, like the former "new variable" filling, replaces a value that would be cleaned to nothing by the reference value (fuzzy); it writes own notes as translator comments, compiles its output like the build (`ShippedTranslations::compileShippedCatalog()`), refuses surplus arguments and a missing output directory. --- .../ShippedTranslations.php | 22 +++- .../ShippedTranslationsTest.php | 12 ++ .../tests/ConvertModuleToPoToolTest.php | 116 +++++++++++++++++- .../Language/tools/po-migration/README.md | 35 ++++-- .../po-migration/convert_module_to_po.php | 107 +++++++++++++--- 5 files changed, 264 insertions(+), 28 deletions(-) diff --git a/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslations.php b/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslations.php index f1eb3840c8ef..7b35c1467fa5 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslations.php +++ b/components/ILIAS/Language/src/ComponentTranslation/ShippedTranslations.php @@ -73,9 +73,29 @@ public function compile( ?callable $on_markup_violation = null, ?callable $on_invalid_plural_forms = null ): array { - return self::compileCatalog( + return $this->compileShippedCatalog( TranslationCatalog::fromPoFile($shipped_po), $module, + $on_markup_violation, + $on_invalid_plural_forms + ); + } + + /** + * @param (callable(string $identifier, list $violations): void)|null $on_markup_violation see compile() + * @param (callable(string $message): void)|null $on_invalid_plural_forms see compile() + * @return array{mo: string, values: array} see compile() + * @throws RuntimeException if the messages cannot be compiled + */ + public function compileShippedCatalog( + TranslationCatalog $shipped, + string $module, + ?callable $on_markup_violation = null, + ?callable $on_invalid_plural_forms = null + ): array { + return self::compileCatalog( + $shipped, + $module, true, fn(string $value, string $key): string => $this->sanitized($value, $key, $on_markup_violation), $on_invalid_plural_forms diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsTest.php index efe64f3bc96b..ca99599517fb 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/ShippedTranslationsTest.php @@ -137,6 +137,18 @@ static function (string $identifier, array $violations) use (&$reported): void { $this->assertArrayNotHasKey('good', $reported); } + /** A value cleaned to nothing (e.g. "shipped_translations->compileShippedCatalog( + \MigratedPoFixture::catalog('itest', ['role' => ' 'Altro']), + 'itest' + ); + + $this->assertSame(['other' => 'Altro'], $compiled['values']); + $this->assertArrayNotHasKey('role', self::served($compiled['mo'])); + } + public function testCompileThrowsForAnUnreadableShippedPo(): void { $this->expectException(RuntimeException::class); diff --git a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php index 806ee989c6bf..e95d8d95a151 100644 --- a/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php +++ b/components/ILIAS/Language/tests/ConvertModuleToPoToolTest.php @@ -193,7 +193,7 @@ public function testTheDatedNewVariablePlaceholderFormatIsMarkedFuzzyNotKeptAsAN /** * A reference key a language lacks or has empty gets the reference value, flagged fuzzy; an own note of - * the empty entry stays its extracted comment. + * the empty entry stays its translator comment. */ public function testAMissingOrEmptyValueGetsTheReferenceValueFlaggedFuzzy(): void { @@ -220,7 +220,8 @@ public function testAMissingOrEmptyValueGetsTheReferenceValueFlaggedFuzzy(): voi $fr = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($out . '/tst_fr.po'); $this->assertSame('Hello', $fr->find(null, 'greeting')?->getTranslation(), 'empty value: the en value'); $this->assertTrue($fr->find(null, 'greeting')->hasFlag('fuzzy')); - $this->assertSame(['Bitte uebersetzen'], $fr->find(null, 'greeting')->getExtractedComments()); + $this->assertSame(['Bitte uebersetzen'], $fr->find(null, 'greeting')->getTranslatorComments()); + $this->assertSame([], $fr->find(null, 'greeting')->getExtractedComments()); $this->assertFalse($fr->find(null, 'bye')?->hasFlag('fuzzy')); $this->assertMatchesRegularExpression('/^de\s+1\s+0\s+1\s+yes$/m', $stdout); @@ -535,6 +536,7 @@ public static function invalidArgumentsProvider(): array 'misspelled long option' => [['--foo', 'tst', 'de']], 'module with a path' => [['../x', 'de']], 'reference language with a path' => [['tst', '../de']], + 'a fourth argument' => [['tst', 'de', 'out']], ]; } @@ -804,6 +806,116 @@ public function testAnUnreadableExistingPoIsWarnedAboutAndReplaced(): void $this->assertSame(['a'], self::idsOf($root . '/out/crs_de.po')); } + /** A mistyped outputDir must not become a new directory tree. */ + public function testAMissingOutputDirFailsAndIsNotCreated(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'de', "crs#:#a#:#A\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'crs', 'de', $root . '/typo/lang'); + + $this->assertSame(1, $exit_code, $stdout . $stderr); + $this->assertStringContainsString("outputDir '$root/typo/lang' does not exist. Nothing was written.", $stderr); + $this->assertDirectoryDoesNotExist($root . '/typo'); + } + + /** Markup the build cleans is kept (WARNING); only a value cleaned to nothing counts as missing. */ + public function testAValueWithMarkupIsKeptUnlessTheBuildWouldCleanItToNothing(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile( + $root, + 'en', + "tst#:#role#:#ILIAS role name\ntst#:#bad#:#Bad en\ntst#:#open#:#Open\ntst#:#none#:#\n" + ); + $this->writeLangFile( + $root, + 'it', + "tst#:#role#:#qui\ntst#:#none#:#runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $this->assertStringContainsString("empty once cleaned, replaced by the reference value (fuzzy):\n it: role (", $stderr); + $this->assertStringContainsString("empty once cleaned, left empty like a missing value:\n it: none (", $stderr); + $this->assertMatchesRegularExpression('/kept, the build cleans it:\n( .*\n)* en: bad \(/', $stderr); + $this->assertMatchesRegularExpression('/kept, the build cleans it:\n( .*\n)* it: open \(/', $stderr); + $it = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_it.po'); + $this->assertSame('ILIAS role name', $it->find(null, 'role')?->getTranslation()); + $this->assertTrue($it->find(null, 'role')->hasFlag('fuzzy')); + $this->assertSame('Apri qui', $it->find(null, 'open')?->getTranslation(), 'kept, the build cleans it'); + $this->assertFalse($it->find(null, 'open')->hasFlag('fuzzy')); + $this->assertSame('', $it->find(null, 'none')?->getTranslation()); + $this->assertFalse($it->find(null, 'none')->hasFlag('fuzzy')); + $this->assertSame('Male', $it->find(null, 'bad')?->getTranslation()); + $this->assertFalse($it->find(null, 'bad')->hasFlag('fuzzy')); + $this->assertMatchesRegularExpression('/^it\s+4\s+0\s+1\s+yes$/m', $stdout); + $en = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_en.po'); + $this->assertSame('Bad en', $en->find(null, 'bad')?->getTranslation(), 'the reference value is kept'); + $this->assertFalse($en->find(null, 'bad')->hasFlag('fuzzy')); + } + + /** A plural form taken from the singular key (plurals.json) may carry its warned markup. */ + public function testMarkupOfTheSingularKeyInAPluralFormPassesTheSelfCheck(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'en', "tst#:#item#:#Items\ntst#:#item_singular#:#One item\n"); + $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein Eintrag\n"); + $this->writePluralsConfig( + $root, + ['en' => 'nplurals=2; plural=(n != 1);', 'de' => 'nplurals=2; plural=(n != 1);'], + ['tst' => ['item' => ['singular' => 'item_singular']]] + ); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + foreach (['en' => ['One item', 'Items'], 'de' => ['Ein Eintrag', 'Eintraege']] as $lang_key => $forms) { + $item = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . "/out/tst_$lang_key.po") + ->find(null, 'item'); + $this->assertSame($forms, $item?->getPluralTranslations(), $lang_key); + } + } + + /** "####" + date + marker is the fuzzy marker; "##" + date + marker stays in the value. */ + public function testFuzzyMarkerBehindFourHashesIsFuzzyAndTwoHashesStayInTheValue(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'en', "tst#:#a#:#A\ntst#:#b#:#B\n"); + $this->writeLangFile($root, 'sv', "tst#:#a#:#Ä####24 10 2013 new variable\ntst#:#b#:#Bb##24 10 2013 new variable\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $sv = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_sv.po'); + $this->assertTrue($sv->find(null, 'a')?->hasFlag('fuzzy')); + $this->assertSame([], $sv->find(null, 'a')->getTranslatorComments()); + $this->assertFalse($sv->find(null, 'b')?->hasFlag('fuzzy')); + $this->assertSame('Bb##24 10 2013 new variable', $sv->find(null, 'b')->getTranslation()); + } + + /** The reference note is "#." like in the .pot, a differing own note a translator comment. */ + public function testAnOwnNoteIsATranslatorCommentAndTheReferenceNoteAnExtractedOne(): void + { + $root = $this->buildFixtureRepo(); + $this->writeLangFile($root, 'en', "tst#:#a#:#A###en note\ntst#:#b#:#B###same note\n"); + $this->writeLangFile($root, 'sv', "tst#:#a#:#Ä###egen anteckning\ntst#:#b#:#Bb###same note\n"); + + [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out'); + + $this->assertSame(0, $exit_code, $stdout . $stderr); + $pot = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst.pot'); + $this->assertSame(['en note'], $pot->find(null, 'a')?->getExtractedComments()); + $en = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_en.po'); + $this->assertSame([], $en->find(null, 'a')?->getTranslatorComments()); + $this->assertSame(['en note'], $en->find(null, 'a')->getExtractedComments()); + $sv = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_sv.po'); + $this->assertSame(['egen anteckning'], $sv->find(null, 'a')?->getTranslatorComments()); + $this->assertSame(['en note'], $sv->find(null, 'a')->getExtractedComments()); + $this->assertSame([], $sv->find(null, 'b')?->getTranslatorComments(), 'a note equal to the reference note is not repeated'); + } + /** @return array}> */ public static function runsWithoutAModule(): array { diff --git a/components/ILIAS/Language/tools/po-migration/README.md b/components/ILIAS/Language/tools/po-migration/README.md index abe48450659c..41a9605f524c 100644 --- a/components/ILIAS/Language/tools/po-migration/README.md +++ b/components/ILIAS/Language/tools/po-migration/README.md @@ -23,7 +23,7 @@ removes the module's `.lang` lines (`--remove-from-lang`), so a new run fails ("Nothing was written") and the `.po`/`.pot` files stay as they are. Without that option a new run would regenerate them from the `.lang` lines and overwrite values merged into the `.po` by the language administration -("merge"), keys added there and their `#.` lines. +("merge"), keys added there and their comments. Order of a migration (see the [Language component's README](../../README.md#migrating-a-module)): @@ -50,8 +50,9 @@ Arguments: `[A-Za-z0-9_]` only. * `referenceLanguage`: default `en`. It is the template of every language: it gives the `msgid`s, their order and the `.pot`. -* `outputDir`: default `tools/po-migration/output//` (ignored by git). - To migrate a module, pass the `lang/` directory of the owning component. +* `outputDir`: default `tools/po-migration/output//` (ignored by git, + created if missing). A given `outputDir` must exist. To migrate a module, + pass the `lang/` directory of the owning component. Options: @@ -78,7 +79,8 @@ Options: stay written). Afterwards the `.po` files are the only source of the module; a second run for it fails. -Unknown options fail, so a typo is never taken as module name or directory. +Unknown options and more than three arguments fail, so a typo is never taken +as module name or directory. Examples (how the pilot modules were migrated): @@ -108,12 +110,20 @@ php components/ILIAS/Language/tools/po-migration/convert_module_to_po.php --remo (the file belongs to exactly one module). Headers `Content-Type` (UTF-8), `Language` (the language key), `Language-Team` (the English language name), `Plural-Forms` (from `plurals.json`), `X-Domain`. -* A `###` comment that is a "new variable" placeholder becomes `#, fuzzy`, any - other comment an extracted comment `#.`. +* A `###` comment that is a "new variable" placeholder (also with further + leading `#`, e.g. `####30 08 2015 new variable`) becomes `#, fuzzy`. The + reference language's comment becomes an extracted comment `#.` (also in the + `.pot`), a different comment of a language a translator comment `# `: + `msgmerge` takes `#.` from the `.pot` but keeps `# `. * A key the language lacks, or has with an empty value, gets the value of the reference language and `#, fuzzy`. Fuzzy entries are compiled and served like all others, so a language without translation shows the English text instead of `-identifier-`. +* A value with markup that `TranslationMarkupPolicy` does not allow is kept + as it is, like the installer did; the build cleans it. A `WARNING` names + language and key. Only a value of another language that the cleaning would + leave empty is treated like a missing one: the reference value and + `#, fuzzy` (it stays empty if the reference value is empty). * No `.mo` file and nothing in the database: run `php cli/setup.php build` afterwards. @@ -126,11 +136,14 @@ plural definition of `plurals.json` does not fit the module, or if the module has no line in any `.lang` file. Before writing, every `.po` is read back in memory with the strict loader and compared with its source: every value, every plural form and the default form of a plural message; on any difference -nothing is written either. Only a failure while writing (e.g. no permission) -or a `.lang` file changed by someone else during the run can leave the -`.po`/`.pot` files written without the `.lang` lines removed; the message -names the files. An existing target `.po` that cannot be read only loses its -headers (`WARNING`). The tool reads and writes through the same adapter as the +nothing is written either. Every `.po` is also compiled like the build +(`ShippedTranslations`, including the markup policy): an error, or a value the +build would serve differently (except a value with markup warned about), fails +the run as well. Only a failure while writing (e.g. no permission) or a `.lang` +file changed by someone else during the run can leave the `.po`/`.pot` files +written without the `.lang` lines removed; the message names the files. An +existing target `.po`/`.pot` that cannot be read is replaced; its headers are +not kept (`WARNING`). The tool reads and writes through the same adapter as the language component (`TranslationCatalog`), so it writes exactly what Setup and the administration read. diff --git a/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php index 623bdb5c6f0c..c84bbec9a19f 100644 --- a/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php +++ b/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php @@ -37,9 +37,11 @@ * from `meta_l_` of `lang/ilias_en.lang`, or, once "meta" is migrated, of a shipped * `components///lang/meta_en.po` (default file name pattern only). * - * The reference language (default "en") gives the msgids, their order and the `.pot`. A reference key that a - * language lacks or has empty gets the reference value, flagged "fuzzy" (fuzzy entries are shipped like all - * others). A key only other languages have cannot be taken over (see --skip-unmigratable-keys). + * The reference language (default "en") gives the msgids, their order and the `.pot`. A reference key that a language + * lacks or has empty gets the reference value, flagged "fuzzy" (fuzzy entries are shipped like all others). A key only + * other languages have cannot be taken over (see --skip-unmigratable-keys). A value with markup the build cleans + * (TranslationMarkupPolicy) is kept (WARNING), unless cleaning leaves nothing: then it counts as missing. The reference + * language's "###" note becomes "#.", another own note a translator comment "# " (msgmerge keeps it). * * Plural messages (plurals.json, "modules"): the tool invents no text, it distributes existing values (one taken * from the reference language counts). Where the rule has a form for n = 1 alone (found by evaluating the rule: @@ -67,12 +69,14 @@ * (checked first) an existing outputDir components///lang whose .php * already contributes the module's ComponentLanguageFileDirectory, no symbolic link in lang/ and the * start marker in every lang/ilias_.lang - * is required. Unknown options (anything starting with "-") fail; module and reference language must match - * [A-Za-z0-9_]+. Like the installer, a `.lang` file is only read behind its line ""; a - * file without that line is skipped with a WARNING (a FAILURE with --remove-from-lang). A module without any line in - * lang/ fails without writing anything (most likely migrated already). Every check runs before anything is - * written: a failing run writes no `.po`/`.pot` and changes no `.lang` file (except on write errors or a `.lang` - * file changed during the run, both reported). + * is required. Unknown options (anything starting with "-") and a fourth argument fail; module and reference + * language must match [A-Za-z0-9_]+; a given outputDir must exist. An existing unreadable target + * `.po`/`.pot` is replaced without its headers (WARNING). Like the installer, a `.lang` file is + * only read behind its line ""; a file without that line is skipped with a WARNING (a + * FAILURE with --remove-from-lang). A module without any line in lang/ fails without writing anything (most likely + * migrated already). Every check runs before anything is written: a failing run writes no `.po`/`.pot` and changes no + * `.lang` file (except on write errors or a `.lang` file changed during the run, both reported). The self-check reads + * every `.po` back and compiles it. * Example: php convert_module_to_po.php tos en ../../../TermsOfService/lang */ @@ -88,7 +92,10 @@ use ILIAS\Language\ComponentTranslation\Catalog\TranslationEntry; use ILIAS\Language\ComponentTranslation\LegacyFuzzyMarker; use ILIAS\Language\ComponentTranslation\MigratedLanguageFilePaths; +use ILIAS\Language\ComponentTranslation\PluralFormKey; use ILIAS\Language\ComponentTranslation\PluralForms; +use ILIAS\Language\ComponentTranslation\ShippedTranslations; +use ILIAS\Language\ComponentTranslation\TranslationMarkupPolicy; /** * Makes control characters (e.g. the ESC of a terminal escape sequence) visible in everything printed from a `.lang` @@ -440,8 +447,8 @@ } if ($own_comment !== null && $is_fuzzy_marker($own_comment)) { $entry->addFlag('fuzzy'); - } elseif ($own_comment !== null) { - $comment = $own_comment; + } elseif ($own_comment !== null && $own_comment !== '' && $own_comment !== $comment) { + $entry->addTranslatorNote($own_comment); } // the own value copied into several forms still needs a translation, except in a source language if (!$is_source_language && $plural_definition !== null && $copies_own_value((string) $key, $plural_definition, $translation_entries, $plural_forms, $filled_keys)) { @@ -531,7 +538,8 @@ try { return is_file($file) ? TranslationCatalog::fromPoFile($file)->getHeaders() : []; } catch (RuntimeException $e) { - fwrite(STDERR, "WARNING: the existing $file is not readable, its headers are not kept: {$e->getMessage()}\n"); + fwrite(STDERR, "WARNING: the existing $file is not readable, its headers are not kept (the file is replaced): " + . $e->getMessage() . "\n"); return []; } }; @@ -570,9 +578,20 @@ } $arguments[] = $argument; } +$usage = "Usage: php convert_module_to_po.php [--pattern=] [--skip-unmigratable-keys] [--remove-from-lang]" + . " [referenceLangKey] [outputDir]\n"; if (!isset($arguments[0])) { - fwrite(STDERR, "FAILURE: the module is required. Usage: php convert_module_to_po.php [--pattern=]" - . " [--skip-unmigratable-keys] [--remove-from-lang] [referenceLangKey] [outputDir]\n"); + fwrite(STDERR, "FAILURE: the module is required. $usage"); + exit(1); +} +if (count($arguments) > 3) { + $surplus = $printable(implode("' '", array_slice($arguments, 3))); + fwrite(STDERR, "FAILURE: too many arguments ('$surplus'). $usage"); + exit(1); +} +// a mistyped outputDir must not become a new directory tree (--remove-from-lang checks it further below) +if (!$remove_from_lang && isset($arguments[2]) && !is_dir($arguments[2])) { + fwrite(STDERR, "FAILURE: outputDir '" . $printable($arguments[2]) . "' does not exist. Nothing was written.\n"); exit(1); } $module = $arguments[0]; @@ -808,6 +827,35 @@ exit(1); } +// a value cleaned to nothing is missing (an empty value is not served) and gets the reference value below +$markup_policy = new TranslationMarkupPolicy(); +$markup_report = []; +$markup_messages = []; +foreach ($per_language as $lang_key => $entries) { + foreach ($entries as $key => $entry) { + $violations = $markup_policy->findViolations($entry['value']); + if ($violations === []) { + continue; + } + $markup_report[$lang_key][(string) $key] = true; + $emptied = $lang_key !== $reference_lang_key && $markup_policy->sanitize($entry['value']) === ''; + if ($emptied) { + $per_language[$lang_key][$key]['value'] = ''; + } + $message = match (true) { + !$emptied => 'kept, the build cleans it', + ($per_language[$reference_lang_key][$key]['value'] ?? '') === '' + => 'empty once cleaned, left empty like a missing value', + default => 'empty once cleaned, replaced by the reference value (fuzzy)', + }; + $markup_messages[$message][] = " $lang_key: " . $printable($key . ' (' . implode('; ', $violations) . ')'); + } +} +foreach ($markup_messages as $message => $lines) { + fwrite(STDERR, "WARNING: module '$module': values with markup the build removes - $message:\n" + . implode("\n", $lines) . "\n"); +} + $own_entry_counts = array_map('count', $per_language); $filled = []; foreach ($per_language as $lang_key => $entries) { @@ -950,6 +998,37 @@ $po_mismatches[] = '(unexpected) ' . $parsed_entry->getId(); } } + // must serve exactly these values, cleaned only where markup was warned about above + $known_markup = ($markup_report[$reference_lang_key] ?? []) + ($markup_report[$lang_key] ?? []); + // a plural message also carries the value of its singular key (plurals.json) + $has_known_markup = static fn(string $message): bool => isset($known_markup[$message]) + || isset($known_markup[$plural_definitions[$message]['singular'] ?? '']); + try { + $compiled = $parsed === null ? null : (new ShippedTranslations($markup_policy))->compileShippedCatalog( + $parsed, + $module, + static function (string $identifier) use (&$po_mismatches, $has_known_markup): void { + if (!$has_known_markup(PluralFormKey::parse($identifier)[0] ?? $identifier)) { + $po_mismatches[] = '(markup) ' . $identifier; + } + }, + static function (string $message) use (&$po_mismatches): void { + $po_mismatches[] = '(build) ' . $message; + } + ); + } catch (RuntimeException $e) { + $compiled = null; + $po_mismatches[] = '(not compilable) ' . $e->getMessage(); + } + foreach ($expected_keys as $key) { + if ( + $compiled !== null + && !$has_known_markup($key) + && ($compiled['values'][$key] ?? '') !== ($entries[$key]['value'] ?? '') + ) { + $po_mismatches[] = $key . ' (build)'; + } + } $fuzzy_count = 0; foreach ($entries as $e) { From 02a743cc9d1acacdf4764c9e5303f7f758a90fa4 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 20 Oct 2026 15:00:04 +0200 Subject: [PATCH 071/103] Language: Refuse two directories that ship the same language files Several modules of a component share its `lang/` directory and are told apart by their file names only. `LanguageFileDirectoryManager` now rejects two directories with the same folder and file name pattern, as both would read the same `.po`. An invalid pattern is left to the build, which warns. --- components/ILIAS/Language/README.md | 5 + .../LanguageFileDirectoryManager.php | 16 +++ .../LanguageFileDirectoryManagerTest.php | 120 ++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/LanguageFileDirectoryManagerTest.php diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 67ad777da8ab..c93a25ac282f 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -101,6 +101,11 @@ or updating a language. `lang/` directory. A 4th constructor argument may name the files differently (e.g. `'ilias_%s'` for `ilias_.po`; exactly one `%s`, no `/` or `..`). Build artifacts and overlays are always named after the module. +* Several modules of one component share its `lang/` directory and are told + apart by their file names only: `LanguageFileDirectoryManager` refuses two + directories that ship the same files. An `ilias_.lang` must not be put + there: it is read once per contributed directory, so its lines would end up + in every module of that folder. * `msgid` is the language key (`txt()` is unchanged). There is no `msgctxt`: an entry without context belongs to the module of its directory, an entry with the module as context is read as well, every other context (also diff --git a/components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectoryManager.php b/components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectoryManager.php index 4e6a64fde441..2bde21312be9 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectoryManager.php +++ b/components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectoryManager.php @@ -55,6 +55,7 @@ private function check(): void } $main_files = 0; $prefixes = []; + $shipped_files = []; foreach ($this->directories as $d) { switch (true) { case $d instanceof MainLanguageFileDirectory: @@ -84,6 +85,21 @@ private function check(): void } break; } + // modules sharing a folder are told apart by their file names only + if ($d->getPrefix() !== '') { + try { + $files = rtrim($d->getPath(), '/') . '/' . MigratedLanguageFilePaths::shippedFileNamePattern($d); + } catch (\InvalidArgumentException) { + // an invalid pattern only warns in the build, see ShippedTranslationsBuild + continue; + } + if (isset($shipped_files[$files])) { + throw new \InvalidArgumentException( + "There must not be two LanguageFileDirectory shipping the same files " . $files + ); + } + $shipped_files[$files] = true; + } } } diff --git a/components/ILIAS/Language/tests/ComponentTranslation/LanguageFileDirectoryManagerTest.php b/components/ILIAS/Language/tests/ComponentTranslation/LanguageFileDirectoryManagerTest.php new file mode 100644 index 000000000000..21124303f43a --- /dev/null +++ b/components/ILIAS/Language/tests/ComponentTranslation/LanguageFileDirectoryManagerTest.php @@ -0,0 +1,120 @@ +anyComponent(); + + $manager = new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory(), + new ComponentLanguageFileDirectory($component, 'adve'), + new ComponentLanguageFileDirectory($component, 'copg') + ); + + $this->assertCount(3, iterator_to_array($manager->getDirectories(), false)); + } + + public function testTwoModulesMustNotShipTheSameFiles(): void + { + $component = $this->anyComponent(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('shipping the same files'); + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new ComponentLanguageFileDirectory($component, 'adve', 'lang/', 'ilias_%s'), + new ComponentLanguageFileDirectory($component, 'copg', 'lang/', 'ilias_%s') + ); + } + + public function testTheSamePrefixIsReportedAsSuch(): void + { + $component = $this->anyComponent(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('with the same prefix'); + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new ComponentLanguageFileDirectory($component, 'copg'), + new ComponentLanguageFileDirectory($component, 'copg') + ); + } + + /** The build warns about an invalid pattern, so it must not stop the manager from being built. */ + public function testADirectoryWithAnInvalidPatternIsLeftToTheBuild(): void + { + $invalid = new class () implements LanguageFileDirectory, NamesShippedLanguageFiles { + public function getPrefix(): string + { + return 'broken'; + } + + public function getPath(): string + { + return 'components/ILIAS/Broken/lang/'; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } + + public function getShippedFileNamePattern(): string + { + return 'no-placeholder'; + } + }; + + $manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $invalid); + + $this->assertSame([$invalid], iterator_to_array($manager->getDirectories(), false)); + } + + private function anyComponent(): Component + { + return new class () implements Component { + public function init( + array|\ArrayAccess &$define, + array|\ArrayAccess &$implement, + array|\ArrayAccess &$use, + array|\ArrayAccess &$contribute, + array|\ArrayAccess &$seek, + array|\ArrayAccess &$provide, + array|\ArrayAccess &$pull, + array|\ArrayAccess &$internal, + ): void { + } + }; + } +} From f0462d2cfa772a07a0d83884572446351b46550f Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 20 Oct 2026 15:00:04 +0200 Subject: [PATCH 072/103] Language: Assert the contributed modules of the component by their prefix `LanguageComponentGraphTest` counted the contributed directories by class only, so a changed or missing module went unnoticed. It now checks for exactly one `MainLanguageFileDirectory` and the prefixes of the `ComponentLanguageFileDirectory` contributions. --- .../Language/tests/LanguageComponentGraphTest.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index 02418ab79346..22df299f06b9 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -175,12 +175,16 @@ public function testContributeEntriesResolveToExpectedConcreteClasses(): void { $this->initComponent(); - $directories = array_map( - static fn(object $directory): string => $directory::class, - $this->contributionsOf(LanguageFileDirectory::class) + $directories = $this->contributionsOf(LanguageFileDirectory::class); + $main = array_filter($directories, static fn(object $d): bool => $d instanceof MainLanguageFileDirectory); + self::assertCount(1, $main); + $modules = array_map( + static fn(ComponentLanguageFileDirectory $d): string => $d->getPrefix(), + array_filter($directories, static fn(object $d): bool => $d instanceof ComponentLanguageFileDirectory) ); - sort($directories); - self::assertSame([ComponentLanguageFileDirectory::class, MainLanguageFileDirectory::class], $directories); + sort($modules); + self::assertSame(['lng'], $modules); + self::assertCount(count($main) + count($modules), $directories, 'only Main and component directories'); $this->contributedSetupAgent(); From 1449cf0f0a0e48d937f3e145d459d500b078508c Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Tue, 20 Oct 2026 16:31:41 +0200 Subject: [PATCH 073/103] Language: Refuse a new entry whose identifier exists in another module `AddLanguageEntry` rejects an identifier that `lng_data` already holds in another module or in another spelling (rule 2 of `docs/development/language.md`), unless the module already holds the entry itself. The page translation view of `ilObjLanguageExtGUI` no longer offers such topics as missing entries, via the same check (`RepositoryProvider::findDuplicatesOf()`, `filterAddable()`), and escapes the topic in the list and its link. --- .../classes/class.ilObjLanguageExtGUI.php | 12 +++- .../src/Activities/AddLanguageEntry.php | 11 +++ .../src/Activities/RepositoryProvider.php | 72 +++++++++++++++++++ .../AddLanguageEntryTestFixture.php | 4 ++ .../AddLanguageEntryValidationTest.php | 30 ++++++++ .../Activities/RepositoryProviderTest.php | 70 ++++++++++++++++++ .../tests/ilObjLanguageExtGUITest.php | 37 ++++++++++ 7 files changed, 233 insertions(+), 3 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php index e2fb635cfe4f..00e05b1329de 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php @@ -27,6 +27,7 @@ use ILIAS\Refinery\Factory as Refinery; use ILIAS\Language\Activities\AddLanguageEntry; use ILIAS\Language\Activities\RemoveLocalLanguageChanges; +use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; use ILIAS\Language\RendersActivityErrors; @@ -274,6 +275,8 @@ public function viewObject( $db_found[] = PluralFormKey::parse($keys[1])[0] ?? $keys[1]; } $missing_entries = array_diff($topics, $db_found); + // only offer what AddLanguageEntry accepts, a topic of another module would be a duplicate + $missing_entries = RepositoryProvider::filterAddable($DIC->database(), $modules, array_values($missing_entries)); } else { // normal view mode: // - the table is filtered manually by module, mode and pattern $filter_mode = $table_gui->getFilterItemByPostVar("mode")->getValue(); @@ -1186,9 +1189,12 @@ protected function buildMissingEntries(?array $a_missing = null): string $res = array("

" . $this->lng->txt("adm_missing_entries") . "

", "