From 9c99cb35e515a534c0e7215f958f9e34b711184e Mon Sep 17 00:00:00 2001 From: vdumas Date: Tue, 12 Sep 2023 12:21:25 +0200 Subject: [PATCH 01/11] =?UTF-8?q?N=C2=B06706=20-=20Wrong=20dictionary=20en?= =?UTF-8?q?try=20for=20FR=20-=20Lnk=20Provider=20Contract=20/=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dictionaries/en.dict.itop-service-mgmt.php | 2 ++ .../dictionaries/fr.dict.itop-service-mgmt.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/datamodels/2.x/itop-service-mgmt/dictionaries/en.dict.itop-service-mgmt.php b/datamodels/2.x/itop-service-mgmt/dictionaries/en.dict.itop-service-mgmt.php index df3df9095..baddc80c5 100644 --- a/datamodels/2.x/itop-service-mgmt/dictionaries/en.dict.itop-service-mgmt.php +++ b/datamodels/2.x/itop-service-mgmt/dictionaries/en.dict.itop-service-mgmt.php @@ -169,6 +169,8 @@ Dict::Add('EN US', 'English', 'English', array( 'Class:ProviderContract/Attribute:contracttype_id+' => '', 'Class:ProviderContract/Attribute:contracttype_name' => 'Contract type name', 'Class:ProviderContract/Attribute:contracttype_name+' => '', + 'Class:ProviderContract/Attribute:services_list' => 'Services', + 'Class:ProviderContract/Attribute:services_list+' => 'All the services purchased with this contract', )); // diff --git a/datamodels/2.x/itop-service-mgmt/dictionaries/fr.dict.itop-service-mgmt.php b/datamodels/2.x/itop-service-mgmt/dictionaries/fr.dict.itop-service-mgmt.php index 37ad423d6..60aab6793 100644 --- a/datamodels/2.x/itop-service-mgmt/dictionaries/fr.dict.itop-service-mgmt.php +++ b/datamodels/2.x/itop-service-mgmt/dictionaries/fr.dict.itop-service-mgmt.php @@ -157,6 +157,8 @@ Dict::Add('FR FR', 'French', 'Français', array( 'Class:ProviderContract/Attribute:contracttype_id+' => '', 'Class:ProviderContract/Attribute:contracttype_name' => 'Nom Type de contrat', 'Class:ProviderContract/Attribute:contracttype_name+' => '', + 'Class:ProviderContract/Attribute:services_list' => 'Services', + 'Class:ProviderContract/Attribute:services_list+' => 'Tous les services achetés par ce contrat', )); // From add433d702ee9104aa9ae83e674f41d2e1fea686 Mon Sep 17 00:00:00 2001 From: vdumas Date: Tue, 12 Sep 2023 12:24:56 +0200 Subject: [PATCH 02/11] =?UTF-8?q?N=C2=B06706=20-=20Missing=20dictionary=20?= =?UTF-8?q?entry=20for=20DE=20-=20Lnk=20Provider=20Contract=20/=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dictionaries/de.dict.itop-service-mgmt.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/datamodels/2.x/itop-service-mgmt/dictionaries/de.dict.itop-service-mgmt.php b/datamodels/2.x/itop-service-mgmt/dictionaries/de.dict.itop-service-mgmt.php index a2039d864..69a6cd3ff 100644 --- a/datamodels/2.x/itop-service-mgmt/dictionaries/de.dict.itop-service-mgmt.php +++ b/datamodels/2.x/itop-service-mgmt/dictionaries/de.dict.itop-service-mgmt.php @@ -157,6 +157,8 @@ Dict::Add('DE DE', 'German', 'Deutsch', array( 'Class:ProviderContract/Attribute:contracttype_id+' => '', 'Class:ProviderContract/Attribute:contracttype_name' => 'Vertragstyp-Name', 'Class:ProviderContract/Attribute:contracttype_name+' => '', + 'Class:ProviderContract/Attribute:services_list' => 'Services', + 'Class:ProviderContract/Attribute:services_list+' => 'Alle für diesen Vertrag erworbenen Services', )); // From faba812fc1f62056fff6c21cd2c769ebbbdd201f Mon Sep 17 00:00:00 2001 From: vdumas Date: Tue, 12 Sep 2023 12:48:33 +0200 Subject: [PATCH 03/11] =?UTF-8?q?N=C2=B06646=20-=20Wrong=20dictionary=20en?= =?UTF-8?q?try=20for=20FR=20-=20Lnk=20Contact=20/=20Contrat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dictionaries/fr.dict.itop-service-mgmt-provider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/datamodels/2.x/itop-service-mgmt-provider/dictionaries/fr.dict.itop-service-mgmt-provider.php b/datamodels/2.x/itop-service-mgmt-provider/dictionaries/fr.dict.itop-service-mgmt-provider.php index b1247c6db..7511f54cb 100644 --- a/datamodels/2.x/itop-service-mgmt-provider/dictionaries/fr.dict.itop-service-mgmt-provider.php +++ b/datamodels/2.x/itop-service-mgmt-provider/dictionaries/fr.dict.itop-service-mgmt-provider.php @@ -99,9 +99,9 @@ Dict::Add('FR FR', 'French', 'Français', array( 'Class:Contract/Attribute:organization_name' => 'Nom client', 'Class:Contract/Attribute:organization_name+' => 'Nom commun', 'Class:Contract/Attribute:contacts_list' => 'Contacts', - 'Class:Contract/Attribute:contacts_list+' => 'Tous les contacts for ce contrat client', + 'Class:Contract/Attribute:contacts_list+' => 'Tous les contacts pour ce contrat client', 'Class:Contract/Attribute:documents_list' => 'Documents', - 'Class:Contract/Attribute:documents_list+' => 'Tous les documents for ce contrat client', + 'Class:Contract/Attribute:documents_list+' => 'Tous les documents pour ce contrat client', 'Class:Contract/Attribute:description' => 'Description', 'Class:Contract/Attribute:description+' => '', 'Class:Contract/Attribute:start_date' => 'Date de début', From e76728b2bf47319486976105b8cb3708bcd86263 Mon Sep 17 00:00:00 2001 From: odain-cbd <56586767+odain-cbd@users.noreply.github.com> Date: Wed, 13 Sep 2023 12:02:49 +0200 Subject: [PATCH 04/11] =?UTF-8?q?N=C2=B05491=20-=20Inconsistent=20dictionn?= =?UTF-8?q?ary=20entries=20regarding=20arguments=20to=20pass=20to=20Dict::?= =?UTF-8?q?Format-test=20first=20(#545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/dict.class.inc.php | 73 ++++-- dictionaries/hu.dictionary.itop.core.php | 4 +- dictionaries/hu.dictionary.itop.ui.php | 6 +- dictionaries/ja.dictionary.itop.core.php | 4 +- dictionaries/ja.dictionary.itop.ui.php | 8 +- dictionaries/ru.dictionary.itop.core.php | 4 +- dictionaries/sk.dictionary.itop.ui.php | 8 +- dictionaries/tr.dictionary.itop.core.php | 4 +- .../DictionariesConsistencyAfterSetupTest.php | 242 ++++++++++++++++++ .../src/BaseTestCase/ItopTestCase.php | 63 ++++- 10 files changed, 362 insertions(+), 54 deletions(-) create mode 100644 tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php diff --git a/core/dict.class.inc.php b/core/dict.class.inc.php index 629094a52..33245f11d 100644 --- a/core/dict.class.inc.php +++ b/core/dict.class.inc.php @@ -3,7 +3,7 @@ // // This file is part of iTop. // -// iTop is free software; you can redistribute it and/or modify +// iTop is free software; you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. @@ -18,7 +18,7 @@ /** * Class Dict - * Management of localizable strings + * Management of localizable strings * * @copyright Copyright (C) 2010-2018 Combodo SARL * @license http://opensource.org/licenses/AGPL-3.0 @@ -144,33 +144,50 @@ class Dict * @return string */ public static function S($sStringCode, $sDefault = null, $bUserLanguageOnly = false) + { + $aInfo = self::GetLabelAndLangCode($sStringCode, $sDefault, $bUserLanguageOnly); + return $aInfo['label']; + } + + /** + * Returns a localised string from the dictonary with its associated lang code + * + * @param string $sStringCode The code identifying the dictionary entry + * @param string $sDefault Default value if there is no match in the dictionary + * @param bool $bUserLanguageOnly True to allow the use of the default language as a fallback, false otherwise + * + * @return array{ + * lang: string, label: string + * } with localized label string and used lang code + */ + private static function GetLabelAndLangCode($sStringCode, $sDefault = null, $bUserLanguageOnly = false) { // Attempt to find the string in the user language // $sLangCode = self::GetUserLanguage(); self::InitLangIfNeeded($sLangCode); - if (!array_key_exists($sLangCode, self::$m_aData)) + if (! array_key_exists($sLangCode, self::$m_aData)) { - IssueLog::Warning("Cannot find $sLangCode in dictionnaries. default labels displayed"); + IssueLog::Warning("Cannot find $sLangCode in all registered dictionaries."); // It may happen, when something happens before the dictionaries get loaded - return $sStringCode; + return [ 'label' => $sStringCode, 'lang' => $sLangCode ]; } $aCurrentDictionary = self::$m_aData[$sLangCode]; if (is_array($aCurrentDictionary) && array_key_exists($sStringCode, $aCurrentDictionary)) { - return $aCurrentDictionary[$sStringCode]; + return [ 'label' => $aCurrentDictionary[$sStringCode], 'lang' => $sLangCode ]; } if (!$bUserLanguageOnly) { // Attempt to find the string in the default language // self::InitLangIfNeeded(self::$m_sDefaultLanguage); - + $aDefaultDictionary = self::$m_aData[self::$m_sDefaultLanguage]; if (is_array($aDefaultDictionary) && array_key_exists($sStringCode, $aDefaultDictionary)) { - return $aDefaultDictionary[$sStringCode]; + return [ 'label' => $aDefaultDictionary[$sStringCode], 'lang' => self::$m_sDefaultLanguage ]; } // Attempt to find the string in english // @@ -179,17 +196,17 @@ class Dict $aDefaultDictionary = self::$m_aData['EN US']; if (is_array($aDefaultDictionary) && array_key_exists($sStringCode, $aDefaultDictionary)) { - return $aDefaultDictionary[$sStringCode]; + return [ 'label' => $aDefaultDictionary[$sStringCode], 'lang' => 'EN US' ]; } } // Could not find the string... // if (is_null($sDefault)) { - return $sStringCode; + return [ 'label' => $sStringCode, 'lang' => null ]; } - return $sDefault; + return [ 'label' => $sDefault, 'lang' => null ]; } @@ -205,19 +222,25 @@ class Dict */ public static function Format($sFormatCode /*, ... arguments ....*/) { - $sLocalizedFormat = self::S($sFormatCode); + ['label' => $sLocalizedFormat, 'lang' => $sLangCode] = self::GetLabelAndLangCode($sFormatCode); + $aArguments = func_get_args(); array_shift($aArguments); - + if ($sLocalizedFormat == $sFormatCode) { // Make sure the information will be displayed (ex: an error occuring before the dictionary gets loaded) return $sFormatCode.' - '.implode(', ', $aArguments); } - return vsprintf($sLocalizedFormat, $aArguments); + try{ + return vsprintf($sLocalizedFormat, $aArguments); + } catch(\Throwable $e){ + \IssueLog::Error("Cannot format dict key", null, ["sFormatCode" => $sFormatCode, "sLangCode" => $sLangCode, 'exception_msg' => $e->getMessage() ]); + return $sFormatCode.' - '.implode(', ', $aArguments); + } } - + /** * Initialize a the entries for a given language (replaces the former Add() method) * @param string $sLanguageCode Code identifying the language i.e. 'FR-FR', 'EN-US' @@ -227,7 +250,7 @@ class Dict { self::$m_aData[$sLanguageCode] = $aEntries; } - + /** * Set the list of available languages * @param hash $aLanguagesList @@ -288,7 +311,7 @@ class Dict { $sDictFile = APPROOT.'env-'.utils::GetCurrentEnvironment().'/dictionaries/'.str_replace(' ', '-', strtolower($sLangCode)).'.dict.php'; require_once($sDictFile); - + if (self::GetApcService()->function_exists('apc_store') && (self::$m_sApplicationPrefix !== null)) { @@ -298,7 +321,7 @@ class Dict } return $bResult; } - + /** * Enable caching (cached using APC) * @param string $sApplicationPrefix The prefix for uniquely identiying this iTop instance @@ -342,14 +365,14 @@ class Dict } } } - + public static function MakeStats($sLanguageCode, $sLanguageRef = 'EN US') { $aMissing = array(); // Strings missing for the target language $aUnexpected = array(); // Strings defined for the target language, but not found in the reference dictionary $aNotTranslated = array(); // Strings having the same value in both dictionaries $aOK = array(); // Strings having different values in both dictionaries - + foreach (self::$m_aData[$sLanguageRef] as $sStringCode => $sValue) { if (!array_key_exists($sStringCode, self::$m_aData[$sLanguageCode])) @@ -357,7 +380,7 @@ class Dict $aMissing[$sStringCode] = $sValue; } } - + foreach (self::$m_aData[$sLanguageCode] as $sStringCode => $sValue) { if (!array_key_exists($sStringCode, self::$m_aData[$sLanguageRef])) @@ -380,7 +403,7 @@ class Dict } return array($aMissing, $aUnexpected, $aNotTranslated, $aOK); } - + public static function Dump() { MyHelpers::var_dump_html(self::$m_aData); @@ -403,7 +426,7 @@ class Dict // No need to actually load the strings since it's only used to know the list of languages // at setup time !! } - + /** * Export all the dictionary entries - of the given language - whose code matches the given prefix * missing entries in the current language will be replaced by entries in the default language @@ -416,7 +439,7 @@ class Dict self::InitLangIfNeeded(self::$m_sDefaultLanguage); $aEntries = array(); $iLength = strlen($sStartingWith); - + // First prefill the array with entries from the default language foreach(self::$m_aData[self::$m_sDefaultLanguage] as $sCode => $sEntry) { @@ -425,7 +448,7 @@ class Dict $aEntries[$sCode] = $sEntry; } } - + // Now put (overwrite) the entries for the user language foreach(self::$m_aData[self::GetUserLanguage()] as $sCode => $sEntry) { diff --git a/dictionaries/hu.dictionary.itop.core.php b/dictionaries/hu.dictionary.itop.core.php index cf2cbe090..502680cf4 100755 --- a/dictionaries/hu.dictionary.itop.core.php +++ b/dictionaries/hu.dictionary.itop.core.php @@ -20,7 +20,7 @@ * @license http://opensource.org/licenses/AGPL-3.0 */ Dict::Add('HU HU', 'Hungarian', 'Magyar', array( - 'Core:DeletedObjectLabel' => '%1s (deleted)~~', + 'Core:DeletedObjectLabel' => '%1$s (deleted)~~', 'Core:DeletedObjectTip' => 'The object has been deleted on %1$s (%2$s)~~', 'Core:UnknownObjectLabel' => 'Object not found (class: %1$s, id: %2$d)~~', @@ -201,7 +201,7 @@ Operators:
'Core:AttributeTag' => 'Tags~~', 'Core:AttributeTag+' => 'Tags~~', - + 'Core:Context=REST/JSON' => 'REST~~', 'Core:Context=Synchro' => 'Synchro~~', 'Core:Context=Setup' => 'Setup~~', diff --git a/dictionaries/hu.dictionary.itop.ui.php b/dictionaries/hu.dictionary.itop.ui.php index 8434f2921..792c6e018 100755 --- a/dictionaries/hu.dictionary.itop.ui.php +++ b/dictionaries/hu.dictionary.itop.ui.php @@ -433,7 +433,7 @@ Dict::Add('HU HU', 'Hungarian', 'Magyar', array( 'UI:Error:2ParametersMissing' => 'Hiba: a következő paramétereket meg kell adni ennél a műveletnél: %1$s és %2$s.', 'UI:Error:3ParametersMissing' => 'Hiba: a következő paramétereket meg kell adni ennél a műveletnél: %1$s, %2$s és %3$s.', 'UI:Error:4ParametersMissing' => 'Hiba: a következő paramétereket meg kell adni ennél a műveletnél: %1$s, %2$s, %3$s és %4$s.', - 'UI:Error:IncorrectOQLQuery_Message' => 'Hiba: nem megfelelő OQL lekérdezés: %1$', + 'UI:Error:IncorrectOQLQuery_Message' => 'Hiba: nem megfelelő OQL lekérdezés: %1$s', 'UI:Error:AnErrorOccuredWhileRunningTheQuery_Message' => 'Hiba történt a lekérdezs futtatása közben: %1$s', 'UI:Error:ObjectAlreadyUpdated' => 'Hiba: az objketum már korábban módosításra került.', 'UI:Error:ObjectCannotBeUpdated' => 'Hiba: az objektum nem frissíthető.', @@ -1004,7 +1004,7 @@ Akció kiváltó okhoz rendelésekor kap egy sorszámot , amely meghatározza az 'Menu:UserAccountsMenu' => 'Felhasználói fiókok', // Duplicated into itop-welcome-itil (will be removed from here...) 'Menu:UserAccountsMenu+' => '', // Duplicated into itop-welcome-itil (will be removed from here...) - 'Menu:UserAccountsMenu:Title' => 'Felhasználói fiókok', // Duplicated into itop-welcome-itil (will be removed from here...) + 'Menu:UserAccountsMenu:Title' => 'Felhasználói fiókok', // Duplicated into itop-welcome-itil (will be removed from here...) 'UI:iTopVersion:Short' => '%1$s verzió: %2$s', 'UI:iTopVersion:Long' => '%1$s verzió: %2$s-%3$s %4$s', @@ -1019,7 +1019,7 @@ Akció kiváltó okhoz rendelésekor kap egy sorszámot , amely meghatározza az 'UI:Deadline_LessThan1Min' => '< 1 perc', 'UI:Deadline_Minutes' => '%1$d perc', 'UI:Deadline_Hours_Minutes' => '%1$dóra %2$dperc', - 'UI:Deadline_Days_Hours_Minutes' => '%1$nap %2$dóra %3$dperc', + 'UI:Deadline_Days_Hours_Minutes' => '%1$dnap %2$dóra %3$dperc', 'UI:Help' => 'Segítség', 'UI:PasswordConfirm' => '(Jóváhagyás)', 'UI:BeforeAdding_Class_ObjectsSaveThisObject' => '%1$s objektumok hozzáadása előtt mentse ezt az objektumot', diff --git a/dictionaries/ja.dictionary.itop.core.php b/dictionaries/ja.dictionary.itop.core.php index 39bd9618b..651a6c2dd 100644 --- a/dictionaries/ja.dictionary.itop.core.php +++ b/dictionaries/ja.dictionary.itop.core.php @@ -20,7 +20,7 @@ * @licence http://opensource.org/licenses/AGPL-3.0 */ Dict::Add('JA JP', 'Japanese', '日本語', array( - 'Core:DeletedObjectLabel' => '%1s (削除されました)', + 'Core:DeletedObjectLabel' => '%1$s (削除されました)', 'Core:DeletedObjectTip' => 'オブジェクトは削除されました %1$s (%2$s)', 'Core:UnknownObjectLabel' => 'オブジェクトは見つかりません (クラス: %1$s, id: %2$d)', @@ -201,7 +201,7 @@ Operators:
'Core:AttributeTag' => 'Tags~~', 'Core:AttributeTag+' => 'Tags~~', - + 'Core:Context=REST/JSON' => 'REST~~', 'Core:Context=Synchro' => 'Synchro~~', 'Core:Context=Setup' => 'Setup~~', diff --git a/dictionaries/ja.dictionary.itop.ui.php b/dictionaries/ja.dictionary.itop.ui.php index 8f3ad8e22..98cf85d31 100644 --- a/dictionaries/ja.dictionary.itop.ui.php +++ b/dictionaries/ja.dictionary.itop.ui.php @@ -794,7 +794,7 @@ Dict::Add('JA JP', 'Japanese', '日本語', array( 'UI:Delete:ConfirmDeletionOf_Count_ObjectsOf_Class' => '%2$sクラスの%1$dオブジェクトの削除', 'UI:Delete:CannotDeleteBecause' => '削除できません: %1$s', 'UI:Delete:ShouldBeDeletedAtomaticallyButNotPossible' => '自動的に削除されるべきですが、出来ません。: %1$s', - 'UI:Delete:MustBeDeletedManuallyButNotPossible' => '手動で削除されるべきですが、出来ません。: %1$', + 'UI:Delete:MustBeDeletedManuallyButNotPossible' => '手動で削除されるべきですが、出来ません。: %1$s', 'UI:Delete:WillBeDeletedAutomatically' => '自動的に削除されます。', 'UI:Delete:MustBeDeletedManually' => '手動で削除されるべきです。', 'UI:Delete:CannotUpdateBecause_Issue' => '自動的に更新されるべきですが、しかし: %1$s', @@ -1005,7 +1005,7 @@ Dict::Add('JA JP', 'Japanese', '日本語', array( 'Menu:UserAccountsMenu' => 'ユーザアカウント', // Duplicated into itop-welcome-itil (will be removed from here...) 'Menu:UserAccountsMenu+' => 'ユーザアカウント', // Duplicated into itop-welcome-itil (will be removed from here...) - 'Menu:UserAccountsMenu:Title' => 'ユーザアカウント', // Duplicated into itop-welcome-itil (will be removed from here...) + 'Menu:UserAccountsMenu:Title' => 'ユーザアカウント', // Duplicated into itop-welcome-itil (will be removed from here...) 'UI:iTopVersion:Short' => '%1$sバージョン%2$s', 'UI:iTopVersion:Long' => '%1$sバージョン%2$s-%3$s ビルド%4$s', @@ -1094,7 +1094,7 @@ Dict::Add('JA JP', 'Japanese', '日本語', array( 'Enum:Undefined' => '未定義', 'UI:DurationForm_Days_Hours_Minutes_Seconds' => '%1$s 日 %2$s 時 %3$s 分 %4$s 秒', 'UI:ModifyAllPageTitle' => '全てを修正', - 'UI:Modify_N_ObjectsOf_Class' => 'クラス%2$Sの%1$dオブジェクトを修正', + 'UI:Modify_N_ObjectsOf_Class' => 'クラス%2$sの%1$dオブジェクトを修正', 'UI:Modify_M_ObjectsOf_Class_OutOf_N' => 'クラス%2$sの%3$d中%1$dを修正', 'UI:Menu:ModifyAll' => '修正...', 'UI:Button:ModifyAll' => '全て修正', @@ -1111,7 +1111,7 @@ Dict::Add('JA JP', 'Japanese', '日本語', array( 'UI:BulkModify_Count_DistinctValues' => '%1$d 個の個別の値:', 'UI:BulkModify:Value_Exists_N_Times' => '%1$s, %2$d 回存在', 'UI:BulkModify:N_MoreValues' => '%1$d 個以上の値...', - 'UI:AttemptingToSetAReadOnlyAttribute_Name' => '読み込み専用フィールド %1$にセットしょうとしています。', + 'UI:AttemptingToSetAReadOnlyAttribute_Name' => '読み込み専用フィールド %1$sにセットしょうとしています。', 'UI:FailedToApplyStimuli' => 'アクションは失敗しました。', 'UI:StimulusModify_N_ObjectsOf_Class' => '%1$s: クラス%3$sの%2$dオブジェクトを修正', 'UI:CaseLogTypeYourTextHere' => 'テキストを入力ください:', diff --git a/dictionaries/ru.dictionary.itop.core.php b/dictionaries/ru.dictionary.itop.core.php index 3aedc2f27..a1a26429a 100644 --- a/dictionaries/ru.dictionary.itop.core.php +++ b/dictionaries/ru.dictionary.itop.core.php @@ -9,7 +9,7 @@ * */ Dict::Add('RU RU', 'Russian', 'Русский', array( - 'Core:DeletedObjectLabel' => '%1ы (удален)', + 'Core:DeletedObjectLabel' => '%1$sы (удален)', 'Core:DeletedObjectTip' => 'Объект был удален %1$s (%2$s)', 'Core:UnknownObjectLabel' => 'Объект не найден (class: %1$s, id: %2$d)', @@ -190,7 +190,7 @@ Dict::Add('RU RU', 'Russian', 'Русский', array( 'Core:AttributeTag' => 'Тег', 'Core:AttributeTag+' => 'Тег', - + 'Core:Context=REST/JSON' => 'REST', 'Core:Context=Synchro' => 'Synchro', 'Core:Context=Setup' => 'Setup', diff --git a/dictionaries/sk.dictionary.itop.ui.php b/dictionaries/sk.dictionary.itop.ui.php index 3574a85d4..7e4c010a3 100644 --- a/dictionaries/sk.dictionary.itop.ui.php +++ b/dictionaries/sk.dictionary.itop.ui.php @@ -4,7 +4,7 @@ * * This file is part of iTop. * - * iTop is free software; you can redistribute it and/or modify + * iTop is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. @@ -419,7 +419,7 @@ Dict::Add('SK SK', 'Slovak', 'Slovenčina', array( 'UI:Error:MandatoryTemplateParameter_group_by' => 'Parameter group_by je povinný. Skontrolujte definíciu šablóny zobrazenia.', 'UI:Error:InvalidGroupByFields' => 'Neplatný zoznam polí pre skupinu podľa: "%1$s".', 'UI:Error:UnsupportedStyleOfBlock' => 'Chyba: nepodporovaný štýl bloku: "%1$s".', - 'UI:Error:IncorrectLinkDefinition_LinkedClass_Class' => 'Nesprávna definícia spojenia : trieda objektov na manažovanie : %l$s nebol nájdený ako externý kľúč v triede %2$s', + 'UI:Error:IncorrectLinkDefinition_LinkedClass_Class' => 'Nesprávna definícia spojenia : trieda objektov na manažovanie : %1$s nebol nájdený ako externý kľúč v triede %2$s', 'UI:Error:Object_Class_Id_NotFound' => 'Objekt: %1$s:%2$d nebol nájdený.', 'UI:Error:WizardCircularReferenceInDependencies' => 'Chyba: Cyklický odkaz v závislostiach medzi poliami, skontrolujte dátový model.', 'UI:Error:UploadedFileTooBig' => 'Nahraný súbor je príliš veľký. (Max povolená veľkosť je %1$s). Ak chcete zmeniť tento limit, obráťte sa na správcu ITOP . (Skontrolujte, PHP konfiguráciu pre upload_max_filesize a post_max_size na serveri).', @@ -1007,7 +1007,7 @@ Keď sú priradené spúštačom, každej akcii je dané číslo "príkazu", šp 'Menu:UserAccountsMenu' => 'Užívateľské účty', // Duplicated into itop-welcome-itil (will be removed from here...) 'Menu:UserAccountsMenu+' => '', // Duplicated into itop-welcome-itil (will be removed from here...) - 'Menu:UserAccountsMenu:Title' => 'Užívateľské účty', // Duplicated into itop-welcome-itil (will be removed from here...) + 'Menu:UserAccountsMenu:Title' => 'Užívateľské účty', // Duplicated into itop-welcome-itil (will be removed from here...) 'UI:iTopVersion:Short' => 'iTop verzia %1$s', 'UI:iTopVersion:Long' => 'iTop verzia %1$s-%2$s postavená na %3$s', @@ -1238,7 +1238,7 @@ Keď sú priradené spúštačom, každej akcii je dané číslo "príkazu", šp 'UI:DashletGroupBy:Prop-GroupBy:DayOfMonth' => 'Deň v mesiaci pre %1$s', 'UI:DashletGroupBy:Prop-GroupBy:Select-Hour' => '%1$s (hodina)', 'UI:DashletGroupBy:Prop-GroupBy:Select-Month' => '%1$s (mesiac)', - 'UI:DashletGroupBy:Prop-GroupBy:Select-DayOfWeek' => '%1$ (deň v týžni)', + 'UI:DashletGroupBy:Prop-GroupBy:Select-DayOfWeek' => '%1$s (deň v týžni)', 'UI:DashletGroupBy:Prop-GroupBy:Select-DayOfMonth' => '%1$s (deň v mesiaci)', 'UI:DashletGroupBy:MissingGroupBy' => 'Prosím zvoľte pole na ktorom objekty budú zoskupené spolu', diff --git a/dictionaries/tr.dictionary.itop.core.php b/dictionaries/tr.dictionary.itop.core.php index 0d99e1b94..82f36b56a 100644 --- a/dictionaries/tr.dictionary.itop.core.php +++ b/dictionaries/tr.dictionary.itop.core.php @@ -211,7 +211,7 @@ Operators:
'Core:AttributeTag' => 'Tags~~', 'Core:AttributeTag+' => 'Tags~~', - + 'Core:Context=REST/JSON' => 'REST~~', 'Core:Context=Synchro' => 'Synchro~~', 'Core:Context=Setup' => 'Setup~~', @@ -309,7 +309,7 @@ Dict::Add('TR TR', 'Turkish', 'Türkçe', array( 'Change:AttName_SetTo_NewValue_PreviousValue_OldValue' => '%1$s\'nin değeri %2$s olarak atandı (önceki değer: %3$s)', 'Change:AttName_SetTo' => '%1$s\'nin değeri %2$s olarak atandı', 'Change:Text_AppendedTo_AttName' => '%2$s\'ye %1$s eklendi', - 'Change:AttName_Changed_PreviousValue_OldValue' => '%1$\'nin değeri deiştirildi, önceki değer: %2$s', + 'Change:AttName_Changed_PreviousValue_OldValue' => '%1$snin değeri deiştirildi, önceki değer: %2$s', 'Change:AttName_Changed' => '%1$s değiştirildi', 'Change:AttName_EntryAdded' => '%1$s değiştirilmiş, yeni giriş eklendi.', 'Change:LinkSet:Added' => '%1$s \'eklendi', diff --git a/tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php b/tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php new file mode 100644 index 000000000..8b54282f6 --- /dev/null +++ b/tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php @@ -0,0 +1,242 @@ + [ + 'sTemplate' => null, + 'sExpectedTraduction' => 'ITOP::DICT:FORMAT:BROKEN:KEY - 1', + ], + 'traduction that breaks expected nb of arguments' => [ + 'sTemplate' => 'toto %1$s titi %2$s', + 'sExpectedTraduction' => 'ITOP::DICT:FORMAT:BROKEN:KEY - 1', + ], + 'traduction ok' => [ + 'sTemplate' => 'toto %1$s titi', + 'sExpectedTraduction' => 'toto 1 titi', + ], + ]; + } + + /** + * @param $sTemplate : if null it will not create dict entry + * @since 2.7.10 N°5491 - Inconsistent dictionary entries regarding arguments to pass to Dict::Format + * @dataProvider FormatProvider + */ + public function testFormatWithOneArgumentAndCustomKey(?string $sTemplate, $sExpectedTranslation){ + //tricky way to mock GetLabelAndLangCode behavior via connected user language + $sLangCode = \Dict::GetUserLanguage(); + $aDictByLang = $this->GetNonPublicStaticProperty(\Dict::class, 'm_aData'); + $sDictKey = 'ITOP::DICT:FORMAT:BROKEN:KEY'; + + if (! is_null($sTemplate)){ + $aDictByLang[$sLangCode][$sDictKey] = $sTemplate; + } + + $this->SetNonPublicStaticProperty(\Dict::class, 'm_aData', $aDictByLang); + + $this->assertEquals($sExpectedTranslation, \Dict::Format($sDictKey, "1")); + } + + //test works after setup (no annotation @beforesetup) + //even if it does not extend ItopDataTestCase + private function ReadDictKeys($sLangCode) : array { + \Dict::InitLangIfNeeded($sLangCode); + + $aDictEntries = $this->GetNonPublicStaticProperty(\Dict::class, 'm_aData'); + return $aDictEntries[$sLangCode]; + } + + /** + * foreach dictionnary label map (key/value) it counts the number argument that should be passed to use Dict::Format + * examples: + * for "gabu zomeu" label there are no args + * for "shadok %1 %2 %3" there are 3 args + * + * limitation: there is no validation check for "%3 itop %2 combodo" which seems unconsistent + * @param $aDictEntry + * + * @return array + */ + private function GetKeyArgCountMap($aDictEntry) { + $aKeyArgsCount = []; + foreach ($aDictEntry as $sKey => $sValue){ + $aKeyArgsCount[$sKey] = $this->countArg($sValue); + } + ksort($aKeyArgsCount); + return $aKeyArgsCount; + } + + private function countArg($sLabel) { + $iMaxIndex = 0; + if (preg_match_all("/%(\d+)/", $sLabel, $aMatches)){ + $aSubMatches = $aMatches[1]; + if (is_array($aSubMatches)){ + foreach ($aSubMatches as $aCurrentMatch){ + $iIndex = $aCurrentMatch; + $iMaxIndex = ($iMaxIndex < $iIndex) ? $iIndex : $iMaxIndex; + } + } + } else if ((false !== strpos($sLabel, "%s")) + || (false !== strpos($sLabel, "%d")) + ){ + $iMaxIndex = 1; + } + + return $iMaxIndex; + } + + /** + * Warning: hardcoded list of languages + * It is hard to have it dynamically via Dict::GetLanguages as for each lang Dict::Init should be called first + **/ + public function LangCodeProvider(){ + return [ + 'cs' => [ 'CS CZ' ], + 'da' => [ 'DA DA' ], + 'de' => [ 'DE DE' ], + 'en' => [ 'EN US' ], + 'es' => [ 'ES CR' ], + 'fr' => [ 'FR FR' ], + 'hu' => [ 'HU HU' ], + 'it' => [ 'IT IT' ], + 'ja' => [ 'JA JP' ], + 'nl' => [ 'NL NL' ], + 'pt' => [ 'PT BR' ], + 'ru' => [ 'RU RU' ], + 'sk' => [ 'SK SK' ], + 'tr' => [ 'TR TR' ], + 'zh' => [ 'ZH CN' ], + ]; + } + + /** + * compare en and other dictionaries and check that for all labels there is the same number of arguments + * if not Dict::Format could raise an exception for some languages. translation should be done again... + * @dataProvider LangCodeProvider + */ + public function testDictEntryValues($sLanguageCodeToTest) + { + $sReferenceLangCode = 'EN US'; + $aReferenceLangDictEntry = $this->ReadDictKeys($sReferenceLangCode); + + $aDictEntry = $this->ReadDictKeys($sLanguageCodeToTest); + + + $aKeyArgsCountMap = []; + $aKeyArgsCountMap[$sReferenceLangCode] = $this->GetKeyArgCountMap($aReferenceLangDictEntry); + //$aKeyArgsCountMap[$sCode] = $this->GetKeyArgCountMap($aDictEntry); + + //set user language + $this->SetNonPublicStaticProperty(\Dict::class, 'm_sCurrentLanguage', $sLanguageCodeToTest); + + $aMismatchedKeys = []; + + foreach ($aKeyArgsCountMap[$sReferenceLangCode] as $sKey => $iExpectedNbOfArgs){ + if (in_array($sKey, self::$aLabelCodeNotToCheck)){ + //false positive: do not test + continue; + } + + if (array_key_exists($sKey, $aDictEntry)){ + $aPlaceHolders = []; + for ($i=0; $i<$iExpectedNbOfArgs; $i++){ + $aPlaceHolders[]=$i; + } + + $sLabelTemplate = $aDictEntry[$sKey]; + try{ + vsprintf($sLabelTemplate, $aPlaceHolders); + } catch(\Throwable $e){ + $sError = $e->getMessage(); + if (array_key_exists($sError, $aMismatchedKeys)){ + $aMismatchedKeys[$sError][$sKey] = $iExpectedNbOfArgs; + } else { + $aMismatchedKeys[$sError] = [$sKey => $iExpectedNbOfArgs]; + } + } + } + } + + $iCount = 0; + foreach ($aMismatchedKeys as $sError => $aKeys){ + var_dump($sError); + foreach ($aKeys as $sKey => $iExpectedNbOfArgs) { + $iCount++; + if ($sReferenceLangCode === $sLanguageCodeToTest) { + var_dump([ + 'key label' => $sKey, + 'expected nb of expected args' => $iExpectedNbOfArgs, + "key value in $sLanguageCodeToTest" => $aDictEntry[$sKey], + ]); + } else { + var_dump([ + 'key label' => $sKey, + 'expected nb of expected args' => $iExpectedNbOfArgs, + "key value in $sLanguageCodeToTest" => $aDictEntry[$sKey], + "key value in $sReferenceLangCode" => $aReferenceLangDictEntry[$sKey], + ]); + } + } + } + + $sErrorMsg = sprintf("%s broken propertie(s) on $sLanguageCodeToTest dictionaries! either change the dict value in $sLanguageCodeToTest or add it in ignored label list (cf aLabelCodeNotToCheck)", $iCount); + $this->assertEquals([], $aMismatchedKeys, $sErrorMsg); + } + + public function VsprintfProvider(){ + return [ + 'not enough args' => [ + "sLabelTemplate" => "$1%s", + "aPlaceHolders" => [], + ], + 'exact nb of args' => [ + "sLabelTemplate" => "$1%s", + "aPlaceHolders" => ["1"], + ], + 'too much args' => [ + "sLabelTemplate" => "$1%s", + "aPlaceHolders" => ["1", "2"], + ], + '\"% ok\" without args' => [ + "sLabelTemplate" => "% ok", + "aPlaceHolders" => [], + ], + '\"% ok $1%s\" without args' => [ + "sLabelTemplate" => "% ok", + "aPlaceHolders" => ['1'], + ], + ]; + } + + /** + * @dataProvider VsprintfProvider + public function testVsprintf($sLabelTemplate, $aPlaceHolders){ + try{ + $this->markTestSkipped("usefull to check a specific PHP version behavior"); + vsprintf($sLabelTemplate, $aPlaceHolders); + $this->assertTrue(true); + } catch(\Throwable $e) { + $this->assertTrue(false, "label \'" . $sLabelTemplate . " failed with " . var_export($aPlaceHolders, true) ); + } + } + */ +} diff --git a/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php index 821c0140f..cd5bcb0a5 100644 --- a/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php +++ b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php @@ -222,6 +222,21 @@ abstract class ItopTestCase extends TestCase return $method->invokeArgs($oObject, $aArgs); } + /** + * @param string $sClass + * @param string $sProperty + * + * @return mixed property + * + * @throws \ReflectionException + * @since 2.7.10 3.1.0 + */ + public function GetNonPublicStaticProperty(string $sClass, string $sProperty) + { + $oProperty = $this->GetProperty($sClass, $sProperty); + + return $oProperty->getValue(); + } /** * @param object $oObject @@ -234,11 +249,27 @@ abstract class ItopTestCase extends TestCase */ public function GetNonPublicProperty(object $oObject, string $sProperty) { - $class = new \ReflectionClass(get_class($oObject)); - $property = $class->getProperty($sProperty); - $property->setAccessible(true); + $oProperty = $this->GetProperty(get_class($oObject), $sProperty); - return $property->getValue($oObject); + return $oProperty->getValue($oObject); + } + + /** + * @param string $sClass + * @param string $sProperty + * + * @return \ReflectionProperty + * + * @throws \ReflectionException + * @since 2.7.10 3.1.0 + */ + private function GetProperty(string $sClass, string $sProperty) + { + $oClass = new \ReflectionClass($sClass); + $oProperty = $oClass->getProperty($sProperty); + $oProperty->setAccessible(true); + + return $oProperty; } /** @@ -251,10 +282,22 @@ abstract class ItopTestCase extends TestCase */ public function SetNonPublicProperty(object $oObject, string $sProperty, $value) { - $class = new \ReflectionClass(get_class($oObject)); - $property = $class->getProperty($sProperty); - $property->setAccessible(true); - - $property->setValue($oObject, $value); + $oProperty = $this->GetProperty(get_class($oObject), $sProperty); + $oProperty->setValue($oObject, $value); } -} \ No newline at end of file + + /** + * @param string $sClass + * @param string $sProperty + * @param $value + * + * @throws \ReflectionException + * @since 2.7.10 3.1.0 + */ + public function SetNonPublicStaticProperty(string $sClass, string $sProperty, $value) + { + $oProperty = $this->GetProperty($sClass, $sProperty); + $oProperty->setValue($value); + } + +} From f65c6904625377771aa2bfd585b508b6340cc969 Mon Sep 17 00:00:00 2001 From: odain Date: Wed, 13 Sep 2023 10:03:05 +0200 Subject: [PATCH 05/11] =?UTF-8?q?N=C2=B05491-fix=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dictionaries/en.dictionary.itop.ui.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dictionaries/en.dictionary.itop.ui.php b/dictionaries/en.dictionary.itop.ui.php index 4b07c6cdf..2f00a0807 100644 --- a/dictionaries/en.dictionary.itop.ui.php +++ b/dictionaries/en.dictionary.itop.ui.php @@ -730,7 +730,7 @@ We hope you’ll enjoy this version as much as we enjoyed imagining and creating 'UI:RunQuery:DevelopedOQLCount' => 'Developed OQL for count', 'UI:RunQuery:ResultSQLCount' => 'Resulting SQL for count', 'UI:RunQuery:ResultSQL' => 'Resulting SQL', - 'UI:RunQuery:Error' => 'An error occured while running the query', + 'UI:RunQuery:Error' => 'An error occured while running the query: %1$s', 'UI:Query:UrlForExcel' => 'URL to use for MS-Excel web queries', 'UI:Query:UrlV1' => 'The list of fields has been left unspecified. The page export-V2.php cannot be invoked without this information. Therefore, the URL suggested here below points to the legacy page: export.php. This legacy version of the export has the following limitation: the list of exported fields may vary depending on the output format and the data model of '.ITOP_APPLICATION_SHORT.'.
Should you want to guarantee that the list of exported columns will remain stable on the long run, then you must specify a value for the attribute "Fields" and use the page export-V2.php.', 'UI:Schema:Title' => ITOP_APPLICATION_SHORT.' objects schema', From 7292a8540b7a0c18010cfac4d3f6978367bd724e Mon Sep 17 00:00:00 2001 From: Stephen Abello Date: Wed, 13 Sep 2023 10:53:32 +0200 Subject: [PATCH 06/11] =?UTF-8?q?N=C2=B06547=20-=20Disallow=20linkset=20ed?= =?UTF-8?q?ition=20when=20lnk=20attribute=20is=20readonly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Links/AbstractBlockLinkSetViewTable.php | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/sources/Application/UI/Links/AbstractBlockLinkSetViewTable.php b/sources/Application/UI/Links/AbstractBlockLinkSetViewTable.php index ee2d91546..dfc1522e3 100644 --- a/sources/Application/UI/Links/AbstractBlockLinkSetViewTable.php +++ b/sources/Application/UI/Links/AbstractBlockLinkSetViewTable.php @@ -70,6 +70,9 @@ abstract class AbstractBlockLinkSetViewTable extends UIContentBlock /** @var AttributeLinkedSet $oAttDef attribute link set */ protected AttributeLinkedSet $oAttDef; + /** @var bool $bIsAttEditable Is attribute editable */ + protected bool $bIsAttEditable; + /** @var string $sTargetClass links target classname */ protected string $sTargetClass; @@ -119,11 +122,12 @@ abstract class AbstractBlockLinkSetViewTable extends UIContentBlock private function Init() { $this->sTargetClass = $this->GetTargetClass(); - + $this->InitIsAttEditable(); + // User rights - $this->bIsAllowCreate = UserRights::IsActionAllowed($this->oAttDef->GetLinkedClass(), UR_ACTION_CREATE) == UR_ALLOWED_YES; - $this->bIsAllowModify = UserRights::IsActionAllowed($this->oAttDef->GetLinkedClass(), UR_ACTION_MODIFY) == UR_ALLOWED_YES; - $this->bIsAllowDelete = UserRights::IsActionAllowed($this->oAttDef->GetLinkedClass(), UR_ACTION_DELETE) == UR_ALLOWED_YES; + $this->bIsAllowCreate = $this->bIsAttEditable && UserRights::IsActionAllowed($this->oAttDef->GetLinkedClass(), UR_ACTION_CREATE) == UR_ALLOWED_YES; + $this->bIsAllowModify = $this->bIsAttEditable && UserRights::IsActionAllowed($this->oAttDef->GetLinkedClass(), UR_ACTION_MODIFY) == UR_ALLOWED_YES; + $this->bIsAllowDelete = $this->bIsAttEditable && UserRights::IsActionAllowed($this->oAttDef->GetLinkedClass(), UR_ACTION_DELETE) == UR_ALLOWED_YES; } @@ -196,6 +200,26 @@ abstract class AbstractBlockLinkSetViewTable extends UIContentBlock $this->AddSubBlock($oBlock->GetRenderContent($oPage, $this->GetExtraParam(), $this->sTableId)); } + /** + * @return void + * @throws \CoreException + */ + private function InitIsAttEditable(): void + { + $iFlags = 0; + + if ($this->oDbObject->IsNew()) + { + $iFlags = $this->oDbObject->GetInitialStateAttributeFlags($this->sAttCode); + } + else + { + $iFlags = $this->oDbObject->GetAttributeFlags($this->sAttCode); + } + + $this->bIsAttEditable = !($iFlags & (OPT_ATT_READONLY | OPT_ATT_SLAVE | OPT_ATT_HIDDEN)); + } + /** * GetTableId. * From 3c51d6fb9895ea891144ab29fddbc0e1798794dd Mon Sep 17 00:00:00 2001 From: odain Date: Wed, 13 Sep 2023 10:27:19 +0200 Subject: [PATCH 07/11] =?UTF-8?q?N=C2=B05491-=20fix=20dictionary=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dictionaries/hu.dictionary.itop.ui.php | 2 +- dictionaries/tr.dictionary.itop.core.php | 2 +- .../navigation-menu/pt_br.dictionary.itop.navigation-menu.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dictionaries/hu.dictionary.itop.ui.php b/dictionaries/hu.dictionary.itop.ui.php index 53b8103df..f020bdbc9 100755 --- a/dictionaries/hu.dictionary.itop.ui.php +++ b/dictionaries/hu.dictionary.itop.ui.php @@ -715,7 +715,7 @@ Reméljük, hogy ezt a verziót ugyanúgy kedvelni fogja, mint ahogy mi élvezt 'UI:CSVReport-Value-Issue-Null' => 'A nulla nem engedélyezett', 'UI:CSVReport-Value-Issue-NotFound' => 'Az objektum nincs meg', 'UI:CSVReport-Value-Issue-FoundMany' => '%1$d egyezés található', - 'UI:CSVReport-Value-Issue-Readonly' => 'A \'%1$\'s attribútum csak olvasható (jelenlegi érték: %2$s, várható érték: %3$s)', + 'UI:CSVReport-Value-Issue-Readonly' => 'A \'%1$s attribútum csak olvasható (jelenlegi érték: %2$s, várható érték: %3$s)', 'UI:CSVReport-Value-Issue-Format' => 'A bevitel feldolgozása sikertelen: %1$s', 'UI:CSVReport-Value-Issue-NoMatch' => 'A \'%1$s\' attribútum nem várt értéket kapott: nincs egyezés, ellenőrizze a beírást', 'UI:CSVReport-Value-Issue-AllowedValues' => 'Allowed \'%1$s\' value(s): %2$s~~', diff --git a/dictionaries/tr.dictionary.itop.core.php b/dictionaries/tr.dictionary.itop.core.php index 8c4bcb81f..e6e697fcd 100644 --- a/dictionaries/tr.dictionary.itop.core.php +++ b/dictionaries/tr.dictionary.itop.core.php @@ -278,7 +278,7 @@ Dict::Add('TR TR', 'Turkish', 'Türkçe', array( 'Change:AttName_SetTo_NewValue_PreviousValue_OldValue' => '%1$s\'nin değeri %2$s olarak atandı (önceki değer: %3$s)', 'Change:AttName_SetTo' => '%1$s\'nin değeri %2$s olarak atandı', 'Change:Text_AppendedTo_AttName' => '%2$s\'ye %1$s eklendi', - 'Change:AttName_Changed_PreviousValue_OldValue' => '%1$snin değeri deiştirildi, önceki değer: %2$s', + 'Change:AttName_Changed_PreviousValue_OldValue' => '%1$s nin değeri deiştirildi, önceki değer: %2$s', 'Change:AttName_Changed' => '%1$s değiştirildi', 'Change:AttName_EntryAdded' => '%1$s değiştirilmiş, yeni giriş eklendi.', 'Change:State_Changed_NewValue_OldValue' => 'Changed from %2$s to %1$s~~', diff --git a/dictionaries/ui/layouts/navigation-menu/pt_br.dictionary.itop.navigation-menu.php b/dictionaries/ui/layouts/navigation-menu/pt_br.dictionary.itop.navigation-menu.php index c6f064574..c70d1a5b3 100644 --- a/dictionaries/ui/layouts/navigation-menu/pt_br.dictionary.itop.navigation-menu.php +++ b/dictionaries/ui/layouts/navigation-menu/pt_br.dictionary.itop.navigation-menu.php @@ -27,8 +27,8 @@ Dict::Add('PT BR', 'Brazilian', 'Brazilian', array( 'UI:Layout:NavigationMenu:MenuFilter:Input:Hint' => 'As correspondências em todos os grupos de menus serão exibidas', 'UI:Layout:NavigationMenu:MenuFilter:Placeholder:Hint' => 'Nenhum resultado para este filtro de menu', 'UI:Layout:NavigationMenu:UserInfo:WelcomeMessage:Text' => 'Olá %1$s!', - 'UI:Layout:NavigationMenu:UserInfo:Picture:AltText' => 'Imagem do contato %1$', + 'UI:Layout:NavigationMenu:UserInfo:Picture:AltText' => 'Imagem do contato %1$s', 'UI:Layout:NavigationMenu:UserMenu:Toggler:Label' => 'Abrir menu do usuário', 'UI:Layout:NavigationMenu:KeyboardShortcut:FocusFilter' => 'Filtrar entradas de menu', -)); \ No newline at end of file +)); From 3e258f32cc5cd08cabaf96c3e4d560133de2bf8b Mon Sep 17 00:00:00 2001 From: odain Date: Wed, 13 Sep 2023 10:30:56 +0200 Subject: [PATCH 08/11] =?UTF-8?q?N=C2=B05491-fix=20redundant=20GetNonPubli?= =?UTF-8?q?cStaticProperty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/BaseTestCase/ItopTestCase.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php index 74006650f..d7ee25386 100644 --- a/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php +++ b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php @@ -276,25 +276,9 @@ abstract class ItopTestCase extends TestCase } /** - * @param string $sClass - * @param string $sProperty - * - * @return mixed property - * - * @throws \ReflectionException * @since 2.7.10 3.1.0 */ public function GetNonPublicStaticProperty(string $sClass, string $sProperty) - { - $oProperty = $this->GetProperty($sClass, $sProperty); - - return $oProperty->getValue(); - } - - /** - * @since 3.1.0 - */ - public function GetNonPublicStaticProperty(string $sClass, string $sProperty) { /** @noinspection OneTimeUseVariablesInspection */ $oProperty = $this->GetProperty($sClass, $sProperty); From 5acf38ac360eabb9d889e0fddf9b06045cf5042e Mon Sep 17 00:00:00 2001 From: Eric Espie Date: Fri, 1 Sep 2023 09:29:07 +0200 Subject: [PATCH 09/11] :white_check_mark: Fix unit tests for MariaDB (cherry picked from commit 61a9a4ac654eb6ae0ca3e153548a1ac18f2d7e54) --- .../unitary-tests/core/ExpressionEvaluateTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php-unit-tests/unitary-tests/core/ExpressionEvaluateTest.php b/tests/php-unit-tests/unitary-tests/core/ExpressionEvaluateTest.php index 71428ab09..6c9733964 100644 --- a/tests/php-unit-tests/unitary-tests/core/ExpressionEvaluateTest.php +++ b/tests/php-unit-tests/unitary-tests/core/ExpressionEvaluateTest.php @@ -546,7 +546,7 @@ class ExpressionEvaluateTest extends iTopDataTestCase $oDate = new DateTime($sStartDate); for ($i = 0 ; $i < $iRepeat ; $i++) { - $sDate = date_format($oDate, 'Y-m-d, H:i:s'); + $sDate = date_format($oDate, 'Y-m-d H:i:s'); $this->debug("Checking '$sDate'"); $this->testEveryTimeFormat($sDate); $oDate->add(new DateInterval($sInterval)); From 7fdbb59c3072aab29a1e6df9a901b7e0d20443ae Mon Sep 17 00:00:00 2001 From: Eric Espie Date: Wed, 13 Sep 2023 15:58:26 +0200 Subject: [PATCH 10/11] =?UTF-8?q?N=C2=B06716=20-=20High=20memory=20Consomp?= =?UTF-8?q?tion=20and=20performance=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/dbobject.class.php | 105 ++++++++++++++++-- pages/schema.php | 13 ++- setup/compiler.class.inc.php | 7 +- sources/Service/Events/EventService.php | 13 ++- .../unitary-tests/core/CRUDEventTest.php | 19 ++++ .../MockDBObjectWithCRUDEventListener.php | 44 ++++++++ .../unitary-tests/core/DBObjectTest.php | 36 ++++++ 7 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 tests/php-unit-tests/unitary-tests/core/DBObject/MockDBObjectWithCRUDEventListener.php diff --git a/core/dbobject.class.php b/core/dbobject.class.php index 5b1e3dd18..8466d4a66 100644 --- a/core/dbobject.class.php +++ b/core/dbobject.class.php @@ -6,7 +6,9 @@ use Combodo\iTop\Core\MetaModel\FriendlyNameType; use Combodo\iTop\Service\Events\EventData; +use Combodo\iTop\Service\Events\EventException; use Combodo\iTop\Service\Events\EventService; +use Combodo\iTop\Service\Events\EventServiceLog; use Combodo\iTop\Service\TemporaryObjects\TemporaryObjectManager; /** @@ -203,6 +205,8 @@ abstract class DBObject implements iDisplay const MAX_UPDATE_LOOP_COUNT = 10; + private $aEventListeners = []; + /** * DBObject constructor. * @@ -255,6 +259,10 @@ abstract class DBObject implements iDisplay $this->RegisterEventListeners(); } + /** + * @see RegisterCRUDListener + * @see EventService::RegisterListener() + */ protected function RegisterEventListeners() { } @@ -6165,6 +6173,51 @@ abstract class DBObject implements iDisplay return OPT_ATT_NORMAL; } + public final function GetListeners(): array + { + $aListeners = []; + foreach ($this->aEventListeners as $aEventListener) { + $aListeners = array_merge($aListeners, $aEventListener); + } + return $aListeners; + } + + /** + * Register a callback for a specific event. The method to call will be saved in the object instance itself whereas calling {@see EventService::RegisterListener()} would + * save a callable (thus the method name AND the whole DBObject instance) + * + * @param string $sEvent corresponding event + * @param string $callback The callback method to call + * @param float $fPriority optional priority for callback order + * @param string $sModuleId + * + * @see EventService::RegisterListener() + * + * @since 3.1.0-3 3.1.1 3.2.0 N°6716 + */ + final protected function RegisterCRUDListener(string $sEvent, string $callback, float $fPriority = 0.0, string $sModuleId = '') + { + $aEventCallbacks = $this->aEventListeners[$sEvent] ?? []; + + $aEventCallbacks[] = array( + 'event' => $sEvent, + 'callback' => $callback, + 'priority' => $fPriority, + 'module' => $sModuleId, + ); + usort($aEventCallbacks, function ($a, $b) { + $fPriorityA = $a['priority']; + $fPriorityB = $b['priority']; + if ($fPriorityA == $fPriorityB) { + return 0; + } + + return ($fPriorityA < $fPriorityB) ? -1 : 1; + }); + + $this->aEventListeners[$sEvent] = $aEventCallbacks; + } + /** * @param string $sEvent * @param array $aEventData @@ -6176,15 +6229,53 @@ abstract class DBObject implements iDisplay */ public function FireEvent(string $sEvent, array $aEventData = array()): void { - if (EventService::IsEventRegistered($sEvent)) { - $aEventData['debug_info'] = 'from: '.get_class($this).':'.$this->GetKey(); - $aEventData['object'] = $this; - $aEventSources = [$this->m_sObjectUniqId]; - foreach (MetaModel::EnumParentClasses(get_class($this), ENUM_PARENT_CLASSES_ALL, false) as $sClass) { - $aEventSources[] = $sClass; + $aEventData['debug_info'] = 'from: '.get_class($this).':'.$this->GetKey(); + $aEventData['object'] = $this; + + // Call local listeners first + $aEventCallbacks = $this->aEventListeners[$sEvent] ?? []; + $oFirstException = null; + $sFirstExceptionMessage = ''; + foreach ($aEventCallbacks as $aEventCallback) { + $oKPI = new ExecutionKPI(); + $sCallback = $aEventCallback['callback']; + if (!method_exists($this, $sCallback)) { + EventServiceLog::Error("Callback '".get_class($this).":$sCallback' does not exist"); + continue; + } + EventServiceLog::Debug("Fire event '$sEvent' calling '".get_class($this).":$sCallback'"); + try { + call_user_func([$this, $sCallback], new EventData($sEvent, null, $aEventData)); + } + catch (EventException $e) { + EventServiceLog::Error("Event '$sEvent' for '$sCallback'} failed with blocking error: ".$e->getMessage()); + throw $e; + } + catch (Exception $e) { + $sMessage = "Event '$sEvent' for '$sCallback'} failed with non-blocking error: ".$e->getMessage(); + EventServiceLog::Error($sMessage); + if (is_null($oFirstException)) { + $sFirstExceptionMessage = $sMessage; + $oFirstException = $e; + } + } + finally { + $oKPI->ComputeStats('FireEvent', $sEvent); } - EventService::FireEvent(new EventData($sEvent, $aEventSources, $aEventData)); } + if (!is_null($oFirstException)) { + throw new Exception($sFirstExceptionMessage, $oFirstException->getCode(), $oFirstException); + } + + // Call global event listeners + if (!EventService::IsEventRegistered($sEvent)) { + return; + } + $aEventSources = []; + foreach (MetaModel::EnumParentClasses(get_class($this), ENUM_PARENT_CLASSES_ALL, false) as $sClass) { + $aEventSources[] = $sClass; + } + EventService::FireEvent(new EventData($sEvent, $aEventSources, $aEventData)); } ////////////////// diff --git a/pages/schema.php b/pages/schema.php index 170553188..a12fe92ee 100644 --- a/pages/schema.php +++ b/pages/schema.php @@ -290,7 +290,6 @@ function DisplayEvents(WebPage $oPage, $sClass) foreach (MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL) as $sChildClass) { if (!MetaModel::IsAbstract($sChildClass)) { $oObject = MetaModel::NewObject($sChildClass); - $aSources[] = $oObject->GetObjectUniqId(); break; } } @@ -299,7 +298,6 @@ function DisplayEvents(WebPage $oPage, $sClass) } } else { $oObject = MetaModel::NewObject($sClass); - $aSources[] = $oObject->GetObjectUniqId(); foreach (MetaModel::EnumParentClasses($sClass, ENUM_PARENT_CLASSES_ALL, false) as $sParentClass) { $aSources[] = $sParentClass; } @@ -320,12 +318,19 @@ function DisplayEvents(WebPage $oPage, $sClass) }); $aColumns = [ 'event' => ['label' => Dict::S('UI:Schema:Events:Event')], - 'listener' => ['label' => Dict::S('UI:Schema:Events:Listener')], + 'callback' => ['label' => Dict::S('UI:Schema:Events:Listener')], 'priority' => ['label' => Dict::S('UI:Schema:Events:Rank')], 'module' => ['label' => Dict::S('UI:Schema:Events:Module')], ]; + // Get the object listeners first $aRows = []; $oReflectionClass = new ReflectionClass($sClass); + if ($oReflectionClass->isInstantiable()) { + /** @var DBObject $oClass */ + $oClass = new $sClass(); + $aRows = $oClass->GetListeners(); + } + foreach ($aListeners as $aListener) { if (is_object($aListener['callback'][0])) { $sListenerClass = $sClass; @@ -343,7 +348,7 @@ function DisplayEvents(WebPage $oPage, $sClass) } $aRows[] = [ 'event' => $aListener['event'], - 'listener' => $sListener, + 'callback' => $sListener, 'priority' => $aListener['priority'], 'module' => $aListener['module'], ]; diff --git a/setup/compiler.class.inc.php b/setup/compiler.class.inc.php index e25d851b8..1959ea186 100644 --- a/setup/compiler.class.inc.php +++ b/setup/compiler.class.inc.php @@ -1449,17 +1449,12 @@ EOF } $sMethods .= "\n $sCallbackFct\n\n"; } - if (strpos($sCallback, '::') === false) { - $sEventListener = '[$this, \''.$sCallback.'\']'; - } else { - $sEventListener = "'$sCallback'"; - } $sListenerRank = (float)($oListener->GetChildText('rank', '0')); $sEvents .= <<m_sObjectUniqId, [], null, $sListenerRank, '$sModuleRelativeDir'); + \$this->RegisterCRUDListener("$sEventName", '$sCallback', $sListenerRank, '$sModuleRelativeDir'); PHP; } } diff --git a/sources/Service/Events/EventService.php b/sources/Service/Events/EventService.php index 072b77473..4611787d5 100644 --- a/sources/Service/Events/EventService.php +++ b/sources/Service/Events/EventService.php @@ -10,6 +10,7 @@ use Closure; use Combodo\iTop\Service\Events\Description\EventDescription; use ContextTag; use CoreException; +use DBObject; use Exception; use ExecutionKPI; use ReflectionClass; @@ -53,6 +54,12 @@ final class EventService /** * Register a callback for a specific event * + * **Warning** : be ultra careful on memory footprint ! each callback will be saved in {@see aEventListeners}, and a callback is + * made of the whole object instance and the method name ({@link https://www.php.net/manual/en/language.types.callable.php}). + * For example to register on DBObject instances, you should better use {@see DBObject::RegisterCRUDListener()} + * + * @uses aEventListeners + * * @api * @param string $sEvent corresponding event * @param callable $callback The callback to call @@ -61,8 +68,12 @@ final class EventService * @param array|string|null $context context filter * @param float $fPriority optional priority for callback order * - * @return string Id of the registration + * @return string registration identifier * + * @see DBObject::RegisterCRUDListener() to register in DBObject instances instead, to reduce memory footprint (callback saving) + * + * @since 3.1.0 method creation + * @since 3.1.0-3 3.1.1 3.2.0 N°6716 PHPDoc change to warn on memory footprint, and {@see DBObject::RegisterCRUDListener()} alternative */ public static function RegisterListener(string $sEvent, callable $callback, $sEventSource = null, array $aCallbackData = [], $context = null, float $fPriority = 0.0, $sModuleId = ''): string { diff --git a/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php b/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php index 95dc968db..aea1cbb52 100644 --- a/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php +++ b/tests/php-unit-tests/unitary-tests/core/CRUDEventTest.php @@ -12,6 +12,7 @@ use Combodo\iTop\Test\UnitTest\ItopDataTestCase; use ContactType; use CoreException; use DBObject; +use DBObject\MockDBObjectWithCRUDEventListener; use DBObjectSet; use DBSearch; use lnkPersonToTeam; @@ -521,6 +522,24 @@ class CRUDEventTest extends ItopDataTestCase $this->assertEquals(2, self::$aEventCalls[EVENT_DB_LINKS_CHANGED]); } + + // Tests with MockDBObject + public function testFireCRUDEvent() + { + $this->RequireOnceUnitTestFile('DBObject/MockDBObjectWithCRUDEventListener.php'); + + // For Metamodel list of classes + MockDBObjectWithCRUDEventListener::Init(); + $oDBObject = new MockDBObjectWithCRUDEventListener(); + $oDBObject2 = new MockDBObjectWithCRUDEventListener(); + + $oDBObject->FireEvent(MockDBObjectWithCRUDEventListener::TEST_EVENT); + + $this->assertNotNull($oDBObject->oEventDataReceived); + $this->assertNull($oDBObject2->oEventDataReceived); + + //echo($oDBObject->oEventDataReceived->Get('debug_info')); + } } /** diff --git a/tests/php-unit-tests/unitary-tests/core/DBObject/MockDBObjectWithCRUDEventListener.php b/tests/php-unit-tests/unitary-tests/core/DBObject/MockDBObjectWithCRUDEventListener.php new file mode 100644 index 000000000..f81ce637c --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/core/DBObject/MockDBObjectWithCRUDEventListener.php @@ -0,0 +1,44 @@ + 'bizmodel, searchable', + 'key_type' => 'autoincrement', + 'name_attcode' => '', + 'state_attcode' => '', + 'reconc_keys' => [], + 'db_table' => 'priv_unit_tests_mock', + 'db_key_field' => 'id', + 'db_finalclass_field' => '', + 'display_template' => '', + 'indexes' => [], + ); + MetaModel::Init_Params($aParams); + } + + protected function RegisterEventListeners() + { + $this->RegisterCRUDListener(self::TEST_EVENT, 'TestEventCallback', 0, 'unit-test'); + } + + public function TestEventCallback(EventData $oEventData) + { + $this->oEventDataReceived = $oEventData; + } +} \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/core/DBObjectTest.php b/tests/php-unit-tests/unitary-tests/core/DBObjectTest.php index 23e9bd592..c8504ee7f 100644 --- a/tests/php-unit-tests/unitary-tests/core/DBObjectTest.php +++ b/tests/php-unit-tests/unitary-tests/core/DBObjectTest.php @@ -897,4 +897,40 @@ class DBObjectTest extends ItopDataTestCase return $oPerson; } + /** + * @since 3.1.0-3 3.1.1 3.2.0 N°6716 test creation + */ + public function testConstructorMemoryFootprint():void + { + $idx = 0; + $fStart = microtime(true); + $fStartLoop = $fStart; + $iInitialPeak = 0; + $iMaxAllowedMemoryIncrease = 1 * 1024 * 1024; + + for ($i = 0; $i < 5000; $i++) { + /** @noinspection PhpUnusedLocalVariableInspection We intentionally use a reference that will disappear on each loop */ + $oPerson = new \Person(); + if (0 == ($idx % 100)) { + $fDuration = microtime(true) - $fStartLoop; + $iMemoryPeakUsage = memory_get_peak_usage(); + if ($iInitialPeak === 0) { + $iInitialPeak = $iMemoryPeakUsage; + $sInitialPeak = \utils::BytesToFriendlyFormat($iInitialPeak, 4); + } + + $sCurrPeak = \utils::BytesToFriendlyFormat($iMemoryPeakUsage, 4); + echo "$idx ".sprintf('%.1f ms', $fDuration * 1000)." - Peak Memory Usage: $sCurrPeak\n"; + + $this->assertTrue(($iMemoryPeakUsage - $iInitialPeak) <= $iMaxAllowedMemoryIncrease , "Peak memory changed from $sInitialPeak to $sCurrPeak after $i loops"); + + $fStartLoop = microtime(true); + } + $idx++; + } + + $fTotalDuration = microtime(true) - $fStart; + echo 'Total duration: '.sprintf('%.3f s', $fTotalDuration)."\n\n"; + } + } From bdf0b4daa97c8f59fb302872537f2e32d27b8bdd Mon Sep 17 00:00:00 2001 From: Pierre Goiffon Date: Wed, 2 Aug 2023 10:35:30 +0200 Subject: [PATCH 11/11] =?UTF-8?q?N=C2=B06562=20Fix=20DOMNode->textContent?= =?UTF-8?q?=20write=20This=20attribute=20is=20read=20only=20Causes=20layou?= =?UTF-8?q?t=20issues=20on=20PHP=208.1.21=20and=208.2.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 734a78834055a559aa0b02e30ad9abe75ed95556) (cherry picked from commit 7aa478d6fffc5e164a200abb36a1ad3c7bc113ff) --- setup/itopdesignformat.class.inc.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/setup/itopdesignformat.class.inc.php b/setup/itopdesignformat.class.inc.php index 4894385df..0289ea577 100644 --- a/setup/itopdesignformat.class.inc.php +++ b/setup/itopdesignformat.class.inc.php @@ -892,7 +892,10 @@ class iTopDesignFormat $oNodeList = $oXPath->query("/itop_design/classes//class/fields/field/values/value"); foreach ($oNodeList as $oNode) { $sCode = $oNode->textContent; - $oNode->textContent = ''; + // N°6562 textContent is readonly, see https://www.php.net/manual/en/class.domnode.php#95545 + // $oNode->textContent = ''; + // N°6562 to update text node content we must use the node methods ! + $oNode->removeChild($oNode->firstChild); $oCodeNode = $oNode->ownerDocument->createElement("code", $sCode); $oNode->appendChild($oCodeNode); } @@ -982,7 +985,12 @@ class iTopDesignFormat if ($oStyleNode) { $this->DeleteNode($oStyleNode); } - $oNode->textContent = $sCode; + + // N°6562 textContent is readonly, see https://www.php.net/manual/en/class.domnode.php#95545 + // $oNode->textContent = $sCode; + // N°6562 to update text node content we must use the node methods ! + $oTextContentNode = new DOMText($sCode); + $oNode->appendChild($oTextContentNode); } } // - Style