Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

APIv4 - Add generic setLanguage, to all APIs #24116

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
45 changes: 45 additions & 0 deletions CRM/Core/BAO/TranslateGetWrapper.php
@@ -0,0 +1,45 @@
<?php

/**
* Wrapper to swap in translated text.
*/
class CRM_Core_BAO_TranslateGetWrapper {

protected $fields;
protected $translatedLanguage;

/**
* CRM_Core_BAO_TranslateGetWrapper constructor.
*
* This wrapper replaces values with configured translated values, if any exist.
*
* @param array $translated
*/
public function __construct($translated) {
$this->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;
}

}
121 changes: 121 additions & 0 deletions CRM/Core/BAO/Translation.php
Expand Up @@ -9,6 +9,9 @@
+--------------------------------------------------------------------+
*/

use Civi\Api4\Generic\AbstractAction;
use Civi\Api4\Translation;

/**
*
* @package CRM
Expand Down Expand Up @@ -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 [];
}

}
25 changes: 25 additions & 0 deletions Civi/Api4/Generic/AbstractAction.php
Expand Up @@ -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 {

Expand All @@ -44,6 +46,18 @@ abstract class AbstractAction implements \ArrayAccess {
*/
protected $version = 4;

/**
* Language (optional).
*
* If set then listeners such as the Translation subsystem may alter
* the output.
*
* @var string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you grep for @option, then there are a couple examples like:

Civi/Api4/Generic/AbstractSaveAction.php:   * @optionsCallback getMatchFields
Civi/Api4/Generic/ExportAction.php:   * @options never,always,unmodified
Civi/Api4/Generic/ExportAction.php:   * @options never,always,unused
Civi/Api4/Action/WorkflowMessage/GetTemplateFields.php:   * @options metadata,example
Civi/Api4/Action/WorkflowMessage/Render.php:   * @options error,warning,info

So this could probably use an @optionsCallback?

*
* @optionsCallback getPreferredLanguageOptions
*/
protected $language;

/**
* Additional api requests - will be called once per result.
*
Expand Down Expand Up @@ -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);
}

}
69 changes: 69 additions & 0 deletions tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php
Expand Up @@ -3,6 +3,7 @@
use Civi\Api4\Address;
use Civi\Api4\Contact;
use Civi\Api4\MessageTemplate;
use Civi\Api4\Translation;
use Civi\Token\TokenProcessor;

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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']);
}

}