Elgg  Version master
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;
7 use Elgg\Traits\Loggable;
8 
16 class Translator {
17 
18  use Loggable;
19 
20  protected array $translations = [];
21 
22  protected string $defaultPath;
23 
24  protected ?string $current_language = null;
25 
26  protected array $allowed_languages;
27 
42  protected array $language_paths = [];
43 
44  protected bool $was_reloaded = false;
45 
52  public function __construct(protected Config $config, protected LocaleService $locale) {
53  $this->defaultPath = dirname(__DIR__, 4) . '/languages/';
54 
55  $this->registerLanguagePath($this->defaultPath);
56  }
57 
63  public function getLoadedTranslations(): array {
64  return $this->translations;
65  }
66 
77  public function translate(string $message_key, array $args = [], string $language = ''): string {
78  if (\Elgg\Values::isEmpty($message_key)) {
79  return '';
80  }
81 
82  // if no language provided, get current language based on detection, user setting or site
83  $language = $language ?: $this->getCurrentLanguage();
84 
85  // build language array for different trys
86  // avoid dupes without overhead of array_unique
87  $langs = [
88  $language => true,
89  ];
90 
91  // load site language
92  $site_language = $this->config->language;
93  if (!empty($site_language)) {
94  $langs[$site_language] = true;
95  }
96 
97  // ultimate language fallback
98  $langs['en'] = true;
99 
100  $langs = array_intersect_key($langs, array_flip($this->getAllowedLanguages()));
101 
102  // try to translate
103  $string = $message_key;
104  foreach (array_keys($langs) as $try_lang) {
105  $this->ensureTranslationsLoaded($try_lang);
106 
107  if (isset($this->translations[$try_lang][$message_key])) {
108  $string = $this->translations[$try_lang][$message_key];
109 
110  // only pass through if we have arguments to allow backward compatibility
111  // with manual sprintf() calls.
112  if (!empty($args)) {
113  try {
114  $string = vsprintf($string, $args);
115 
116  if ($string === false) {
117  $string = $message_key;
118 
119  $this->getLogger()->warning("Translation error for key '{$message_key}': Too few arguments provided (" . var_export($args, true) . ')');
120  }
121  } catch (\ValueError $e) {
122  // PHP 8 throws errors
123  $string = $message_key;
124 
125  $this->getLogger()->warning("Translation error for key '{$message_key}': " . $e->getMessage());
126  }
127  }
128 
129  break;
130  } else {
131  $message = sprintf(
132  'Missing %s translation for "%s" language key',
133  ($try_lang === 'en') ? 'English' : $try_lang,
134  $message_key
135  );
136 
137  if ($try_lang === 'en') {
138  $this->getLogger()->notice($message);
139  } else {
140  $this->getLogger()->info($message);
141  }
142  }
143  }
144 
145  return $string;
146  }
147 
162  public function addTranslation(string $country_code, array $language_array, bool $ensure_translations_loaded = true): bool {
163  $country_code = trim(strtolower($country_code));
164 
165  if (empty($language_array) || $country_code === '') {
166  return false;
167  }
168 
169  if (!isset($this->translations[$country_code])) {
170  $this->translations[$country_code] = [];
171 
172  if ($ensure_translations_loaded) {
173  // make sure all existing paths are included first before adding language arrays
174  $this->loadTranslations($country_code);
175  }
176  }
177 
178  // Note that we are using union operator instead of array_merge() due to performance implications
179  $this->translations[$country_code] = $language_array + $this->translations[$country_code];
180 
181  return true;
182  }
183 
189  public function getCurrentLanguage(): string {
190  if (!isset($this->current_language)) {
191  $this->current_language = $this->detectLanguage();
192  }
193 
194  if (empty($this->current_language)) {
195  $this->current_language = 'en';
196  }
197 
198  return $this->current_language;
199  }
200 
208  public function setCurrentLanguage(?string $language = null): void {
209  $this->current_language = $language;
210  }
211 
219  public function detectLanguage(): string {
220  // detect from URL
221  $url_lang = _elgg_services()->request->getParam('hl');
222  $user = _elgg_services()->session_manager->getLoggedInUser();
223 
224  if (!empty($url_lang)) {
225  // store language for logged out users
226  if (empty($user)) {
227  $cookie = new \ElggCookie('language');
228  $cookie->value = $url_lang;
229  elgg_set_cookie($cookie);
230  }
231 
232  return $url_lang;
233  }
234 
235  // detect from cookie
236  $cookie = _elgg_services()->request->cookies->get('language');
237  if (!empty($cookie)) {
238  return $cookie;
239  }
240 
241  // check logged in user
242  if (!empty($user) && !empty($user->language)) {
243  return $user->language;
244  }
245 
246  // detect from browser if not logged in
247  if ($this->config->language_detect_from_browser) {
248  $browserlangs = _elgg_services()->request->getLanguages();
249  if (!empty($browserlangs)) {
250  $browserlang = explode('_', $browserlangs[0]);
251 
252  return $browserlang[0];
253  }
254  }
255 
256  // get site setting or empty string if not set in config
257  return (string) $this->config->language;
258  }
259 
269  public function bootTranslations(): void {
270  $languages = array_unique(['en', $this->getCurrentLanguage()]);
271 
272  foreach ($languages as $language) {
273  $this->loadTranslations($language);
274  }
275  }
276 
290  public function loadTranslations(string $language): void {
291  $data = elgg_load_system_cache("{$language}.lang");
292  if (is_array($data)) {
293  $this->addTranslation($language, $data, false);
294  return;
295  }
296 
297  foreach ($this->getLanguagePaths() as $path) {
298  $this->registerTranslations($path, false, $language);
299  }
300 
301  $translations = elgg_extract($language, $this->translations, []);
302  elgg_save_system_cache("{$language}.lang", $translations);
303  }
304 
317  public function registerTranslations(string $path, bool $load_all = false, ?string $language = null): bool {
318  $path = \Elgg\Project\Paths::sanitize($path);
319 
320  // don't need to register translations as the folder is missing
321  if (!is_dir($path)) {
322  $this->getLogger()->info("No translations could be loaded from: {$path}");
323  return true;
324  }
325 
326  // Make a note of this path just in case we need to register this language later
327  $this->registerLanguagePath($path);
328 
329  $this->getLogger()->info("Translations loaded from: {$path}");
330 
331  if ($language) {
332  $load_language_files = ["{$language}.php"];
333  $load_all = false;
334  } else {
335  // Get the current language based on site defaults and user preference
336  $current_language = $this->getCurrentLanguage();
337 
338  $load_language_files = [
339  'en.php',
340  "{$current_language}.php"
341  ];
342 
343  $load_language_files = array_unique($load_language_files);
344  }
345 
346  $handle = opendir($path);
347  if (empty($handle)) {
348  $this->getLogger()->error("Could not open language path: {$path}");
349  return false;
350  }
351 
352  $return = true;
353  while (($language_file = readdir($handle)) !== false) {
354  // ignore bad files
355  if (str_starts_with($language_file, '.') || !str_ends_with($language_file, '.php')) {
356  continue;
357  }
358 
359  if (in_array($language_file, $load_language_files) || $load_all) {
360  $return = $return && $this->includeLanguageFile($path . $language_file);
361  }
362  }
363 
364  closedir($handle);
365 
366  return $return;
367  }
368 
377  protected function includeLanguageFile(string $path): bool {
378  $result = Includer::includeFile($path);
379 
380  if (is_array($result)) {
381  $this->addTranslation(basename($path, '.php'), $result);
382  return true;
383  }
384 
385  $this->getLogger()->warning("Language file did not return an array: {$path}");
386 
387  return false;
388  }
389 
399  public function reloadAllTranslations(): void {
400  if ($this->was_reloaded) {
401  return;
402  }
403 
404  $languages = $this->getAvailableLanguages();
405 
406  foreach ($languages as $language) {
407  $this->ensureTranslationsLoaded($language);
408  }
409 
410  _elgg_services()->events->triggerAfter('reload', 'translations');
411 
412  $this->was_reloaded = true;
413  }
414 
423  public function getInstalledTranslations(bool $calculate_completeness = false): array {
424  if ($calculate_completeness) {
425  // Ensure that all possible translations are loaded
426  $this->reloadAllTranslations();
427  }
428 
429  $result = [];
430 
431  $languages = $this->getAvailableLanguages();
432  foreach ($languages as $language) {
433  if ($this->languageKeyExists($language, $language)) {
434  $value = $this->translate($language, [], $language);
435  } else {
436  $value = $this->translate($language);
437  }
438 
439  if (($language !== 'en') && $calculate_completeness) {
440  $completeness = $this->getLanguageCompleteness($language);
441  $value .= ' (' . $completeness . '% ' . $this->translate('complete') . ')';
442  }
443 
445  }
446 
447  natcasesort($result);
448 
449  return $result;
450  }
451 
459  public function getLanguageCompleteness(string $language): float {
460  if ($language === 'en') {
461  return (float) 100;
462  }
463 
464  // Ensure that all possible translations are loaded
465  $this->reloadAllTranslations();
466 
467  $en = count($this->translations['en']);
468 
469  $missing = count($this->getMissingLanguageKeys($language));
470 
471  $lang = $en - $missing;
472 
473  return round(($lang / $en) * 100, 2);
474  }
475 
486  public function getMissingLanguageKeys(string $language): array {
487  // Ensure that all possible translations are loaded
488  $this->reloadAllTranslations();
489 
490  $missing = [];
491 
492  foreach ($this->translations['en'] as $k => $v) {
493  if (!isset($this->translations[$language][$k]) || ($this->translations[$language][$k] === $this->translations['en'][$k])) {
494  $missing[] = $k;
495  }
496  }
497 
498  return $missing;
499  }
500 
510  public function languageKeyExists(string $key, string $language = 'en'): bool {
511  if (\Elgg\Values::isEmpty($key)) {
512  return false;
513  }
514 
515  $this->ensureTranslationsLoaded($language);
516 
517  if (!array_key_exists($language, $this->translations)) {
518  return false;
519  }
520 
521  return array_key_exists($key, $this->translations[$language]);
522  }
523 
530  public function getAvailableLanguages(): array {
531  $languages = [];
532 
533  $allowed_languages = $this->locale->getLanguageCodes();
534 
535  foreach ($this->getLanguagePaths() as $path) {
536  try {
537  $iterator = new \DirectoryIterator($path);
538  } catch (\Exception $e) {
539  continue;
540  }
541 
542  foreach ($iterator as $file) {
543  if ($file->isDir()) {
544  continue;
545  }
546 
547  if ($file->getExtension() !== 'php') {
548  continue;
549  }
550 
551  $language = $file->getBasename('.php');
552  if (empty($language) || !in_array($language, $allowed_languages)) {
553  continue;
554  }
555 
556  $languages[$language] = true;
557  }
558  }
559 
560  return _elgg_services()->events->triggerResults('languages', 'translations', [], array_keys($languages));
561  }
562 
569  public function getAllowedLanguages(): array {
570  if (isset($this->allowed_languages)) {
572  }
573 
574  $allowed_languages = $this->config->allowed_languages;
575  if (!empty($allowed_languages)) {
576  $allowed_languages = explode(',', $allowed_languages);
577  $allowed_languages = array_filter(array_unique($allowed_languages));
578  } else {
579  $allowed_languages = $this->getAvailableLanguages();
580  }
581 
582  if (!in_array($this->config->language, $allowed_languages)) {
583  // site language is always allowed
584  $allowed_languages[] = $this->config->language;
585  }
586 
587  if (!in_array('en', $allowed_languages)) {
588  // 'en' language is always allowed
589  $allowed_languages[] = 'en';
590  }
591 
592  $this->allowed_languages = $allowed_languages;
593 
594  return $allowed_languages;
595  }
596 
606  public function registerLanguagePath(string $path): void {
607  if (isset($this->language_paths[$path])) {
608  return;
609  }
610 
611  if (!is_dir($path)) {
612  return;
613  }
614 
615  $this->language_paths[$path] = true;
616  }
617 
625  public function getLanguagePaths(): array {
626  return array_keys($this->language_paths);
627  }
628 
636  protected function ensureTranslationsLoaded(string $language): void {
637  if (isset($this->translations[$language])) {
638  return;
639  }
640 
641  // The language being requested is not the same as the language of the
642  // logged in user, so we will have to load it separately. (Most likely
643  // we're sending a notification and the recipient is using a different
644  // language than the logged in user.)
645  $this->loadTranslations($language);
646  }
647 }
getLogger()
Returns logger.
Definition: Loggable.php:37
$allowed_languages
Definition: settings.php:47
$language
Definition: useradd.php:17
return[ 'admin/delete_admin_notices'=>['access'=> 'admin'], 'admin/menu/save'=>['access'=> 'admin'], 'admin/plugins/activate'=>['access'=> 'admin'], 'admin/plugins/activate_all'=>['access'=> 'admin'], 'admin/plugins/deactivate'=>['access'=> 'admin'], 'admin/plugins/deactivate_all'=>['access'=> 'admin'], 'admin/plugins/set_priority'=>['access'=> 'admin'], 'admin/security/security_txt'=>['access'=> 'admin'], 'admin/security/settings'=>['access'=> 'admin'], 'admin/security/regenerate_site_secret'=>['access'=> 'admin'], 'admin/site/cache/invalidate'=>['access'=> 'admin'], 'admin/site/flush_cache'=>['access'=> 'admin'], 'admin/site/icons'=>['access'=> 'admin'], 'admin/site/set_maintenance_mode'=>['access'=> 'admin'], 'admin/site/set_robots'=>['access'=> 'admin'], 'admin/site/theme'=>['access'=> 'admin'], 'admin/site/unlock_upgrade'=>['access'=> 'admin'], 'admin/site/settings'=>['access'=> 'admin'], 'admin/upgrade'=>['access'=> 'admin'], 'admin/upgrade/reset'=>['access'=> 'admin'], 'admin/user/ban'=>['access'=> 'admin'], 'admin/user/bulk/ban'=>['access'=> 'admin'], 'admin/user/bulk/delete'=>['access'=> 'admin'], 'admin/user/bulk/unban'=>['access'=> 'admin'], 'admin/user/bulk/validate'=>['access'=> 'admin'], 'admin/user/change_email'=>['access'=> 'admin'], 'admin/user/delete'=>['access'=> 'admin'], 'admin/user/login_as'=>['access'=> 'admin'], 'admin/user/logout_as'=>[], 'admin/user/makeadmin'=>['access'=> 'admin'], 'admin/user/resetpassword'=>['access'=> 'admin'], 'admin/user/removeadmin'=>['access'=> 'admin'], 'admin/user/unban'=>['access'=> 'admin'], 'admin/user/validate'=>['access'=> 'admin'], 'annotation/delete'=>[], 'avatar/upload'=>[], 'comment/save'=>[], 'diagnostics/download'=>['access'=> 'admin'], 'entity/chooserestoredestination'=>[], 'entity/delete'=>[], 'entity/mute'=>[], 'entity/restore'=>[], 'entity/subscribe'=>[], 'entity/trash'=>[], 'entity/unmute'=>[], 'entity/unsubscribe'=>[], 'login'=>['access'=> 'logged_out'], 'logout'=>[], 'notifications/mute'=>['access'=> 'public'], 'plugins/settings/remove'=>['access'=> 'admin'], 'plugins/settings/save'=>['access'=> 'admin'], 'plugins/usersettings/save'=>[], 'register'=>['access'=> 'logged_out', 'middleware'=>[\Elgg\Router\Middleware\RegistrationAllowedGatekeeper::class,],], 'river/delete'=>[], 'settings/notifications'=>[], 'settings/notifications/subscriptions'=>[], 'user/changepassword'=>['access'=> 'public'], 'user/requestnewpassword'=>['access'=> 'public'], 'useradd'=>['access'=> 'admin'], 'usersettings/save'=>[], 'widgets/add'=>[], 'widgets/delete'=>[], 'widgets/move'=>[], 'widgets/save'=>[],]
Definition: actions.php:73
if(! $entity instanceof \ElggUser) $data
Definition: attributes.php:13
$user
Definition: ban.php:7
elgg_save_system_cache(string $type, $data, ?int $expire_after=null)
Elgg cache Cache file interface for caching data.
Definition: cache.php:16
elgg_load_system_cache(string $type)
Retrieve the contents of a system cache.
Definition: cache.php:27
Provides locale related features.
ensureTranslationsLoaded(string $language)
Make sure translations are loaded.
Definition: Translator.php:636
getLanguagePaths()
Returns a unique array with locations of translation files.
Definition: Translator.php:625
setCurrentLanguage(?string $language=null)
Sets current system language.
Definition: Translator.php:208
__construct(protected Config $config, protected LocaleService $locale)
Constructor.
Definition: Translator.php:52
languageKeyExists(string $key, string $language='en')
Check if a given language key exists.
Definition: Translator.php:510
detectLanguage()
Detect the current system/user language or false.
Definition: Translator.php:219
getCurrentLanguage()
Get the current system/user language or 'en'.
Definition: Translator.php:189
translate(string $message_key, array $args=[], string $language='')
Given a message key, returns an appropriately translated full-text string.
Definition: Translator.php:77
getAllowedLanguages()
Returns an array of allowed languages as configured by the site admin.
Definition: Translator.php:569
getAvailableLanguages()
Returns an array of all available language keys.
Definition: Translator.php:530
reloadAllTranslations()
Reload all translations from all registered paths.
Definition: Translator.php:399
registerTranslations(string $path, bool $load_all=false, ?string $language=null)
When given a full path, finds translation files and loads them.
Definition: Translator.php:317
getLoadedTranslations()
Get a map of all loaded translations.
Definition: Translator.php:63
registerLanguagePath(string $path)
Registers a path for potential translation files.
Definition: Translator.php:606
addTranslation(string $country_code, array $language_array, bool $ensure_translations_loaded=true)
Add a translation.
Definition: Translator.php:162
bootTranslations()
Ensures all needed translations are loaded.
Definition: Translator.php:269
loadTranslations(string $language)
Load both core and plugin translations.
Definition: Translator.php:290
getInstalledTranslations(bool $calculate_completeness=false)
Return an array of installed translations as an associative array "two letter code" => "native langua...
Definition: Translator.php:423
includeLanguageFile(string $path)
Load cached or include a language file by its path.
Definition: Translator.php:377
getLanguageCompleteness(string $language)
Return the level of completeness for a given language code (compared to english)
Definition: Translator.php:459
getMissingLanguageKeys(string $language)
Return the translation keys missing from a given language, or those that are identical to the english...
Definition: Translator.php:486
Allow executing scripts without $this context or local vars.
Definition: Includer.php:10
Find Elgg and project paths.
Definition: Paths.php:8
Functions for use as event handlers or other situations where you need a globally accessible callable...
Definition: Values.php:13
$config
Advanced site settings, debugging section.
Definition: debugging.php:6
_elgg_services()
Get the global service provider.
Definition: elgglib.php:353
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:256
$value
Definition: generic.php:51
$args
Some servers don't allow PHP to check the rewrite, so try via AJAX.
foreach(array_keys($combine_languages) as $language) $translations
$site_language
$path
Definition: details.php:70
$lang
Definition: html.php:13
if($container instanceof ElggGroup && $container->guid !=elgg_get_page_owner_guid()) $key
Definition: summary.php:44
if(parse_url(elgg_get_site_url(), PHP_URL_PATH) !=='/') if(file_exists(elgg_get_root_path() . 'robots.txt'))
Set robots.txt.
Definition: robots.php:10
elgg_set_cookie(\ElggCookie $cookie)
Set a cookie, but allow plugins to customize it first.
Definition: sessions.php:65
$languages