Completed
Push — 3.0 ( f21f65...29eeab )
by Jerome
150:15 queued 95:45
created

engine/classes/Elgg/I18n/Translator.php (2 issues)

Labels
Severity
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
	 * @access private
70
	 * @internal
71
	 */
72 4898
	public function __construct(Config $config, LocaleService $localService) {
73 4898
		$this->config = $config;
74 4898
		$this->localeService = $localService;
75
		
76 4898
		$this->defaultPath = dirname(dirname(dirname(dirname(__DIR__)))) . "/languages/";
77
		
78 4898
		$this->registerLanguagePath($this->defaultPath);
79 4898
	}
80
81
	/**
82
	 * Get a map of all loaded translations
83
	 *
84
	 * @return array
85
	 */
86 765
	public function getLoadedTranslations() {
87 765
		return $this->translations;
88
	}
89
90
	/**
91
	 * Given a message key, returns an appropriately translated full-text string
92
	 *
93
	 * @param string $message_key The short message code
94
	 * @param array  $args        An array of arguments to pass through vsprintf().
95
	 * @param string $language    Optionally, the standard language code
96
	 *                            (defaults to site/user default, then English)
97
	 *
98
	 * @return string Either the translated string, the English string,
99
	 * or the original language string.
100
	 */
101 1029
	public function translate($message_key, array $args = [], $language = "") {
102 1029
		if (!is_string($message_key) || strlen($message_key) < 1) {
103 1
			_elgg_services()->logger->warning(
104 1
				'$message_key needs to be a string in ' . __METHOD__ . '(), ' . gettype($message_key) . ' provided'
105
			);
106 1
			return '';
107
		}
108
109 1029
		if (!$language) {
110
			// no language provided, get current language
111
			// based on detection, user setting or site
112 1013
			$language = $this->getCurrentLanguage();
113
		}
114
115 1029
		$this->ensureTranslationsLoaded($language);
116
117
		// build language array for different trys
118
		// avoid dupes without overhead of array_unique
119
		$langs = [
120 1029
			$language => true,
121
		];
122
123
		// load site language
124 1029
		$site_language = $this->config->language;
125 1029
		if (!empty($site_language)) {
126 1029
			$this->ensureTranslationsLoaded($site_language);
127
128 1029
			$langs[$site_language] = true;
129
		}
130
131
		// ultimate language fallback
132 1029
		$langs['en'] = true;
133
134
		// try to translate
135 1029
		$logger = _elgg_services()->logger;
136 1029
		$string = $message_key;
137 1029
		foreach (array_keys($langs) as $try_lang) {
138 1029
			if (isset($this->translations[$try_lang][$message_key])) {
139 865
				$string = $this->translations[$try_lang][$message_key];
140
141
				// only pass through if we have arguments to allow backward compatibility
142
				// with manual sprintf() calls.
143 865
				if (!empty($args)) {
144 93
					$string = vsprintf($string, $args);
145
				}
146
147 865
				break;
148
			} else {
149 376
				$message = sprintf(
150 376
					'Missing %s translation for "%s" language key',
151 376
					($try_lang === 'en') ? 'English' : $try_lang,
152 376
					$message_key
153
				);
154
				
155 376
				if ($try_lang === 'en') {
156 258
					$logger->notice($message);
157
				} else {
158 376
					$logger->info($message);
159
				}
160
			}
161
		}
162
163 1029
		return $string;
164
	}
165
166
	/**
167
	 * Add a translation.
168
	 *
169
	 * Translations are arrays in the Zend Translation array format, eg:
170
	 *
171
	 *	$english = array('message1' => 'message1', 'message2' => 'message2');
172
	 *  $german = array('message1' => 'Nachricht1','message2' => 'Nachricht2');
173
	 *
174
	 * @param string $country_code               Standard country code (eg 'en', 'nl', 'es')
175
	 * @param array  $language_array             Formatted array of strings
176
	 * @param bool   $ensure_translations_loaded Ensures translations are loaded before adding the language array (default: true)
177
	 *
178
	 * @return bool Depending on success
179
	 */
180 5360
	public function addTranslation($country_code, $language_array, $ensure_translations_loaded = true) {
181 5360
		$country_code = strtolower($country_code);
182 5360
		$country_code = trim($country_code);
183
184 5360
		if (!is_array($language_array) || empty($language_array) || $country_code === "") {
185 1
			return false;
186
		}
187
188 5360
		if (!isset($this->translations[$country_code])) {
189 4899
			$this->translations[$country_code] = [];
190
			
191 4899
			if ($ensure_translations_loaded) {
192
				// make sure all existing paths are included first before adding language arrays
193 4875
				$this->loadTranslations($country_code);
194
			}
195
		}
196
197
		// Note that we are using union operator instead of array_merge() due to performance implications
198 5360
		$this->translations[$country_code] = $language_array + $this->translations[$country_code];
199
200 5360
		return true;
201
	}
