Completed
Push — 3.1 ( d59679...4b8741 )
by Jeroen
62:38 queued 13s
created

engine/classes/Elgg/I18n/Translator.php (1 issue)

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
Private method name "Translator::ensureTranslationsLoaded" must be prefixed with an underscore
Loading history...
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