1 | <?php |
||
2 | |||
3 | namespace Elgg\I18n; |
||
4 | |||
5 | use Elgg\Config; |
||
6 | use Elgg\Includer; |
||
7 | |||
8 | /** |
||
9 | * Translator |
||
10 | * |
||
11 | * Use elgg()->translator |
||
12 | * |
||
13 | * @since 1.10.0 |
||
14 | */ |
||
15 | class Translator { |
||
16 | |||
17 | /** |
||
18 | * @var Config |
||
19 | */ |
||
20 | private $config; |
||
21 | |||
22 | /** |
||
23 | * @var LocaleService |
||
24 | */ |
||
25 | private $localeService; |
||
26 | |||
27 | /** |
||
28 | * @var array |
||
29 | */ |
||
30 | private $translations = []; |
||
31 | |||
32 | /** |
||
33 | * @var string |
||
34 | */ |
||
35 | private $defaultPath = null; |
||
36 | |||
37 | /** |
||
38 | * @var string |
||
39 | */ |
||
40 | private $current_language = null; |
||
41 | |||
42 | /** |
||
43 | * Paths to scan for autoloading languages. |
||
44 | * |
||
45 | * Languages are automatically loaded for the site or |
||
46 | * user's default language. Plugins can extend or override strings. |
||
47 | * language_paths is an array of paths to scan for PHP files matching |
||
48 | * the default language. The order of paths is determined by the plugin load order, |
||
49 | * with later entries overriding earlier. Language files within these paths are |
||
50 | * named as the two-letter ISO 639-1 country codes for the language they represent. |
||
51 | * |
||
52 | * @link http://en.wikipedia.org/wiki/ISO_639-1 |
||
53 | * |
||
54 | * @var array (paths are keys) |
||
55 | */ |
||
56 | private $language_paths = []; |
||
57 | |||
58 | /** |
||
59 | * @var bool |
||
60 | */ |
||
61 | private $was_reloaded = false; |
||
62 | |||
63 | /** |
||
64 | * Constructor |
||
65 | * |
||
66 | * @param Config $config Elgg config |
||
67 | * @param LocaleService $localService locale service |
||
68 | */ |
||
69 | 4999 | public function __construct(Config $config, LocaleService $localService) { |
|
70 | 4999 | $this->config = $config; |
|
71 | 4999 | $this->localeService = $localService; |
|
72 | |||
73 | 4999 | $this->defaultPath = dirname(dirname(dirname(dirname(__DIR__)))) . "/languages/"; |
|
74 | |||
75 | 4999 | $this->registerLanguagePath($this->defaultPath); |
|
76 | 4999 | } |
|
77 | |||
78 | /** |
||
79 | * Get a map of all loaded translations |
||
80 | * |
||
81 | * @return array |
||
82 | */ |
||
83 | 781 | public function getLoadedTranslations() { |
|
84 | 781 | return $this->translations; |
|
85 | } |
||
86 | |||
87 | /** |
||
88 | * Given a message key, returns an appropriately translated full-text string |
||
89 | * |
||
90 | * @param string $message_key The short message code |
||
91 | * @param array $args An array of arguments to pass through vsprintf(). |
||
92 | * @param string $language Optionally, the standard language code |
||
93 | * (defaults to site/user default, then English) |
||
94 | * |
||
95 | * @return string Either the translated string, the English string, |
||
96 | * or the original language string. |
||
97 | */ |
||
98 | 1117 | public function translate($message_key, array $args = [], $language = "") { |
|
99 | 1117 | if (!is_string($message_key) || strlen($message_key) < 1) { |
|
100 | 1 | _elgg_services()->logger->warning( |
|
101 | 1 | '$message_key needs to be a string in ' . __METHOD__ . '(), ' . gettype($message_key) . ' provided' |
|
102 | ); |
||
103 | 1 | return ''; |
|
104 | } |
||
105 | |||
106 | 1117 | if (!$language) { |
|
107 | // no language provided, get current language |
||
108 | // based on detection, user setting or site |
||
109 | 1100 | $language = $this->getCurrentLanguage(); |
|
110 | } |
||
111 | |||
112 | 1117 | $this->ensureTranslationsLoaded($language); |
|
113 | |||
114 | // build language array for different trys |
||
115 | // avoid dupes without overhead of array_unique |
||
116 | $langs = [ |
||
117 | 1117 | $language => true, |
|
118 | ]; |
||
119 | |||
120 | // load site language |
||
121 | 1117 | $site_language = $this->config->language; |
|
122 | 1117 | if (!empty($site_language)) { |
|
123 | 1117 | $this->ensureTranslationsLoaded($site_language); |
|
124 | |||
125 | 1117 | $langs[$site_language] = true; |
|
126 | } |
||
127 | |||
128 | // ultimate language fallback |
||
129 | 1117 | $langs['en'] = true; |
|
130 | |||
131 | // try to translate |
||
132 | 1117 | $logger = _elgg_services()->logger; |
|
133 | 1117 | $string = $message_key; |
|
134 | 1117 | foreach (array_keys($langs) as $try_lang) { |
|
135 | 1117 | if (isset($this->translations[$try_lang][$message_key])) { |
|
136 | 946 | $string = $this->translations[$try_lang][$message_key]; |
|
137 | |||
138 | // only pass through if we have arguments to allow backward compatibility |
||
139 | // with manual sprintf() calls. |
||
140 | 946 | if (!empty($args)) { |
|
141 | 104 | $string = vsprintf($string, $args); |
|
142 | } |
||
143 | |||
144 | 946 | break; |
|
145 | } else { |
||
146 | 417 | $message = sprintf( |
|
147 | 417 | 'Missing %s translation for "%s" language key', |
|
148 | 417 | ($try_lang === 'en') ? 'English' : $try_lang, |
|
149 | 417 | $message_key |
|
150 | ); |
||
151 | |||
152 | 417 | if ($try_lang === 'en') { |
|
153 | 293 | $logger->notice($message); |
|
154 | } else { |
||
155 | 417 | $logger->info($message); |
|
156 | } |
||
157 | } |
||
158 | } |
||
159 | |||
160 | 1117 | return $string; |
|
161 | } |
||
162 | |||
163 | /** |
||
164 | * Add a translation. |
||
165 | * |
||
166 | * Translations are arrays in the Zend Translation array format, eg: |
||
167 | * |
||
168 | * $english = array('message1' => 'message1', 'message2' => 'message2'); |
||
169 | * $german = array('message1' => 'Nachricht1','message2' => 'Nachricht2'); |
||
170 | * |
||
171 | * @param string $country_code Standard country code (eg 'en', 'nl', 'es') |
||
172 | * @param array $language_array Formatted array of strings |
||
173 | * @param bool $ensure_translations_loaded Ensures translations are loaded before adding the language array (default: true) |
||
174 | * |
||
175 | * @return bool Depending on success |
||
176 | */ |
||
177 | 5460 | public function addTranslation($country_code, $language_array, $ensure_translations_loaded = true) { |
|
178 | 5460 | $country_code = strtolower($country_code); |
|
179 | 5460 | $country_code = trim($country_code); |
|
180 | |||
181 | 5460 | if (!is_array($language_array) || empty($language_array) || $country_code === "") { |
|
182 | 1 | return false; |
|
183 | } |
||
184 | |||
185 | 5460 | if (!isset($this->translations[$country_code])) { |
|
186 | 5000 | $this->translations[$country_code] = []; |
|
187 | |||
188 | 5000 | if ($ensure_translations_loaded) { |
|
189 | // make sure all existing paths are included first before adding language arrays |
||
190 | 4937 | $this->loadTranslations($country_code); |
|
191 | } |
||
192 | } |
||
193 | |||
194 | // Note that we are using union operator instead of array_merge() due to performance implications |
||
195 | 5460 | $this->translations[$country_code] = $language_array + $this->translations[$country_code]; |
|
196 | |||
197 | 5460 | return true; |
|
198 | } |
||
199 | |||
200 | /** |
||
201 | * Get the current system/user language or "en". |
||
202 | * |
||
203 | * @return string The language code for the site/user or "en" if not set |
||
204 | */ |
||
205 | 5936 | public function getCurrentLanguage() { |
|
206 | 5936 | if (!isset($this->current_language)) { |
|
207 | 5041 | $this->current_language = $this->detectLanguage(); |
|
208 | } |
||
209 | |||
210 | 5936 | if (!$this->current_language) { |
|
211 | $this->current_language = 'en'; |
||
212 | } |
||
213 | |||
214 | 5936 | return $this->current_language; |
|
215 | } |
||
216 | |||
217 | /** |
||
218 | * Sets current system language |
||
219 | * |
||
220 | * @param string $language Language code |
||
221 | * |
||
222 | * @return void |
||
223 | */ |
||
224 | 1350 | public function setCurrentLanguage($language = null) { |
|
225 | 1350 | $this->current_language = $language; |
|
226 | 1350 | } |
|
227 | |||
228 | /** |
||
229 | * Detect the current system/user language or false. |
||
230 | * |
||
231 | * @return false|string The language code (eg "en") or false if not set |
||
232 | * |
||
233 | * @internal |
||
234 | */ |
||
235 | 5041 | public function detectLanguage() { |
|
236 | // detect from URL |
||
237 | 5041 | $url_lang = _elgg_services()->request->getParam('hl'); |
|
238 | 5041 | if (!empty($url_lang)) { |
|
239 | 2 | return $url_lang; |
|
240 | } |
||
241 | |||
242 | // check logged in user |
||
243 | 5041 | $user = _elgg_services()->session->getLoggedInUser(); |
|
244 | 5041 | if (!empty($user) && !empty($user->language)) { |
|
245 | 1 | return $user->language; |
|
246 | } |
||
247 | |||
248 | // get site setting |
||
249 | 5041 | $site_language = $this->config->language; |
|
250 | 5041 | if (!empty($site_language)) { |
|
251 | 5041 | return $site_language; |
|
252 | } |
||
253 | |||
254 | return false; |
||
255 | } |
||
256 | |||
257 | /** |
||
258 | * Ensures all needed translations are loaded |
||
259 | * |
||
260 | * This loads only English and the language of the logged in user. |
||
261 | * |
||
262 | * @return void |
||
263 | * |
||
264 | * @internal |
||
265 | */ |
||
266 | 5459 | public function bootTranslations() { |
|
267 | 5459 | $languages = array_unique(['en', $this->getCurrentLanguage()]); |
|
268 | |||
269 | 5459 | foreach ($languages as $language) { |
|
270 | 5459 | $this->loadTranslations($language); |
|
271 | } |
||
272 | 5459 | } |
|
273 | |||
274 | /** |
||
275 | * Load both core and plugin translations |
||
276 | * |
||
277 | * The $language argument can be used to load translations |
||
278 | * on-demand in case we need to translate something to a language not |
||
279 | * loaded by default for the current request. |
||
280 | * |
||
281 | * @param string $language Language code |
||
282 | * |
||
283 | * @return void |
||
284 | * |
||
285 | * @internal |
||
286 | */ |
||
287 | 5460 | public function loadTranslations($language) { |
|
288 | 5460 | if (!is_string($language)) { |
|
289 | 6 | return; |
|
290 | } |
||
291 | |||
292 | 5460 | $data = elgg_load_system_cache("{$language}.lang"); |
|
293 | 5460 | if (is_array($data)) { |
|
294 | 186 | $this->addTranslation($language, $data, false); |
|
295 | 186 | return; |
|
296 | } |
||
297 | |||
298 | 5393 | foreach ($this->getLanguagePaths() as $path) { |
|
299 | 5393 | $this->registerTranslations($path, false, $language); |
|
300 | } |
||
301 | |||
302 | 5393 | $translations = elgg_extract($language, $this->translations, []); |
|
303 | 5393 | elgg_save_system_cache("{$language}.lang", $translations); |
|
304 | 5393 | } |
|
305 | |||
306 | /** |
||
307 | * When given a full path, finds translation files and loads them |
||
308 | * |
||
309 | * @param string $path Full path |
||
310 | * @param bool $load_all If true all languages are loaded, if |
||
311 | * false only the current language + en are loaded |
||
312 | * @param string $language Language code |
||
313 | * |
||
314 | * @return bool success |
||
315 | * |
||
316 | * @internal |
||
317 | */ |
||
318 | 5400 | public function registerTranslations($path, $load_all = false, $language = null) { |
|
319 | 5400 | $path = \Elgg\Project\Paths::sanitize($path); |
|
320 | |||
321 | // don't need to register translations as the folder is missing |
||
322 | 5400 | if (!is_dir($path)) { |
|
323 | _elgg_services()->logger->info("No translations could be loaded from: $path"); |
||
324 | return true; |
||
325 | } |
||
326 | |||
327 | // Make a note of this path just in case we need to register this language later |
||
328 | 5400 | $this->registerLanguagePath($path); |
|
329 | |||
330 | 5400 | _elgg_services()->logger->info("Translations loaded from: $path"); |
|
331 | |||
332 | 5400 | if ($language) { |
|
333 | 5393 | $load_language_files = ["$language.php"]; |
|
334 | 5393 | $load_all = false; |
|
335 | } else { |
||
336 | // Get the current language based on site defaults and user preference |
||
337 | 23 | $current_language = $this->getCurrentLanguage(); |
|
338 | |||
339 | $load_language_files = [ |
||
340 | 23 | 'en.php', |
|
341 | 23 | "$current_language.php" |
|
342 | ]; |
||
343 | |||
344 | 23 | $load_language_files = array_unique($load_language_files); |
|
345 | } |
||
346 | |||
347 | 5400 | $return = true; |
|
348 | 5400 | if ($handle = opendir($path)) { |
|
349 | 5400 | while (false !== ($language_file = readdir($handle))) { |
|
350 | // ignore bad files |
||
351 | 5400 | if (substr($language_file, 0, 1) == '.' || substr($language_file, -4) !== '.php') { |
|
352 | 5400 | continue; |
|
353 | } |
||
354 | |||
355 | 5400 | if (in_array($language_file, $load_language_files) || $load_all) { |
|
356 | 5400 | $return = $return && $this->includeLanguageFile($path . $language_file); |
|
357 | } |
||
358 | } |
||
359 | 5400 | closedir($handle); |
|
360 | } else { |
||
361 | _elgg_services()->logger->error("Could not open language path: $path"); |
||
362 | $return = false; |
||
363 | } |
||
364 | |||
365 | 5400 | return $return; |
|
366 | } |
||
367 | |||
368 | /** |
||
369 | * Load cached or include a language file by its path |
||
370 | * |
||
371 | * @param string $path Path to file |
||
372 | * @return bool |
||
373 | * |
||
374 | * @internal |
||
375 | */ |
||
376 | 37 | protected function includeLanguageFile($path) { |
|
377 | 37 | $result = Includer::includeFile($path); |
|
378 | |||
379 | 37 | if (is_array($result)) { |
|
380 | 37 | $this->addTranslation(basename($path, '.php'), $result); |
|
381 | 37 | return true; |
|
382 | } |
||
383 | |||
384 | _elgg_services()->logger->warning("Language file did not return an array: $path"); |
||
385 | |||
386 | return false; |
||
387 | } |
||
388 | |||
389 | /** |
||
390 | * Reload all translations from all registered paths. |
||
391 | * |
||
392 | * This is only called by functions which need to know all possible translations. |
||
393 | * |
||
394 | * @todo Better on demand loading based on language_paths array |
||
395 | * |
||
396 | * @return void |
||
397 | * |
||
398 | * @internal |
||
399 | */ |
||
400 | 784 | public function reloadAllTranslations() { |
|
401 | 784 | if ($this->was_reloaded) { |
|
402 | 364 | return; |
|
403 | } |
||
404 | |||
405 | 781 | $languages = $this->getAvailableLanguages(); |
|
406 | |||
407 | 781 | foreach ($languages as $language) { |
|
408 | 781 | $this->ensureTranslationsLoaded($language); |
|
409 | } |
||
410 | |||
411 | 781 | _elgg_services()->events->triggerAfter('reload', 'translations'); |
|
412 | |||
413 | 781 | $this->was_reloaded = true; |
|
414 | 781 | } |
|
415 | |||
416 | /** |
||
417 | * Return an array of installed translations as an associative |
||
418 | * array "two letter code" => "native language name". |
||
419 | * |
||
420 | * @param boolean $calculate_completeness Set to true if you want a completeness postfix added to the language text |
||
421 | * |
||
422 | * @return array |
||
423 | */ |
||
424 | 394 | public function getInstalledTranslations($calculate_completeness = false) { |
|
425 | 394 | if ($calculate_completeness) { |
|
426 | // Ensure that all possible translations are loaded |
||
427 | 4 | $this->reloadAllTranslations(); |
|
428 | } |
||
429 | |||
430 | 394 | $result = []; |
|
431 | |||
432 | 394 | $languages = $this->getAvailableLanguages(); |
|
433 | 394 | foreach ($languages as $language) { |
|
434 | 394 | if ($this->languageKeyExists($language, $language)) { |
|
435 | 394 | $value = $this->translate($language, [], $language); |
|
436 | } else { |
||
437 | 361 | $value = $this->translate($language); |
|
438 | } |
||
439 | |||
440 | 394 | if (($language !== 'en') && $calculate_completeness) { |
|
441 | 4 | $completeness = $this->getLanguageCompleteness($language); |
|
442 | 4 | $value .= " (" . $completeness . "% " . $this->translate('complete') . ")"; |
|
443 | } |
||
444 | |||
445 | 394 | $result[$language] = $value; |
|
446 | } |
||
447 | |||
448 | 394 | natcasesort($result); |
|
449 | |||
450 | 394 | return $result; |
|
451 | } |
||
452 | |||
453 | /** |
||
454 | * Return the level of completeness for a given language code (compared to english) |
||
455 | * |
||
456 | * @param string $language Language |
||
457 | * |
||
458 | * @return float |
||
459 | */ |
||
460 | 394 | public function getLanguageCompleteness($language) { |
|
461 | |||
462 | 394 | if ($language == 'en') { |
|
463 | 30 | return (float) 100; |
|
464 | } |
||
465 | |||
466 | // Ensure that all possible translations are loaded |
||
467 | 364 | $this->reloadAllTranslations(); |
|
468 | |||
469 | 364 | $en = count($this->translations['en']); |
|
470 | |||
471 | 364 | $missing = $this->getMissingLanguageKeys($language); |
|
472 | 364 | if ($missing) { |
|
473 | 364 | $missing = count($missing); |
|
474 | } else { |
||
475 | $missing = 0; |
||
476 | } |
||
477 | |||
478 | 364 | $lang = $en - $missing; |
|
479 | |||
480 | 364 | return round(($lang / $en) * 100, 2); |
|
481 | } |
||
482 | |||
483 | /** |
||
484 | * Return the translation keys missing from a given language, |
||
485 | * or those that are identical to the english version. |
||
486 | * |
||
487 | * @param string $language The language |
||
488 | * |
||
489 | * @return mixed |
||
490 | * |
||
491 | * @internal |
||
492 | */ |
||
493 | 364 | public function getMissingLanguageKeys($language) { |
|
494 | |||
495 | // Ensure that all possible translations are loaded |
||
496 | 364 | $this->reloadAllTranslations(); |
|
497 | |||
498 | 364 | $missing = []; |
|
499 | |||
500 | 364 | foreach ($this->translations['en'] as $k => $v) { |
|
501 | 364 | if ((!isset($this->translations[$language][$k])) |
|
502 | 364 | || ($this->translations[$language][$k] == $this->translations['en'][$k])) { |
|
503 | 364 | $missing[] = $k; |
|
504 | } |
||
505 | } |
||
506 | |||
507 | 364 | if (count($missing)) { |
|
508 | 364 | return $missing; |
|
509 | } |
||
510 | |||
511 | return false; |
||
512 | } |
||
513 | |||
514 | /** |
||
515 | * Check if a given language key exists |
||
516 | * |
||
517 | * @param string $key The translation key |
||
518 | * @param string $language The specific language to check |
||
519 | * |
||
520 | * @return bool |
||
521 | * @since 1.11 |
||
522 | */ |
||
523 | 607 | public function languageKeyExists($key, $language = 'en') { |
|
524 | 607 | if (empty($key)) { |
|
525 | return false; |
||
526 | } |
||
527 | |||
528 | 607 | $this->ensureTranslationsLoaded($language); |
|
529 | |||
530 | 607 | if (!array_key_exists($language, $this->translations)) { |
|
531 | 6 | return false; |
|
532 | } |
||
533 | |||
534 | 601 | return array_key_exists($key, $this->translations[$language]); |
|
535 | } |
||
536 | |||
537 | /** |
||
538 | * Returns an array of all available language keys. Triggers a hook to allow plugins to add/remove languages |
||
539 | * |
||
540 | * @return array |
||
541 | * @since 3.0 |
||
542 | */ |
||
543 | 784 | public function getAvailableLanguages() { |
|
544 | 784 | $languages = []; |
|
545 | |||
546 | 784 | $allowed_languages = $this->localeService->getLanguageCodes(); |
|
547 | |||
548 | 784 | foreach ($this->getLanguagePaths() as $path) { |
|
549 | try { |
||
550 | 784 | $iterator = new \DirectoryIterator($path); |
|
551 | } catch (\Exception $e) { |
||
552 | continue; |
||
553 | } |
||
554 | |||
555 | 784 | foreach ($iterator as $file) { |
|
556 | 784 | if ($file->isDir()) { |
|
557 | 784 | continue; |
|
558 | } |
||
559 | |||
560 | 784 | if ($file->getExtension() !== 'php') { |
|
561 | continue; |
||
562 | } |
||
563 | |||
564 | 784 | $language = $file->getBasename('.php'); |
|
565 | 784 | if (empty($language) || !in_array($language, $allowed_languages)) { |
|
566 | continue; |
||
567 | } |
||
568 | |||
569 | 784 | $languages[$language] = true; |
|
570 | } |
||
571 | } |
||
572 | |||
573 | 784 | $languages = array_keys($languages); |
|
574 | |||
575 | 784 | return _elgg_services()->hooks->trigger('languages', 'translations', [], $languages); |
|
576 | } |
||
577 | |||
578 | /** |
||
579 | * Registers a path for potential translation files |
||
580 | * |
||
581 | * @param string $path path to a folder that contains translation files |
||
582 | * |
||
583 | * @return void |
||
584 | * |
||
585 | * @internal |
||
586 | */ |
||
587 | 5473 | public function registerLanguagePath($path) { |
|
588 | 5473 | $this->language_paths[$path] = true; |
|
589 | 5473 | } |
|
590 | |||
591 | /** |
||
592 | * Returns a unique array with locations of translation files |
||
593 | * |
||
594 | * @return array |
||
595 | */ |
||
596 | 5393 | protected function getLanguagePaths() { |
|
597 | 5393 | return array_keys($this->language_paths); |
|
598 | } |
||
599 | |||
600 | /** |
||
601 | * Make sure translations are loaded |
||
602 | * |
||
603 | * @param string $language Language |
||
604 | * @return void |
||
605 | */ |
||
606 | 1542 | private function ensureTranslationsLoaded($language) { |
|
0 ignored issues
–
show
Coding Style
introduced
by
![]() |
|||
607 | 1542 | if (isset($this->translations[$language])) { |
|
608 | 1542 | return; |
|
609 | } |
||
610 | |||
611 | // The language being requested is not the same as the language of the |
||
612 | // logged in user, so we will have to load it separately. (Most likely |
||
613 | // we're sending a notification and the recipient is using a different |
||
614 | // language than the logged in user.) |
||
615 | 795 | $this->loadTranslations($language); |
|
616 | 795 | } |
|
617 | |||
618 | /** |
||
619 | * Returns an array of language codes. |
||
620 | * |
||
621 | * @return array |
||
622 | * @deprecated 3.0 please use elgg()->locale->getLanguageCodes() |
||
623 | */ |
||
624 | public static function getAllLanguageCodes() { |
||
625 | elgg_deprecated_notice(__METHOD__ . ' has been deprecated use elgg()->locale->getLanguageCodes()', '3.0'); |
||
626 | return elgg()->locale->getLanguageCodes(); |
||
627 | } |
||
628 | |||
629 | /** |
||
630 | * Normalize a language code (e.g. from Transifex) |
||
631 | * |
||
632 | * @param string $code Language code |
||
633 | * |
||
634 | * @return string |
||
635 | * |
||
636 | * @internal |
||
637 | */ |
||
638 | public static function normalizeLanguageCode($code) { |
||
639 | $code = strtolower($code); |
||
640 | $code = preg_replace('~[^a-z0-9]~', '_', $code); |
||
641 | return $code; |
||
642 | } |
||
643 | } |
||
644 |