Elgg  Version 5.1
Translator.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Elgg\I18n;
4 
5 use Elgg\Config;
6 use Elgg\Includer;
8 
16 class Translator {
17 
18  use Loggable;
19 
20  protected Config $config;
21 
23 
24  protected array $translations = [];
25 
26  protected string $defaultPath;
27 
28  protected ?string $current_language = null;
29 
30  protected array $allowed_languages;
31 
46  protected array $language_paths = [];
47 
48  protected bool $was_reloaded = false;
49 
56  public function __construct(Config $config, LocaleService $locale) {
57  $this->config = $config;
58  $this->locale = $locale;
59 
60  $this->defaultPath = dirname(__DIR__, 4) . '/languages/';
61 
62  $this->registerLanguagePath($this->defaultPath);
63  }
64 
70  public function getLoadedTranslations(): array {
71  return $this->translations;
72  }
73 
84  public function translate(string $message_key, array $args = [], string $language = ''): string {
85  if (\Elgg\Values::isEmpty($message_key)) {
86  return '';
87  }
88 
89  // if no language provided, get current language based on detection, user setting or site
91 
92  // build language array for different trys
93  // avoid dupes without overhead of array_unique
94  $langs = [
95  $language => true,
96  ];
97 
98  // load site language
99  $site_language = $this->config->language;
100  if (!empty($site_language)) {
101  $langs[$site_language] = true;
102  }
103 
104  // ultimate language fallback
105  $langs['en'] = true;
106 
107  $langs = array_intersect_key($langs, array_flip($this->getAllowedLanguages()));
108 
109  // try to translate
110  $string = $message_key;
111  foreach (array_keys($langs) as $try_lang) {
112  $this->ensureTranslationsLoaded($try_lang);
113 
114  if (isset($this->translations[$try_lang][$message_key])) {
115  $string = $this->translations[$try_lang][$message_key];
116 
117  // only pass through if we have arguments to allow backward compatibility
118  // with manual sprintf() calls.
119  if (!empty($args)) {
120  try {
121  $string = vsprintf($string, $args);
122 
123  if ($string === false) {
124  $string = $message_key;
125 
126  $this->getLogger()->warning("Translation error for key '{$message_key}': Too few arguments provided (" . var_export($args, true) . ')');
127  }
128  } catch (\ValueError $e) {
129  // PHP 8 throws errors
130  $string = $message_key;
131 
132  $this->getLogger()->warning("Translation error for key '{$message_key}': " . $e->getMessage());
133  }
134  }
135 
136  break;
137  } else {
138  $message = sprintf(
139  'Missing %s translation for "%s" language key',
140  ($try_lang === 'en') ? 'English' : $try_lang,
141  $message_key
142  );
143 
144  if ($try_lang === 'en') {
145  $this->getLogger()->notice($message);
146  } else {
147  $this->getLogger()->info($message);
148  }
149  }
150  }
151 
152  return $string;
153  }
154 
169  public function addTranslation(string $country_code, array $language_array, bool $ensure_translations_loaded = true): bool {
170  $country_code = trim(strtolower($country_code));
171 
172  if (empty($language_array) || $country_code === '') {
173  return false;
174  }
175 
176  if (!isset($this->translations[$country_code])) {
177  $this->translations[$country_code] = [];
178 
179  if ($ensure_translations_loaded) {
180  // make sure all existing paths are included first before adding language arrays
181  $this->loadTranslations($country_code);
182  }
183  }
184 
185  // Note that we are using union operator instead of array_merge() due to performance implications
186  $this->translations[$country_code] = $language_array + $this->translations[$country_code];
187 
188  return true;
189  }
190 
196  public function getCurrentLanguage(): string {
197  if (!isset($this->current_language)) {
198  $this->current_language = $this->detectLanguage();
199  }
200 
201  if (empty($this->current_language)) {
202  $this->current_language = 'en';
203  }
204 
206  }
207 
215  public function setCurrentLanguage(string $language = null): void {
216  $this->current_language = $language;
217  }
218 
226  public function detectLanguage(): string {
227  // detect from URL
228  $url_lang = _elgg_services()->request->getParam('hl');
229  $user = _elgg_services()->session_manager->getLoggedInUser();
230 
231  if (!empty($url_lang)) {
232  // store language for logged out users
233  if (empty($user)) {
234  $cookie = new \ElggCookie('language');
235  $cookie->value = $url_lang;
236  elgg_set_cookie($cookie);
237  }
238 
239  return $url_lang;
240  }
241 
242  // detect from cookie
243  $cookie = _elgg_services()->request->cookies->get('language');
244  if (!empty($cookie)) {
245  return $cookie;
246  }
247 
248  // check logged in user
249  if (!empty($user) && !empty($user->language)) {
250  return $user->language;
251  }
252 
253  // detect from browser if not logged in
254  if ($this->config->language_detect_from_browser) {
255  $browserlangs = _elgg_services()->request->getLanguages();
256  if (!empty($browserlangs)) {
257  $browserlang = explode('_', $browserlangs[0]);
258 
259  return $browserlang[0];
260  }
261  }
262 
263  // get site setting or empty string if not set in config
264  return (string) $this->config->language;
265  }
266 
276  public function bootTranslations(): void {
277  $languages = array_unique(['en', $this->getCurrentLanguage()]);
278 
279  foreach ($languages as $language) {
280  $this->loadTranslations($language);
281  }
282  }
283 
297  public function loadTranslations(string $language): void {
298  $data = elgg_load_system_cache("{$language}.lang");
299  if (is_array($data)) {
300  $this->addTranslation($language, $data, false);
301  return;
302  }
303 
304  foreach ($this->getLanguagePaths() as $path) {
305  $this->registerTranslations($path, false, $language);
306  }
307 
308  $translations = elgg_extract($language, $this->translations, []);
309  elgg_save_system_cache("{$language}.lang", $translations);
310  }
311 
324  public function registerTranslations(string $path, bool $load_all = false, string $language = null): bool {
325  $path = \Elgg\Project\Paths::sanitize($path);
326 
327  // don't need to register translations as the folder is missing
328  if (!is_dir($path)) {
329  $this->getLogger()->info("No translations could be loaded from: {$path}");
330  return true;
331  }
332 
333  // Make a note of this path just in case we need to register this language later
334  $this->registerLanguagePath($path);
335 
336  $this->getLogger()->info("Translations loaded from: {$path}");
337 
338  if ($language) {
339  $load_language_files = ["{$language}.php"];
340  $load_all = false;
341  } else {
342  // Get the current language based on site defaults and user preference
343  $current_language = $this->getCurrentLanguage();
344 
345  $load_language_files = [
346  'en.php',
347  "{$current_language}.php"
348  ];
349 
350  $load_language_files = array_unique($load_language_files);
351  }
352 
353  $handle = opendir($path);
354  if (empty($handle)) {
355  $this->getLogger()->error("Could not open language path: {$path}");
356  return false;
357  }
358 
359  $return = true;
360  while (($language_file = readdir($handle)) !== false) {
361  // ignore bad files
362  if (str_starts_with($language_file, '.') || !str_ends_with($language_file, '.php')) {
363  continue;
364  }
365 
366  if (in_array($language_file, $load_language_files) || $load_all) {
367  $return = $return && $this->includeLanguageFile($path . $language_file);
368  }
369  }
370 
371  closedir($handle);
372 
373  return $return;
374  }
375 
384  protected function includeLanguageFile(string $path): bool {
386 
387  if (is_array($result)) {
388  $this->addTranslation(basename($path, '.php'), $result);
389  return true;
390  }
391 
392  $this->getLogger()->warning("Language file did not return an array: {$path}");
393 
394  return false;
395  }
396 
406  public function reloadAllTranslations(): void {
407  if ($this->was_reloaded) {
408  return;
409  }
410 
412 
413  foreach ($languages as $language) {
414  $this->ensureTranslationsLoaded($language);
415  }
416 
417  _elgg_services()->events->triggerAfter('reload', 'translations');
418 
419  $this->was_reloaded = true;
420  }
421 
430  public function getInstalledTranslations(bool $calculate_completeness = false): array {
431  if ($calculate_completeness) {
432  // Ensure that all possible translations are loaded
433  $this->reloadAllTranslations();
434  }
435 
436  $result = [];
437 
439  foreach ($languages as $language) {
440  if ($this->languageKeyExists($language, $language)) {
441  $value = $this->translate($language, [], $language);
442  } else {
443  $value = $this->translate($language);
444  }
445 
446  if (($language !== 'en') && $calculate_completeness) {
447  $completeness = $this->getLanguageCompleteness($language);
448  $value .= ' (' . $completeness . '% ' . $this->translate('complete') . ')';
449  }
450 
452  }
453 
454  natcasesort($result);
455 
456  return $result;
457  }
458 
466  public function getLanguageCompleteness(string $language): float {
467  if ($language === 'en') {
468  return (float) 100;
469  }
470 
471  // Ensure that all possible translations are loaded
472  $this->reloadAllTranslations();
473 
474  $en = count($this->translations['en']);
475 
476  $missing = count($this->getMissingLanguageKeys($language));
477 
478  $lang = $en - $missing;
479 
480  return round(($lang / $en) * 100, 2);
481  }
482 
493  public function getMissingLanguageKeys(string $language): array {
494  // Ensure that all possible translations are loaded
495  $this->reloadAllTranslations();
496 
497  $missing = [];
498 
499  foreach ($this->translations['en'] as $k => $v) {
500  if (!isset($this->translations[$language][$k]) || ($this->translations[$language][$k] === $this->translations['en'][$k])) {
501  $missing[] = $k;
502  }
503  }
504 
505  return $missing;
506  }
507 
517  public function languageKeyExists(string $key, string $language = 'en'): bool {
518  if (\Elgg\Values::isEmpty($key)) {
519  return false;
520  }
521 
523 
524  if (!array_key_exists($language, $this->translations)) {
525  return false;
526  }
527 
528  return array_key_exists($key, $this->translations[$language]);
529  }
530 
537  public function getAvailableLanguages(): array {
538  $languages = [];
539 
540  $allowed_languages = $this->locale->getLanguageCodes();
541 
542  foreach ($this->getLanguagePaths() as $path) {
543  try {
544  $iterator = new \DirectoryIterator($path);
545  } catch (\Exception $e) {
546  continue;
547  }
548 
549  foreach ($iterator as $file) {
550  if ($file->isDir()) {
551  continue;
552  }
553 
554  if ($file->getExtension() !== 'php') {
555  continue;
556  }
557 
558  $language = $file->getBasename('.php');
559  if (empty($language) || !in_array($language, $allowed_languages)) {
560  continue;
561  }
562 
563  $languages[$language] = true;
564  }
565  }
566 
567  return _elgg_services()->events->triggerResults('languages', 'translations', [], array_keys($languages));
568  }
569 
576  public function getAllowedLanguages(): array {
577  if (isset($this->allowed_languages)) {
579  }
580 
581  $allowed_languages = $this->config->allowed_languages;
582  if (!empty($allowed_languages)) {
583  $allowed_languages = explode(',', $allowed_languages);
584  $allowed_languages = array_filter(array_unique($allowed_languages));
585  } else {
586  $allowed_languages = $this->getAvailableLanguages();
587  }
588 
589  if (!in_array($this->config->language, $allowed_languages)) {
590  // site language is always allowed
591  $allowed_languages[] = $this->config->language;
592  }
593 
594  if (!in_array('en', $allowed_languages)) {
595  // 'en' language is always allowed
596  $allowed_languages[] = 'en';
597  }
598 
599  $this->allowed_languages = $allowed_languages;
600 
601  return $allowed_languages;
602  }
603 
613  public function registerLanguagePath(string $path): void {
614  if (isset($this->language_paths[$path])) {
615  return;
616  }
617 
618  if (!is_dir($path)) {
619  return;
620  }
621 
622  $this->language_paths[$path] = true;
623  }
624 
632  public function getLanguagePaths(): array {
633  return array_keys($this->language_paths);
634  }
635 
643  protected function ensureTranslationsLoaded(string $language): void {
644  if (isset($this->translations[$language])) {
645  return;
646  }
647 
648  // The language being requested is not the same as the language of the
649  // logged in user, so we will have to load it separately. (Most likely
650  // we're sending a notification and the recipient is using a different
651  // language than the logged in user.)
652  $this->loadTranslations($language);
653  }
654 }
static includeFile($file)
Include a file with as little context as possible.
Definition: Includer.php:18
LocaleService $locale
Definition: Translator.php:22
setCurrentLanguage(string $language=null)
Sets current system language.
Definition: Translator.php:215
addTranslation(string $country_code, array $language_array, bool $ensure_translations_loaded=true)
Add a translation.
Definition: Translator.php:169
getLanguageCompleteness(string $language)
Return the level of completeness for a given language code (compared to english)
Definition: Translator.php:466
loadTranslations(string $language)
Load both core and plugin translations.
Definition: Translator.php:297
c Accompany it with the information you received as to the offer to distribute corresponding source complete source code means all the source code for all modules it plus any associated interface definition plus the scripts used to control compilation and installation of the executable as a special the source code distributed need not include anything that is normally and so on of the operating system on which the executable unless that component itself accompanies the executable If distribution of executable or object code is made by offering access to copy from a designated then offering equivalent access to copy the source code from the same place counts as distribution of the source even though third parties are not compelled to copy the source along with the object code You may not or distribute the Program except as expressly provided under this License Any attempt otherwise to sublicense or distribute the Program is void
Definition: LICENSE.txt:215
registerTranslations(string $path, bool $load_all=false, string $language=null)
When given a full path, finds translation files and loads them.
Definition: Translator.php:324
getAvailableLanguages()
Returns an array of all available language keys.
Definition: Translator.php:537
$args
Some servers don&#39;t allow PHP to check the rewrite, so try via AJAX.
translate(string $message_key, array $args=[], string $language= '')
Given a message key, returns an appropriately translated full-text string.
Definition: Translator.php:84
static isEmpty($value)
Check if a value isn&#39;t empty, but allow 0 and &#39;0&#39;.
Definition: Values.php:192
$value
Definition: generic.php:51
elgg_extract($key, $array, $default=null, bool $strict=true)
Checks for $array[$key] and returns its value if it exists, else returns $default.
Definition: elgglib.php:254
Provides locale related features.
$path
Definition: details.php:70
if(!$entity instanceof\ElggUser) $data
Definition: attributes.php:13
registerLanguagePath(string $path)
Registers a path for potential translation files.
Definition: Translator.php:613
trait Loggable
Enables adding a logger.
Definition: Loggable.php:14
detectLanguage()
Detect the current system/user language or false.
Definition: Translator.php:226
reloadAllTranslations()
Reload all translations from all registered paths.
Definition: Translator.php:406
$language
Definition: useradd.php:17
languageKeyExists(string $key, string $language= 'en')
Check if a given language key exists.
Definition: Translator.php:517
getLanguagePaths()
Returns a unique array with locations of translation files.
Definition: Translator.php:632
$lang
Definition: html.php:13
getLoadedTranslations()
Get a map of all loaded translations.
Definition: Translator.php:70
$user
Definition: ban.php:7
bootTranslations()
Ensures all needed translations are loaded.
Definition: Translator.php:276
__construct(Config $config, LocaleService $locale)
Constructor.
Definition: Translator.php:56
getCurrentLanguage()
Get the current system/user language or &#39;en&#39;.
Definition: Translator.php:196
elgg_save_system_cache(string $type, $data, int $expire_after=null)
Saves a system cache.
Definition: cache.php:34
elgg_set_cookie(\ElggCookie $cookie)
Set a cookie, but allow plugins to customize it first.
Definition: sessions.php:65
if($container instanceof ElggGroup &&$container->guid!=elgg_get_page_owner_guid()) $key
Definition: summary.php:44
$site_language
getLogger()
Returns logger.
Definition: Loggable.php:37
getAllowedLanguages()
Returns an array of allowed languages as configured by the site admin.
Definition: Translator.php:576
includeLanguageFile(string $path)
Load cached or include a language file by its path.
Definition: Translator.php:384
ensureTranslationsLoaded(string $language)
Make sure translations are loaded.
Definition: Translator.php:643
elgg_load_system_cache(string $type)
Retrieve the contents of a system cache.
Definition: cache.php:45
getMissingLanguageKeys(string $language)
Return the translation keys missing from a given language, or those that are identical to the english...
Definition: Translator.php:493
static sanitize($path, $append_slash=true)
Sanitize file paths ensuring that they begin and end with slashes etc.
Definition: Paths.php:76
_elgg_services()
Get the global service provider.
Definition: elgglib.php:346
$languages
getInstalledTranslations(bool $calculate_completeness=false)
Return an array of installed translations as an associative array "two letter code" => "native langua...
Definition: Translator.php:430