diff --git a/CRM/Core/BAO/TranslateGetWrapper.php b/CRM/Core/BAO/TranslateGetWrapper.php new file mode 100644 index 000000000000..67214b242605 --- /dev/null +++ b/CRM/Core/BAO/TranslateGetWrapper.php @@ -0,0 +1,45 @@ +fields = $translated['fields']; + $this->translatedLanguage = $translated['language']; + } + + /** + * @inheritdoc + */ + public function fromApiInput($apiRequest) { + return $apiRequest; + } + + /** + * @inheritdoc + */ + public function toApiOutput($apiRequest, $result) { + foreach ($result as &$value) { + if (!isset($value['id'], $this->fields[$value['id']])) { + continue; + } + $toSet = array_intersect_key($this->fields[$value['id']], $value); + $value = array_merge($value, $toSet); + $value['actual_language'] = $this->translatedLanguage; + } + return $result; + } + +} diff --git a/CRM/Core/BAO/Translation.php b/CRM/Core/BAO/Translation.php index c7ab686cce8b..3d631015192f 100644 --- a/CRM/Core/BAO/Translation.php +++ b/CRM/Core/BAO/Translation.php @@ -9,6 +9,9 @@ +--------------------------------------------------------------------+ */ +use Civi\Api4\Generic\AbstractAction; +use Civi\Api4\Translation; + /** * * @package CRM @@ -144,4 +147,122 @@ public static function self_civi_api4_validate(\Civi\Api4\Event\ValidateValuesEv } } + /** + * Callback for hook_civicrm_post(). + * + * Flush out cached values. + * + * @param \Civi\Core\Event\PostEvent $event + */ + public static function self_hook_civicrm_post(\Civi\Core\Event\PostEvent $event): void { + unset(Civi::$statics[__CLASS__]); + } + + /** + * Implements hook_civicrm_apiWrappers(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_apiWrappers/ + * + * @see \CRM_Utils_Hook::apiWrappers() + * @throws \CRM_Core_Exception + */ + public static function hook_civicrm_apiWrappers(&$wrappers, $apiRequest): void { + // Only implement for apiv4 & not in a circular way. + if ($apiRequest['entity'] === 'Translation' + || $apiRequest['entity'] === 'Entity' + || !$apiRequest instanceof AbstractAction + // Only intervene in 'get'. Code handling save type actions left out of scope. + || $apiRequest['action'] !== 'get' + ) { + return; + } + + $apiLanguage = $apiRequest->getLanguage(); + if (!$apiLanguage || $apiRequest->getLanguage() === Civi::settings()->get('lcMessages')) { + return; + } + + if ($apiRequest['action'] === 'get') { + if (!isset(\Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$apiRequest->getPreferredLanguage()])) { + $translated = self::getTranslatedFieldsForRequest($apiRequest); + // @todo - once https://github.com/civicrm/civicrm-core/pull/24063 is merged + // this could set any defined translation fields that don't have a translation + // for one or more fields in the set to '' - ie 'if any are defined for + // an entity/language then all must be' - it seems like being strict on this + // now will make it easier later.... + if (!empty($translated['fields']['msg_html']) && !isset($translated['fields']['msg_text'])) { + $translated['fields']['msg_text'] = ''; + } + foreach ($translated['fields'] as $field) { + \Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$apiRequest->getLanguage()]['fields'][$field['entity_id']][$field['entity_field']] = $field['string']; + \Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$apiRequest->getLanguage()]['language'] = $translated['language']; + } + } + if (!empty(\Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$apiRequest->getLanguage()])) { + $wrappers[] = new CRM_Core_BAO_TranslateGetWrapper(\Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$apiRequest->getLanguage()]); + } + + } + } + + /** + * @param \Civi\Api4\Generic\AbstractAction $apiRequest + * @return array translated fields. + * + * @throws \CRM_Core_Exception + */ + protected static function getTranslatedFieldsForRequest(AbstractAction $apiRequest): array { + $translations = Translation::get() + ->addWhere('entity_table', '=', CRM_Core_DAO_AllCoreTables::getTableForEntityName($apiRequest['entity'])) + ->setCheckPermissions(FALSE) + ->setSelect(['entity_field', 'entity_id', 'string', 'language']); + if ((substr($apiRequest->getLanguage(), '-3', '3') !== '_NO')) { + // Generally we want to check for any translations of the base language + // and prefer, for example, French French over US English for French Canadians. + // Sites that genuinely want to cater to both will add translations for both + // and we work through preferences below. + $translations->addWhere('language', 'LIKE', substr($apiRequest->getLanguage(), 0, 2) . '%'); + } + else { + // And here we have ... the Norwegians. They have three main variants which + // share the same country suffix but not language prefix. As with other languages + // any Norwegian is better than no Norwegian and sites that care will do multiple + $translations->addWhere('language', 'LIKE', '%_NO'); + } + $fields = $translations->execute(); + $languages = []; + foreach ($fields as $index => $field) { + $languages[$field['language']][$index] = $field; + } + if (isset($languages[$apiRequest->getLanguage()])) { + return ['fields' => $languages[$apiRequest->getLanguage()], 'language' => $apiRequest->getLanguage()]; + } + if (count($languages) === 1) { + return ['fields' => reset($languages), 'language' => key($languages)]; + } + if (count($languages) > 1) { + // In this situation we have multiple language options but no exact match. + // This might be, for example, a case where we have, for example, a US English and + // a British English, but no Kiwi English. In that case the best is arguable + // but I think we all agree that we want to avoid Aussie English here. + $defaultLanguages = [ + 'de' => 'de_DE', + 'en' => 'en_US', + 'fr' => 'fr_FR', + 'es' => 'es_ES', + 'nl' => 'nl_NL', + 'pt' => 'pt_PT', + 'zh' => 'zh_TW', + ]; + $defaultLanguage = $defaultLanguages[substr($apiRequest->getLanguage()(), 0, 2)] ?? NULL; + if (isset($languages[$defaultLanguage])) { + return ['fields' => $languages[$defaultLanguage], 'language' => $defaultLanguage]; + } + // We have no way to determine which is best from the available variants. + // If the site wants more control they can translate to the variants. + return ['fields' => reset($languages), 'language' => key($languages)]; + } + return []; + } + } diff --git a/Civi/Api4/Generic/AbstractAction.php b/Civi/Api4/Generic/AbstractAction.php index 9347394ada9d..eee6b91e430d 100644 --- a/Civi/Api4/Generic/AbstractAction.php +++ b/Civi/Api4/Generic/AbstractAction.php @@ -32,6 +32,8 @@ * @method bool getDebug() * @method $this setChain(array $chain) * @method array getChain() + * @method $this setLanguage(string|null $language) + * @method string|null getLanguage() */ abstract class AbstractAction implements \ArrayAccess { @@ -44,6 +46,18 @@ abstract class AbstractAction implements \ArrayAccess { */ protected $version = 4; + /** + * Preferred Language (optional). + * + * If set then listeners such as the Translation subsystem may alter + * the output. + * + * @optionsCallback getPreferredLanguageOptions + * + * @var string + */ + protected $language; + /** * Additional api requests - will be called once per result. * @@ -564,4 +578,15 @@ protected function addCallbackToDebugOutput($callable) { } } + /** + * Get available preferred languages. + * + * @return array + */ + protected function getPreferredLanguageOptions(): array { + $languages = \CRM_Contact_BAO_Contact::buildOptions('preferred_language'); + ksort($languages); + return array_keys($languages); + } + } diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index 0f44b3a9aaa4..165e4b53142b 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -3,6 +3,7 @@ use Civi\Api4\Address; use Civi\Api4\Contact; use Civi\Api4\MessageTemplate; +use Civi\Api4\Translation; use Civi\Token\TokenProcessor; /** @@ -200,6 +201,40 @@ public function testCaseActivityCopyTemplate():void { $this->assertStringContainsString('Case ID : 1234', $message); } + /** + * Test that translated strings are rendered for templates where they exist. + * + * @throws \API_Exception|\CRM_Core_Exception + */ + public function testGetTranslatedTemplate(): void { + $this->individualCreate(['preferred_language' => 'fr_FR']); + $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); + $this->addTranslation(); + + $messageTemplate = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) + ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') + ->setLanguage('fr_FR') + ->execute()->indexBy('workflow_name'); + + $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); + + $this->assertStringContainsString('{ts}Contribution Receipt{/ts}', $messageTemplate['contribution_offline_receipt']['msg_subject']); + $this->assertStringContainsString('Below you will find a receipt', $messageTemplate['contribution_offline_receipt']['msg_html']); + $this->assertArrayNotHasKey('actual_language', $messageTemplate['contribution_offline_receipt']); + + $messageTemplate = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) + ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') + ->setLanguage('fr_CA') + ->execute()->indexBy('workflow_name'); + + $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); + + } + /** * Test rendering of domain tokens. * @@ -919,4 +954,38 @@ protected function getExpectedContactOutputNewStyle($id, array $tokenData, strin return $expected; } + /** + * @return mixed + * @throws \API_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + private function addTranslation() { + $messageTemplateID = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', '=', 'contribution_online_receipt') + ->addSelect('id') + ->execute()->first()['id']; + + Translation::save()->setRecords([ + ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], + ['entity_field' => 'msg_html', 'string' => 'Voila!'], + ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], + ])->setDefaults([ + 'entity_table' => 'civicrm_msg_template', + 'entity_id' => $messageTemplateID, + 'status_id:name' => 'active', + 'language' => 'fr_FR', + ])->execute(); + return $messageTemplateID; + } + + /** + * @param $contribution_online_receipt + */ + private function assertFrenchTranslationRetrieved($contribution_online_receipt): void { + $this->assertEquals('Bonjour', $contribution_online_receipt['msg_subject']); + $this->assertEquals('Voila!', $contribution_online_receipt['msg_html']); + $this->assertEquals('fr_FR', $contribution_online_receipt['actual_language']); + } + }