202
203
	/**
204
	 * Get the current system/user language or "en".
205
	 *
206
	 * @return string The language code for the site/user or "en" if not set
207
	 */
208 5808
	public function getCurrentLanguage() {
209 5808
		if (!isset($this->current_language)) {
210 4940
			$this->current_language = $this->detectLanguage();
211
		}
212
213 5808
		if (!$this->current_language) {
214
			$this->current_language = 'en';
215
		}
216
217 5808
		return $this->current_language;
218
	}
219
220
	/**
221
	 * Sets current system language
222
	 *
223
	 * @param string $language Language code
224
	 *
225
	 * @return void
226
	 */
227 1292
	public function setCurrentLanguage($language = null) {
228 1292
		$this->current_language = $language;
229 1292
	}
230
231
	/**
232
	 * Detect the current system/user language or false.
233
	 *
234
	 * @return false|string The language code (eg "en") or false if not set
235
	 * @access private
236
	 * @internal
237
	 */
238 4940
	public function detectLanguage() {
239
		// detect from URL
240 4940
		$url_lang = _elgg_services()->request->getParam('hl');
241 4940
		if (!empty($url_lang)) {
242 2
			return $url_lang;
243
		}
244
245
		// check logged in user
246 4940
		$user = _elgg_services()->session->getLoggedInUser();
247 4940
		if (!empty($user) && !empty($user->language)) {
248 1
			return $user->language;
249
		}
250
251
		// get site setting
252 4940
		$site_language = $this->config->language;
253 4940
		if (!empty($site_language)) {
254 4940
			return $site_language;
255
		}
256
257
		return false;
258
	}
259
260
	/**
261
	 * Ensures all needed translations are loaded
262
	 *
263
	 * This loads only English and the language of the logged in user.
264
	 *
265
	 * @return void
266
	 *
267
	 * @access private
268
	 * @internal
269
	 */
270 5358
	public function bootTranslations() {
271 5358
		$languages = array_unique(['en', $this->getCurrentLanguage()]);
272
		
273 5358
		foreach ($languages as $language) {
274 5358
			$this->loadTranslations($language);
275
		}
276 5358
	}
277
278
	/**
279
	 * Load both core and plugin translations
280
	 *
281
	 * The $language argument can be used to load translations
282
	 * on-demand in case we need to translate something to a language not
283
	 * loaded by default for the current request.
284
	 *
285
	 * @param string $language Language code
286
	 *
287
	 * @return void
288
	 *
289
	 * @access private
290
	 * @internal
291
	 */
