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;
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
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 
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 {
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 
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 
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 
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 }
static includeFile($file)
Include a file with as little context as possible.
Definition: Includer.php:18
setCurrentLanguage(string $language=null)
Sets current system language.
Definition: Translator.php:208
addTranslation(string $country_code, array $language_array, bool $ensure_translations_loaded=true)
Add a translation.
Definition: Translator.php:162
getLanguageCompleteness(string $language)
Return the level of completeness for a given language code (compared to english)
Definition: Translator.php:459
loadTranslations(string $language)
Load both core and plugin translations.
Definition: Translator.php:290
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:317
getAvailableLanguages()
Returns an array of all available language keys.
Definition: Translator.php:530
$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:77
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
$config
Advanced site settings, debugging section.
Definition: debugging.php:6
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:606
trait Loggable
Enables adding a logger.
Definition: Loggable.php:14
detectLanguage()
Detect the current system/user language or false.
Definition: Translator.php:219
reloadAllTranslations()
Reload all translations from all registered paths.
Definition: Translator.php:399
$language
Definition: useradd.php:17
languageKeyExists(string $key, string $language= 'en')
Check if a given language key exists.
Definition: Translator.php:510
getLanguagePaths()
Returns a unique array with locations of translation files.
Definition: Translator.php:625
$lang
Definition: html.php:13
getLoadedTranslations()
Get a map of all loaded translations.
Definition: Translator.php:63
$user
Definition: ban.php:7
bootTranslations()
Ensures all needed translations are loaded.
Definition: Translator.php:269
getCurrentLanguage()
Get the current system/user language or &#39;en&#39;.
Definition: Translator.php:189
elgg_save_system_cache(string $type, $data, int $expire_after=null)
Elgg cache Cache file interface for caching data.
Definition: cache.php:16
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:569
includeLanguageFile(string $path)
Load cached or include a language file by its path.
Definition: Translator.php:377
ensureTranslationsLoaded(string $language)
Make sure translations are loaded.
Definition: Translator.php:636
elgg_load_system_cache(string $type)
Retrieve the contents of a system cache.
Definition: cache.php:27
getMissingLanguageKeys(string $language)
Return the translation keys missing from a given language, or those that are identical to the english...
Definition: Translator.php:486
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:351
$languages
__construct(protected Config $config, protected LocaleService $locale)
Constructor.
Definition: Translator.php:52
getInstalledTranslations(bool $calculate_completeness=false)
Return an array of installed translations as an associative array "two letter code" => "native langua...
Definition: Translator.php:423