Translator   F
last analyzed

Complexity

Total Complexity 84

Size/Duplication

Total Lines 630
Duplicated Lines 0 %

Test Coverage

Coverage 91.12%

Importance

Changes 0
Metric Value
eloc 205
c 0
b 0
f 0
dl 0
loc 630
ccs 195
cts 214
cp 0.9112
rs 2
wmc 84

21 Methods

Rating   Name   Duplication   Size   Complexity  
A setCurrentLanguage() 0 2 1
A bootTranslations() 0 5 2
A registerLanguagePath() 0 10 3
B detectLanguage() 0 39 8
A getMissingLanguageKeys() 0 13 4
A getCurrentLanguage() 0 10 3
B registerTranslations() 0 50 10
B translate() 0 69 11
A getLanguagePaths() 0 2 1
A getAllowedLanguages() 0 26 5
A reloadAllTranslations() 0 14 3
A languageKeyExists() 0 12 3
A addTranslation() 0 20 5
A ensureTranslationsLoaded() 0 10 2
A getLoadedTranslations() 0 2 1
A loadTranslations() 0 13 3
A getInstalledTranslations() 0 27 6
A includeLanguageFile() 0 11 2
B getAvailableLanguages() 0 31 8
A getLanguageCompleteness() 0 15 2
A __construct() 0 4 1

How to fix   Complexity   

Complex Class

Complex classes like Translator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Translator, and based on these observations, apply Extract Interface, too.

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