292 5360
	public function loadTranslations($language) {
293 5360
		if (!is_string($language)) {
294 6
			return;
295
		}
296
		
297 5360
		$data = elgg_load_system_cache("{$language}.lang");
298 5360
		if ($data) {
299 154
			$this->addTranslation($language, $data, false);
0 ignored issues
show
$data of type string is incompatible with the type array expected by parameter $language_array of Elgg\I18n\Translator::addTranslation(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

299
			$this->addTranslation($language, /** @scrutinizer ignore-type */ $data, false);
Loading history...
300 154
			return;
301
		}
302
		
303 5333
		foreach ($this->getLanguagePaths() as $path) {
304 5333
			$this->registerTranslations($path, false, $language);
305
		}
306
			
307 5333
		$translations = elgg_extract($language, $this->translations, []);
308 5333
		elgg_save_system_cache("{$language}.lang", $translations);
0 ignored issues
show
It seems like $translations can also be of type array; however, parameter $data of elgg_save_system_cache() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

308
		elgg_save_system_cache("{$language}.lang", /** @scrutinizer ignore-type */ $translations);
Loading history...
309 5333
	}
310
311
	/**
312
	 * When given a full path, finds translation files and loads them
313
	 *
314
	 * @param string $path     Full path
315
	 * @param bool   $load_all If true all languages are loaded, if
316
	 *                         false only the current language + en are loaded
317
	 * @param string $language Language code
318
	 *
319
	 * @return bool success
320
	 *
321
	 * @access private
322
	 * @internal
323
	 */
324 5339
	public function registerTranslations($path, $load_all = false, $language = null) {
325 5339
		$path = \Elgg\Project\Paths::sanitize($path);
326
		
327
		// don't need to register translations as the folder is missing
328 5339
		if (!is_dir($path)) {
329
			_elgg_services()->logger->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 5339
		$this->registerLanguagePath($path);
335
336 5339
		_elgg_services()->logger->info("Translations loaded from: $path");
337
338 5339
		if ($language) {
339 5333
			$load_language_files = ["$language.php"];
340 5333
			$load_all = false;
341
		} else {
342
			// Get the current language based on site defaults and user preference
343 21
			$current_language = $this->getCurrentLanguage();
344
345
			$load_language_files = [
346 21
				'en.php',
347 21
				"$current_language.php"
348
			];
349
350 21
			$load_language_files = array_unique($load_language_files);
351
		}
352
353 5339
		$return = true;
354 5339
		if ($handle = opendir($path)) {
355 5339
			while (false !== ($language_file = readdir($handle))) {
356
				// ignore bad files
357 5339
				if (substr($language_file, 0, 1) == '.' || substr($language_file, -4) !== '.php') {
358 5339
					continue;
359
				}
360
361 5339
				if (in_array($language_file, $load_language_files) || $load_all) {
362 5339
					$return = $return && $this->includeLanguageFile($path . $language_file);
363
				}
364
			}
365 5339
			closedir($handle);
366
		} else {
367
			_elgg_services()->logger->error("Could not open language path: $path");
368
			$return = false;
369
		}
370
371 5339
		return $return;
372
	}
373
374
	/**
375
	 * Load cached or include a language file by its path
376
	 *
377
	 * @param string $path Path to file
378
	 * @return bool
379
	 * @access private
380
	 * @internal
381
	 */
382 34
	protected function includeLanguageFile($path) {
383 34
		$result = Includer::includeFile($path);
384
		
385 34
		if (is_array($result)) {
386 34
			$this->addTranslation(basename($path, '.php'), $result);
387 34
			return true;
388
		}
389
390
		return false;
391
	}
392
393
	/**
394
	 * Reload all translations from all registered paths.
395
	 *
396
	 * This is only called by functions which need to know all possible translations.
397
	 *
398
	 * @todo Better on demand loading based on language_paths array
399
	 *
400
	 * @return void
401
	 * @access private
402
	 * @internal
403
	 */
404 764
	public function reloadAllTranslations() {
405 764
		if ($this->was_reloaded) {
406 352
			return;
407
		}
408
409 764
		$languages = $this->getAvailableLanguages();
410
		
411 764
		foreach ($languages as $language) {
412 764
			$this->ensureTranslationsLoaded($language);
413
		}
414
		
415 764
		_elgg_services()->events->triggerAfter('reload', 'translations');
416
417 764
		$this->was_reloaded = true;
418 764
	}
419
420
	/**
421
	 * Return an array of installed translations as an associative
422
	 * array "two letter code" => "native language name".
423
	 *
424
	 * @param boolean $calculate_completeness Set to true if you want a completeness postfix added to the language text
425
	 *
426
	 * @return array
427
	 */
428 382
	public function getInstalledTranslations($calculate_completeness = false) {
429 382
		if ($calculate_completeness) {
430
			// Ensure that all possible translations are loaded
431
			$this->reloadAllTranslations();
432
		}
433
		
434 382
		$result = [];
435
436 382
		$languages = $this->getAvailableLanguages();
437 382
		foreach ($languages as $language) {
438 382
			if ($this->languageKeyExists($language, $language)) {
439 382
				$value = $this->translate($language, [], $language);
440
			} else {
441 346
				$value = $this->translate($language);
442
			}
443
			
444 382
			if (($language !== 'en') && $calculate_completeness) {
445
				$completeness = $this->getLanguageCompleteness($language);
446
				$value .= " (" . $completeness . "% " . $this->translate('complete') . ")";
447
			}
448
			
449 382
			$result[$language] = $value;
450
		}
451
		
452 382
		natcasesort($result);
453
			
454 382
		return $result;
455
	}
456
457
	/**
458
	 * Return the level of completeness for a given language code (compared to english)
459
	 *
460
	 * @param string $language Language
461
	 *
462
	 * @return float
463
	 */
464 382
	public function getLanguageCompleteness($language) {
465
466 382
		if ($language == 'en') {
467 30
			return (float) 100;
468
		}
469
470
		// Ensure that all possible translations are loaded
471 352
		$this->reloadAllTranslations();
472
473 352
		$en = count($this->translations['en']);
474
475 352
		$missing = $this->getMissingLanguageKeys($language);
476 352
		if ($missing) {
477 352
			$missing = count($missing);
478
		} else {
479
			$missing = 0;
480
		}
481
482 352
		$lang = $en - $missing;
483
484 352
		return round(($lang / $en) * 100, 2);
485
	}
486
487
	/**
488
	 * Return the translation keys missing from a given language,
489
	 * or those that are identical to the english version.
490
	 *
491
	 * @param string $language The language
492
	 *
493
	 * @return mixed
494
	 * @access private
495
	 * @internal
496
	 */
497 352
	public function getMissingLanguageKeys($language) {
498
499
		// Ensure that all possible translations are loaded
500 352
		$this->reloadAllTranslations();
501
502 352
		$missing = [];
503
504 352
		foreach ($this->translations['en'] as $k => $v) {
505 352
			if ((!isset($this->translations[$language][$k]))
506 352
				|| ($this->translations[$language][$k] == $this->translations['en'][$k])) {
507 352
				$missing[] = $k;
508
			}
509
		}
510
511 352
		if (count($missing)) {
512 352
			return $missing;
513
		}
514
515
		return false;
516
	}
517
518
	/**
519
	 * Check if a given language key exists
520
	 *
521
	 * @param string $key      The translation key
522
	 * @param string $language The specific language to check
523
	 *
524
	 * @return bool
525
	 * @since 1.11
526
	 */
527 547
	public function languageKeyExists($key, $language = 'en') {
528 547
		if (empty($key)) {
529
			return false;
530
		}
531
532 547
		$this->ensureTranslationsLoaded($language);
533
534 547
		if (!array_key_exists($language, $this->translations)) {
535 6
			return false;
536
		}
537
538 541
		return array_key_exists($key, $this->translations[$language]);
539
	}
540
	
541
	/**
542
	 * Returns an array of all available language keys. Triggers a hook to allow plugins to add/remove languages
543
	 *
544
	 * @return array
545
	 * @since 3.0
546
	 */
547 764
	public function getAvailableLanguages() {
548 764
		$languages = [];
549
		
550 764
		$allowed_languages = $this->localeService->getLanguageCodes();
551
		
552 764
		foreach ($this->getLanguagePaths() as $path) {
553
			try {
554 764
				$iterator = new \DirectoryIterator($path);
555
			} catch (\Exception $e) {
556
				continue;
557
			}
558
			
559 764
			foreach ($iterator as $file) {
560 764
				if ($file->isDir()) {
561 764
					continue;
562
				}
563
				
564 764
				if ($file->getExtension() !== 'php') {
565
					continue;
566
				}
567
				
568 764
				$language = $file->getBasename('.php');
569 764
				if (empty($language) || !in_array($language, $allowed_languages)) {
570
					continue;
571
				}
572
				
573 764
				$languages[$language] = true;
574
			}
575
		}
576
		
577 764
		$languages = array_keys($languages);
578
				
579 764
		return _elgg_services()->hooks->trigger('languages', 'translations', [], $languages);
580
	}
581
	
582
	/**
583
	 * Registers a path for potential translation files
584
	 *
585
	 * @param string $path path to a folder that contains translation files
586
	 *
587
	 * @return void
588
	 * @access private
589
	 * @internal
590
	 */
591 5378
	public function registerLanguagePath($path) {
592 5378
		$this->language_paths[$path] = true;
593 5378
	}
594
	
595
	/**
596
	 * Returns a unique array with locations of translation files
597
	 *
598
	 * @return array
599
	 * @access private
600
	 * @internal
601
	 */
602 5333
	protected function getLanguagePaths() {
603 5333
		return array_keys($this->language_paths);
604
	}
605
606
	/**
607
	 * Make sure translations are loaded
608
	 *
609
	 * @param string $language Language
610
	 * @return void
611
	 * @access private
612
	 * @internal
613
	 */
614 1441
	private function ensureTranslationsLoaded($language) {
615 1441
		if (isset($this->translations[$language])) {
616 1441
			return;
617
		}
618
		
619
		// The language being requested is not the same as the language of the
620
		// logged in user, so we will have to load it separately. (Most likely
621
		// we're sending a notification and the recipient is using a different
622
		// language than the logged in user.)
623 773
		$this->loadTranslations($language);
624 773
	}
625
626
	/**
627
	 * Returns an array of language codes.
628
	 *
629
	 * @return array
630
	 * @deprecated 3.0 please use elgg()->locale->getLanguageCodes()
631
	 */
632
	public static function getAllLanguageCodes() {
633
		elgg_deprecated_notice(__METHOD__ . ' has been deprecated use elgg()->locale->getLanguageCodes()', '3.0');
634
		return elgg()->locale->getLanguageCodes();
635
	}
636
637
	/**
638
	 * Normalize a language code (e.g. from Transifex)
639
	 *
640
	 * @param string $code Language code
641
	 *
642
	 * @return string
643
	 * @access private
644
	 * @internal
645
	 */
646
	public static function normalizeLanguageCode($code) {
647
		$code = strtolower($code);
648
		$code = preg_replace('~[^a-z0-9]~', '_', $code);
649
		return $code;
650
	}
651
}
652