-
-
Notifications
You must be signed in to change notification settings - Fork 812
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
(dev/translation#78) When setting locale, track it as a `Civi\Core\Lo…
…cale` instance
- Loading branch information
Showing
2 changed files
with
322 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,305 @@ | ||
<?php | ||
/* | ||
+--------------------------------------------------------------------+ | ||
| Copyright CiviCRM LLC. All rights reserved. | | ||
| | | ||
| This work is published under the GNU AGPLv3 license with some | | ||
| permitted exceptions and without any warranty. For full license | | ||
| and copyright information, see https://civicrm.org/licensing | | ||
+--------------------------------------------------------------------+ | ||
*/ | ||
|
||
namespace Civi\Core; | ||
|
||
/** | ||
* Define a locale. | ||
* | ||
* ## FULL AND PARTIAL LOCALES | ||
* | ||
* Compare: | ||
* | ||
* // FULL LOCALE - All localization services support this locale. | ||
* $quebecois = new Locale([ | ||
* 'nominal' => 'fr_CA', | ||
* 'ts' => 'fr_CA', | ||
* 'db' => 'fr_CA', | ||
* 'moneyFormat' => 'fr_CA', | ||
* 'uf' => 'fr_CA', | ||
* ]); | ||
* $quebecois->apply(); | ||
* | ||
* // PARTIAL LOCALE - Some localization services are not available, but the locale is still used. | ||
* $chicano = new Locale([ | ||
* 'nominal' => 'es_US', | ||
* 'ts' => 'es_MX', | ||
* 'db' => NULL, | ||
* 'moneyFormat' => 'en_US', | ||
* 'uf' => 'es_US', | ||
* ]); | ||
* $chicano->apply(); | ||
* | ||
* The existence of partial-locales is (perhaps) unfortunate but (at large scale) inevitable. | ||
* The software comes with a list of 200 communication-locales (OptionValues), and admins may | ||
* register more. There are only ~50 locales supported by `ts()` and 1-3 locales in the DB | ||
* (for a typical business-entity). If you use any of these other locales, then some services | ||
* must raise errors (or fallback to an alternate locale). | ||
* | ||
* ## NEGOTIATION | ||
* | ||
* The process of _negotiation_ takes a requested locale and determines how to configure | ||
* the localization services. For example, suppose a caller requests `es_US` (which isn't fully supported): | ||
* | ||
* - You could activate an adjacent locale which has full support (like `es_MX` or `en_US`). | ||
* - You could activate `es_US` and mix elements from different locales (eg `ts()` uses `es_MX`; | ||
* workflow-messages use `es_US` or `es_MX`, as available). | ||
* | ||
* To negotiate an effective locale and apply it: | ||
* | ||
* Locale::negotiate('es_US')->apply(); | ||
* | ||
* At time of writing, the negotiation behavior is based on system-setting `partial_locales` | ||
* (which enables or disables support for partial locales). It may be useful to make this hookable. | ||
* | ||
* It is also possible to perform a re-negotiation. For example, suppose the user requests | ||
* locale `es_US`, and we're sending an automated email -- but we only have emails written for | ||
* three languages. | ||
* | ||
* $msgs = ['es_MX' => 'Buenos dias', 'en_US' => 'Good day', 'fr_CA' => 'Bon jour']; | ||
* $locale = Locale::negotiate('es_US') | ||
* ->renegotiate(array_keys($msgs)) | ||
* ->apply(); | ||
* $msg = $msgs[$locale->nominal]; | ||
* | ||
* In a world where you only allow fully supported locales, there would be no need for | ||
* re-negotiation. However, if you have partially supported locales (with different mix of | ||
* resources in each), then you need some defined behavior for unsupported edges | ||
* (either raising an error or using a fallback). | ||
*/ | ||
class Locale { | ||
|
||
/** | ||
* The official/visible name of the current locale. | ||
* | ||
* This can be any active locale that appears in communication preferences | ||
* (eg `civicrm_contact.preferred_language`; ie option-group `languages`). | ||
* | ||
* @var string | ||
* @readonly | ||
*/ | ||
public $nominal; | ||
|
||
/** | ||
* Locale used for `ts()` and `l10n/**.mo` lookups. | ||
* | ||
* @var string | ||
* @readonly | ||
* @internal | ||
*/ | ||
public $ts; | ||
|
||
/** | ||
* Locale used for multilingual MySQL schema. | ||
* | ||
* Only defined on systems where multilingual is configured. Otherwise, null. | ||
* | ||
* @var string|null | ||
* @readonly | ||
* @internal | ||
*/ | ||
public $db; | ||
|
||
/** | ||
* Locale used for `Civi::format()` operations (dates and currencies). | ||
* | ||
* @var string | ||
* @readonly | ||
* @internal | ||
*/ | ||
public $moneyFormat; | ||
|
||
/** | ||
* Locale used by CMS. | ||
* | ||
* @var string | ||
* @readonly | ||
* @internal | ||
*/ | ||
public $uf; | ||
|
||
/** | ||
* Lookup details about the desired locale. | ||
* | ||
* @param string|null $locale | ||
* The name of a locale that one wishes to use. | ||
* The name may be NULL to use the current/active locale. | ||
* @return \Civi\Core\Locale | ||
*/ | ||
public static function resolve(?string $locale): Locale { | ||
return $locale === NULL ? static::detect() : static::negotiate($locale); | ||
} | ||
|
||
/** | ||
* Determine the current locale based on global properties. | ||
* | ||
* @return \Civi\Core\Locale | ||
*/ | ||
public static function detect(): Locale { | ||
// If anyone has ever called `setLocale()` (*which they should, ideally*), then we already have an object... | ||
global $civicrmLocale; | ||
if ($civicrmLocale) { | ||
return $civicrmLocale; | ||
} | ||
|
||
// If they haven't (*which wasn't required before*)... then we'll figure it out... | ||
global $tsLocale, $dbLocale; | ||
$locale = new Locale(); | ||
$locale->nominal = $tsLocale; | ||
$locale->ts = $tsLocale; | ||
$locale->db = $dbLocale ? ltrim($dbLocale, '_') : NULL; | ||
$locale->moneyFormat = $tsLocale; | ||
$locale->uf = \CRM_Utils_System::getUFLocale(); | ||
return $locale; | ||
} | ||
|
||
/** | ||
* Negotiate an effective locale, based on the user's preference. | ||
* | ||
* @param string $preferred | ||
* The locale that is preferred by the user. | ||
* Ex: `en_US`, `es_ES`, `fr_CA` | ||
* @return \Civi\Core\Locale | ||
* The effective locale specification. | ||
*/ | ||
public static function negotiate(string $preferred): Locale { | ||
// Create a locale for the requested language | ||
if (!preg_match(';^[a-z][a-z]_[A-Z][A-Z]$;', $preferred)) { | ||
throw new \RuntimeException("Cannot instantiate malformed locale: $preferred"); | ||
} | ||
|
||
$systemDefault = \Civi::settings()->get('lcMessages'); | ||
|
||
if (\Civi::settings()->get('partial_locales')) { | ||
\CRM_Core_OptionValue::getValues(['name' => 'languages'], $optionValues, 'weight', TRUE); | ||
$validNominalLocales = array_column($optionValues, 'label', 'name'); | ||
$validTsLocales = \CRM_Core_I18n::languages(FALSE); /* Active OV _and_ available MO */ | ||
$validFormatLocales = $validNominalLocales; /* FIXME Where do we get this? */ | ||
} | ||
else { | ||
$validNominalLocales = $validTsLocales = $validFormatLocales | ||
= \CRM_Core_I18n::languages(FALSE); | ||
// Or stricter? array_fill_keys(\CRM_Core_I18n::uiLanguages(TRUE), TRUE); | ||
} | ||
$validDbLocales = \CRM_Core_I18n::isMultiLingual() ? \Civi::settings()->get('languageLimit') : NULL; | ||
|
||
// TODO This always falls back to the system locale. Maybe use getLocalePrecedence() instead... | ||
$locale = new static(); | ||
$locale->nominal = isset($validNominalLocales[$preferred]) ? $preferred : $systemDefault; | ||
$locale->ts = isset($validTsLocales[$preferred]) ? $preferred : $systemDefault; | ||
$locale->moneyFormat = isset($validFormatLocales[$locale->nominal]) ? $locale->nominal : $systemDefault; | ||
$locale->db = \CRM_Core_I18n::isMultiLingual() && isset($validDbLocales[$locale->nominal]) ? $locale->nominal : NULL; | ||
return $locale; | ||
} | ||
|
||
public static function null(): Locale { | ||
return new Locale([ | ||
'nominal' => NULL, | ||
'ts' => NULL, | ||
'moneyFormat' => NULL, | ||
'db' => \CRM_Core_I18n::isMultiLingual() ? \Civi::settings()->get('lcMessages') : NULL , | ||
]); | ||
} | ||
|
||
public function __construct(array $params = []) { | ||
foreach ($params as $key => $value) { | ||
$this->{$key} = $value; | ||
} | ||
} | ||
|
||
/** | ||
* Activate this locale, updating any active PHP services that rely on it. | ||
* | ||
* @return static | ||
*/ | ||
public function apply(): Locale { | ||
\CRM_Core_I18n::singleton()->setLocale($this); | ||
return $this; | ||
} | ||
|
||
/** | ||
* Re-negotiate the effective locale. | ||
* | ||
* This is useful if you are beginning some business-transaction where the business | ||
* record has localized resources. For example, a CiviContribute receipt might have | ||
* different templates for a handful of locales -- in which case, you should choose | ||
* among those locales. | ||
* | ||
* The current implementation prefers to match the nominal language. | ||
* | ||
* @param string[] $availableLocales | ||
* List of locales that you know how to serve. | ||
* Ex: ['en_US', 'fr_CA', 'es_MX'] | ||
* @return \Civi\Core\Locale | ||
* The chosen locale. | ||
* If no good locales could be chosen, then NULL. | ||
*/ | ||
public function renegotiate(array $availableLocales): ?Locale { | ||
$fallbacks = array_merge( | ||
// We'd like to stay in the active locale (or something closely related) | ||
($this->nominal ? static::getLocalePrecedence($this->nominal) : []), | ||
// If we can't, then try the system locale (or something closely related) | ||
static::getLocalePrecedence(\Civi::settings()->get('lcMessages')) | ||
); | ||
$picked = static::pickFirstLocale($availableLocales, $fallbacks); | ||
return $picked ? static::negotiate($picked) : NULL; | ||
} | ||
|
||
/** | ||
* (Internal helper) Given a list of available locales and a general preference, pick the best match. | ||
* | ||
* @param array $availableLocales | ||
* Ex: ['en_US', 'es_MX', 'es_ES', 'fr_CA'] | ||
* @param array $preferredLocales | ||
* Ex: ['es_PR', 'es_419', 'es_MX', 'es_ES'] | ||
* @return string|null | ||
* The available locale with the highest preference. | ||
* Ex: 'es_MX' | ||
*/ | ||
private static function pickFirstLocale(array $availableLocales, array $preferredLocales): ?string { | ||
foreach ($preferredLocales as $locale) { | ||
if (in_array($locale, $availableLocales, TRUE)) { | ||
return $locale; | ||
} | ||
} | ||
return NULL; | ||
} | ||
|
||
/** | ||
* (Internal helper) Given a $preferred locale, determine a prioritized list of alternate locales. | ||
* | ||
* @param string $preferred | ||
* Ex: 'es_PR' | ||
* @return string[] | ||
* Ex: ['es_PR', 'es_419', 'es_MX', 'es_ES'] | ||
*/ | ||
private static function getLocalePrecedence(string $preferred): array { | ||
[$lang] = explode('_', $preferred); | ||
|
||
// (Eileen) 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', 'en_GB', 'en_AU', 'en_NZ'], | ||
'fr' => ['fr_FR', 'fr_CA'], | ||
'es' => ['es_419', 'es_MX', 'es_ES'], | ||
'nl' => ['nl_NL'], | ||
'pt' => ['pt_PT', 'pt_BR'], | ||
'zh' => ['zh_TW'], | ||
]; | ||
$fallbacks = $defaultLanguages[$lang] ?? []; | ||
array_unshift($fallbacks, $preferred); | ||
return $fallbacks; | ||
} | ||
|
||
} |