From 9c332a906d411063e318519e6cf8ad5b50ad8d84 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 10 Aug 2021 16:18:50 -0700 Subject: [PATCH 1/5] CRM_Utils_AutoClean - Add variant of `swap()` tuned for setting locale This can currently be done with `swap()`, but we may be doing this several times, and `swapLocale()` is a little more pithy and a little more microoptimal. --- CRM/Utils/AutoClean.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CRM/Utils/AutoClean.php b/CRM/Utils/AutoClean.php index 1be38b0ece20..514283f43467 100644 --- a/CRM/Utils/AutoClean.php +++ b/CRM/Utils/AutoClean.php @@ -48,6 +48,26 @@ public static function with($callback) { return $ac; } + /** + * Temporarily set the active locale. Cleanup locale when the autoclean handle disappears. + * + * @param string|null $newLocale + * Ex: 'fr_CA' + * @return \CRM_Utils_AutoClean|null + */ + public static function swapLocale(?string $newLocale) { + $oldLocale = $GLOBALS['tsLocale'] ?? NULL; + if ($oldLocale === $newLocale) { + return NULL; + } + + $i18n = \CRM_Core_I18n::singleton(); + $i18n->setLocale($newLocale); + return static::with(function() use ($i18n, $oldLocale) { + $i18n->setLocale($oldLocale); + }); + } + /** * Temporarily swap values using callback functions, and cleanup * when the current context shuts down. From 389aeb7042e4d7e5771f88686d7e69889aaa4ea1 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 9 Aug 2021 20:49:30 -0700 Subject: [PATCH 2/5] (REF) ActionSchedule - Convert setCommunicationLanguage(...) to setLocale(pickLocale(...)) --- CRM/Core/BAO/ActionSchedule.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CRM/Core/BAO/ActionSchedule.php b/CRM/Core/BAO/ActionSchedule.php index e804f3bc7657..abcd747f4448 100644 --- a/CRM/Core/BAO/ActionSchedule.php +++ b/CRM/Core/BAO/ActionSchedule.php @@ -271,7 +271,7 @@ public static function sendMailings($mappingID, $now) { // switch language if necessary if ($multilingual) { $preferred_language = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $dao->contactID, 'preferred_language'); - CRM_Core_BAO_ActionSchedule::setCommunicationLanguage($actionSchedule->communication_language, $preferred_language); + CRM_Core_I18n::singleton()->setLocale(CRM_Core_BAO_ActionSchedule::pickLocale($actionSchedule->communication_language, $preferred_language)); } $errors = []; @@ -401,10 +401,11 @@ public static function getRecipientListing($mappingID, $recipientType) { } /** - * @param $communication_language - * @param $preferred_language + * @param string|null $communication_language + * @param string|null $preferred_language + * @return string */ - public static function setCommunicationLanguage($communication_language, $preferred_language) { + public static function pickLocale($communication_language, $preferred_language) { $currentLocale = CRM_Core_I18n::getLocale(); $language = $currentLocale; @@ -425,8 +426,7 @@ public static function setCommunicationLanguage($communication_language, $prefer } // change the language - $i18n = CRM_Core_I18n::singleton(); - $i18n->setLocale($language); + return $language; } /** From 4b7b899badc1312040384e15ffdf23bab7425814 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 9 Aug 2021 20:55:44 -0700 Subject: [PATCH 3/5] TokenProcessor - If there is a `locale`, then use it There are two likely ways in which a tokenized email winds up with localized strings - either the Smarty `{ts}...{/ts}` tags define it, or a custom/hook-based tag uses `ts()`. This ensures that the locale is set in both cases. It's hypothetically possible that some other `civi.token.eval` listeners need to use the `row->context['locale']`. However, I grepped universe and couldn't find anything that would be affected. (There were two contrib listeners for `civi.token.eval` and neither seemed to be affected.) --- Civi/Token/TokenCompatSubscriber.php | 4 ++ Civi/Token/TokenProcessor.php | 3 + .../phpunit/Civi/Token/TokenProcessorTest.php | 69 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/Civi/Token/TokenCompatSubscriber.php b/Civi/Token/TokenCompatSubscriber.php index d957c3cb9d94..c2d6c5b6e123 100644 --- a/Civi/Token/TokenCompatSubscriber.php +++ b/Civi/Token/TokenCompatSubscriber.php @@ -51,6 +51,10 @@ public function onEvaluate(TokenValueEvent $e) { if (empty($row->context['contactId'])) { continue; } + + unset($swapLocale); + $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']); + /** @var int $contactId */ $contactId = $row->context['contactId']; if (empty($row->context['contact'])) { diff --git a/Civi/Token/TokenProcessor.php b/Civi/Token/TokenProcessor.php index e8ee1b8f73f5..7ad2ec7b5842 100644 --- a/Civi/Token/TokenProcessor.php +++ b/Civi/Token/TokenProcessor.php @@ -55,6 +55,7 @@ class TokenProcessor { * automatically from contactId.) * - actionSchedule: DAO, the rule which triggered the mailing * [for CRM_Core_BAO_ActionScheduler]. + * - locale: string, the name of a locale (eg 'fr_CA') to use for {ts} strings in the view. * - schema: array, a list of fields that will be provided for each row. * This is automatically populated with any general context * keys, but you may need to add extra keys for token-row data. @@ -351,6 +352,8 @@ public function render($name, $row) { $row = $this->getRow($row); } + $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']); + $message = $this->getMessage($name); $row->fill($message['format']); $useSmarty = !empty($row->context['smarty']); diff --git a/tests/phpunit/Civi/Token/TokenProcessorTest.php b/tests/phpunit/Civi/Token/TokenProcessorTest.php index cd8dba1cf23b..ce6037d071f6 100644 --- a/tests/phpunit/Civi/Token/TokenProcessorTest.php +++ b/tests/phpunit/Civi/Token/TokenProcessorTest.php @@ -139,6 +139,75 @@ public function testRowTokens() { } } + public function testRenderLocalizedSmarty() { + $this->dispatcher->addSubscriber(new TokenCompatSubscriber()); + $p = new TokenProcessor($this->dispatcher, [ + 'controller' => __CLASS__, + 'smarty' => TRUE, + ]); + $p->addMessage('text', '{ts}Yes{/ts} {ts}No{/ts}', 'text/plain'); + $p->addRow([]); + $p->addRow(['locale' => 'fr_FR']); + $p->addRow(['locale' => 'es_MX']); + + $expectText = [ + 'Yes No', + 'Oui Non', + 'Sí No', + ]; + + $rowCount = 0; + foreach ($p->evaluate()->getRows() as $key => $row) { + /** @var TokenRow */ + $this->assertTrue($row instanceof TokenRow); + $this->assertEquals($expectText[$key], $row->render('text')); + $rowCount++; + } + $this->assertEquals(3, $rowCount); + } + + public function testRenderLocalizedHookToken() { + $cid = $this->individualCreate(); + + $this->dispatcher->addSubscriber(new TokenCompatSubscriber()); + \Civi::dispatcher()->addListener('hook_civicrm_tokens', function($e) { + $e->tokens['trans'] = [ + 'trans.affirm' => ts('Translated affirmation'), + ]; + }); + \Civi::dispatcher()->addListener('hook_civicrm_tokenValues', function($e) { + if (in_array('affirm', $e->tokens['trans'])) { + foreach ($e->contactIDs as $cid) { + $e->details[$cid]['trans.affirm'] = ts('Yes'); + } + } + }); + + $p = new TokenProcessor($this->dispatcher, [ + 'controller' => __CLASS__, + 'smarty' => FALSE, + ]); + $p->addMessage('text', '!!{trans.affirm}!!', 'text/plain'); + $p->addRow(['contactId' => $cid]); + $p->addRow(['contactId' => $cid, 'locale' => 'fr_FR']); + $p->addRow(['contactId' => $cid, 'locale' => 'es_MX']); + + $expectText = [ + '!!Yes!!', + '!!Oui!!', + '!!Sí!!', + ]; + + $rowCount = 0; + foreach ($p->evaluate()->getRows() as $key => $row) { + /** @var TokenRow */ + $this->assertTrue($row instanceof TokenRow); + $this->assertEquals($expectText[$key], $row->render('text')); + $rowCount++; + } + $this->assertEquals(3, $rowCount); + } + public function testGetMessageTokens() { $p = new TokenProcessor($this->dispatcher, [ 'controller' => __CLASS__, From f14b9b1bb012d93609404e63f3c61c1be56ec671 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 9 Aug 2021 21:13:57 -0700 Subject: [PATCH 4/5] ActionSchedule - Convert from global `setLocale()` to `$context['locale']` Before: Runs `setLocale()` and then executes the entire pipeline for `TokenProcessor` After: Leaves the global locale alone. Instead, rely on `TokenProcessor` to switch locale as it visits each recipient. --- CRM/Core/BAO/ActionSchedule.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/CRM/Core/BAO/ActionSchedule.php b/CRM/Core/BAO/ActionSchedule.php index abcd747f4448..00ac176a983e 100644 --- a/CRM/Core/BAO/ActionSchedule.php +++ b/CRM/Core/BAO/ActionSchedule.php @@ -268,18 +268,19 @@ public static function sendMailings($mappingID, $now) { $multilingual = CRM_Core_I18n::isMultilingual(); while ($dao->fetch()) { - // switch language if necessary - if ($multilingual) { - $preferred_language = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $dao->contactID, 'preferred_language'); - CRM_Core_I18n::singleton()->setLocale(CRM_Core_BAO_ActionSchedule::pickLocale($actionSchedule->communication_language, $preferred_language)); - } - $errors = []; try { $tokenProcessor = self::createTokenProcessor($actionSchedule, $mapping); - $tokenProcessor->addRow() + $row = $tokenProcessor->addRow() ->context('contactId', $dao->contactID) ->context('actionSearchResult', (object) $dao->toArray()); + + // switch language if necessary + if ($multilingual) { + $preferred_language = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $dao->contactID, 'preferred_language'); + $row->context('locale', CRM_Core_BAO_ActionSchedule::pickLocale($actionSchedule->communication_language, $preferred_language)); + } + foreach ($tokenProcessor->evaluate()->getRows() as $tokenRow) { if ($actionSchedule->mode === 'SMS' || $actionSchedule->mode === 'User_Preference') { CRM_Utils_Array::extend($errors, self::sendReminderSms($tokenRow, $actionSchedule, $dao->contactID)); From 7c0d73354402ffcdd5f70b9868b084567c7dbbd8 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 10 Aug 2021 16:45:21 -0700 Subject: [PATCH 5/5] ActionSchedule - Set locale during mail hooks / delivery / etc From a core POV, this should be unnecessary. And I'm not sure that it's a good idea for contrib to assume that the locale has been set to the email receipient. (It probably isn't done that way in some contexts.) Never-the-less, this should provide greater continuity with the pre-existing behavior. If anyone, say, uses `Hook::alterMail()` to append a translated footer, then it should continue to work in the same way as before. --- CRM/Core/BAO/ActionSchedule.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CRM/Core/BAO/ActionSchedule.php b/CRM/Core/BAO/ActionSchedule.php index 00ac176a983e..d77feae9331b 100644 --- a/CRM/Core/BAO/ActionSchedule.php +++ b/CRM/Core/BAO/ActionSchedule.php @@ -282,6 +282,9 @@ public static function sendMailings($mappingID, $now) { } foreach ($tokenProcessor->evaluate()->getRows() as $tokenRow) { + // It's possible, eg, that sendReminderEmail fires Hook::alterMailParams() and that some listener use ts(). + $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']); + if ($actionSchedule->mode === 'SMS' || $actionSchedule->mode === 'User_Preference') { CRM_Utils_Array::extend($errors, self::sendReminderSms($tokenRow, $actionSchedule, $dao->contactID)); } @@ -294,6 +297,8 @@ public static function sendMailings($mappingID, $now) { $caseID = empty($dao->case_id) ? NULL : $dao->case_id; CRM_Core_BAO_ActionSchedule::createMailingActivity($tokenRow, $mapping, $dao->contactID, $dao->entityID, $caseID); } + + unset($swapLocale); } } catch (\Civi\Token\TokenException $e) {