From 1190626dc6669a5a9242ab788a64d058e42dd9cb Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 5 Aug 2022 03:00:29 -0700 Subject: [PATCH] (dev/translation#78) When setting locale, track it as a `Civi\Core\Locale` instance --- CRM/Core/I18n.php | 24 +++- Civi/Core/Locale.php | 305 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 Civi/Core/Locale.php diff --git a/CRM/Core/I18n.php b/CRM/Core/I18n.php index d14716b0b8..23ce9e18d3 100644 --- a/CRM/Core/I18n.php +++ b/CRM/Core/I18n.php @@ -654,25 +654,35 @@ class CRM_Core_I18n { /** * Change the processing language without changing the current user language * - * @param string $locale + * @param string|\Civi\Core\Locale $locale * Locale (for example 'en_US', or 'fr_CA'). * True if the domain was changed for an extension. */ public function setLocale($locale) { + global $civicrmLocale; + if ($locale === NULL) { + $civicrmLocale = \Civi\Core\Locale::null(); + } + elseif (is_object($locale)) { + $civicrmLocale = $locale; + } + else { + $civicrmLocale = \Civi\Core\Locale::negotiate($locale); + } // Change the language of the CMS as well, for URLs. - CRM_Utils_System::setUFLocale($locale); + CRM_Utils_System::setUFLocale($civicrmLocale->uf); // For sql queries, if running in DB multi-lingual mode. global $dbLocale; if ($dbLocale) { - $dbLocale = "_{$locale}"; + $dbLocale = '_' . $civicrmLocale->db; } // For self::getLocale() global $tsLocale; - $tsLocale = $locale; + $tsLocale = $civicrmLocale->ts; CRM_Core_I18n::singleton()->reactivate(); } @@ -811,10 +821,10 @@ function ts($text, $params = []) { } } - $activeLocale = CRM_Core_I18n::getLocale(); - if (!$i18n or $lastLocale != $activeLocale) { + $civicrmLocale = CRM_Core_I18n::getLocale(); + if (!$i18n or $lastLocale != $civicrmLocale) { $i18n = CRM_Core_I18n::singleton(); - $lastLocale = $activeLocale; + $lastLocale = $civicrmLocale; } if ($function) { diff --git a/Civi/Core/Locale.php b/Civi/Core/Locale.php new file mode 100644 index 0000000000..e3e12579ac --- /dev/null +++ b/Civi/Core/Locale.php @@ -0,0 +1,305 @@ + '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; + } + +} -- 2.25.1