Passed
Push — master ( c0a3a7...3b84a4 )
by Jeroen
58:51
created

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

1
<?php
2
3
namespace Elgg\I18n;
4
5
use Elgg\Config;
6
7
/**
8
 * WARNING: API IN FLUX. DO NOT USE DIRECTLY.
9
 *
10
 * @access private
11
 *
12
 * @since 1.10.0
13
 */
14
class Translator {
15
16
	/**
17
	 * @var Config
18
	 */
19
	private $config;
20
21
	/**
22
	 * @var array
23
	 */
24
	private $translations = [];
25
26
	/**
27
	 * @var bool
28
	 */
29
	private $is_initialized = false;
30
31
	/**
32
	 * @var string
33
	 */
34
	private $defaultPath = null;
35
36
	/**
37
	 * @var string
38
	 */
39
	private $current_language = null;
40
41
	/**
42
	 * Paths to scan for autoloading languages.
43
	 *
44
	 * Languages are automatically loaded for the site or
45
	 * user's default language.  Plugins can extend or override strings.
46
	 * language_paths is an array of paths to scan for PHP files matching
47
	 * the default language.  The order of paths is determined by the plugin load order,
48
	 * with later entries overriding earlier.  Language files within these paths are
49
	 * named as the two-letter ISO 639-1 country codes for the language they represent.
50
	 *
51
	 * @link http://en.wikipedia.org/wiki/ISO_639-1
52
	 *
53
	 * @var array (paths are keys)
54
	 */
55
	private $language_paths = [];
56
57
	/**
58
	 * @var bool
59
	 */
60
	private $was_reloaded = false;
61
62
	/**
63
	 * @var bool
64
	 */
65
	private $loaded_from_cache = false;
66
67
	/**
68
	 * Constructor
69
	 *
70
	 * @param Config $config Elgg config
71
	 */
72 4417
	public function __construct(Config $config) {
73 4417
		$this->config = $config;
74 4417
		$this->defaultPath = dirname(dirname(dirname(dirname(__DIR__)))) . "/languages/";
75 4417
	}
76
77
	/**
78
	 * @return bool
79
	 */
80 19
	public function wasLoadedFromCache() {
81 19
		return $this->loaded_from_cache;
82
	}
83
84
	/**
85
	 * Get a map of all loaded translations
86
	 *
87
	 * @return array
88
	 */
89 978
	public function getLoadedTranslations() {
90 978
		return $this->translations;
91
	}
92
93
	/**
94
	 * Given a message key, returns an appropriately translated full-text string
95
	 *
96
	 * @param string $message_key The short message code
97
	 * @param array  $args        An array of arguments to pass through vsprintf().
98
	 * @param string $language    Optionally, the standard language code
99
	 *                            (defaults to site/user default, then English)
100
	 *
101
	 * @return string Either the translated string, the English string,
102
	 * or the original language string.
103
	 */
104 867
	public function translate($message_key, array $args = [], $language = "") {
105 867
		if (!is_string($message_key) || strlen($message_key) < 1) {
106
			_elgg_services()->logger->warn(
107
				'$message_key needs to be a string in ' . __METHOD__ . '(), ' . gettype($message_key) . ' provided'
108
			);
109
			return '';
110
		}
111
112 867
		if (!$language) {
113
			// no language provided, get current language
114
			// based on detection, user setting or site
115 853
			$language = $this->getCurrentLanguage();
116
		}
117
118 867
		$this->ensureTranslationsLoaded($language);
119
120
		// build language array for different trys
121
		// avoid dupes without overhead of array_unique
122 867
		$langs[$language] = true;
123
124
		// load site language
125 867
		$site_language = $this->config->language;
126 867
		if (!empty($site_language)) {
127 867
			$this->ensureTranslationsLoaded($site_language);
128
129 867
			$langs[$site_language] = true;
130
		}
131
132
		// ultimate language fallback
133 867
		$langs['en'] = true;
134
135
		// try to translate
136 867
		$notice = '';
137 867
		$string = $message_key;
138 867
		foreach (array_keys($langs) as $try_lang) {
139 867
			if (isset($this->translations[$try_lang][$message_key])) {
140 687
				$string = $this->translations[$try_lang][$message_key];
141
142
				// only pass through if we have arguments to allow backward compatibility
143
				// with manual sprintf() calls.
144 687
				if ($args) {
145 59
					$string = vsprintf($string, $args);
146
				}
147
148 687
				break;
149
			} else {
150 450
				$notice = sprintf(
151 450
					'Missing %s translation for "%s" language key',
152 450
					($try_lang === 'en') ? 'English' : $try_lang,
153 450
					$message_key
154
				);
155
			}
156
		}
157
158 867
		if ($notice) {
159 450
			_elgg_services()->logger->notice($notice);
160
		}
161
162 867
		return $string;
163
	}
164
165
	/**
166
	 * Add a translation.
167
	 *
168
	 * Translations are arrays in the Zend Translation array format, eg:
169
	 *
170
	 *	$english = array('message1' => 'message1', 'message2' => 'message2');
171
	 *  $german = array('message1' => 'Nachricht1','message2' => 'Nachricht2');
172
	 *
173
	 * @param string $country_code   Standard country code (eg 'en', 'nl', 'es')
174
	 * @param array  $language_array Formatted array of strings
175
	 *
176
	 * @return bool Depending on success
177
	 */
178 4789
	public function addTranslation($country_code, $language_array) {
179 4789
		$country_code = strtolower($country_code);
180 4789
		$country_code = trim($country_code);
181
182 4789
		if (!is_array($language_array) || $country_code === "") {
183
			return false;
184
		}
185
186 4789
		if (count($language_array) > 0) {
187 4789
			if (!isset($this->translations[$country_code])) {
188 4418
				$this->translations[$country_code] = $language_array;
189
			} else {
190 1398
				$this->translations[$country_code] = $language_array + $this->translations[$country_code];
191
			}
192
		}
193
194 4789
		return true;
195
	}
196
197
	/**
198
	 * Get the current system/user language or "en".
199
	 *
200
	 * @return string The language code for the site/user or "en" if not set
201
	 */
202 5084
	public function getCurrentLanguage() {
203 5084
		if (!isset($this->current_language)) {
204 4459
			$this->current_language = $this->detectLanguage();
205
		}
206
207 5084
		if (!$this->current_language) {
208
			$this->current_language = 'en';
209
		}
210
211 5084
		return $this->current_language;
212
	}
213
214
	/**
215
	 * Sets current system language
216
	 *
217
	 * @param string $language Language code
218
	 *
219
	 * @return void
220
	 */
221 1398
	public function setCurrentLanguage($language = null) {
222 1398
		$this->current_language = $language;
223 1398
	}
224
225
	/**
226
	 * Detect the current system/user language or false.
227
	 *
228
	 * @return false|string The language code (eg "en") or false if not set
229
	 */
230 4459
	public function detectLanguage() {
231
		// detect from URL
232 4459
		$url_lang = _elgg_services()->input->get('hl');
233 4459
		if (!empty($url_lang)) {
234 2
			return $url_lang;
235
		}
236
237
		// check logged in user
238 4459
		$user = _elgg_services()->session->getLoggedInUser();
239 4459
		if (!empty($user) && !empty($user->language)) {
240 1
			return $user->language;
241
		}
242
243
		// get site setting
244 4459
		$site_language = $this->config->language;
245 4459
		if (!empty($site_language)) {
246 4459
			return $site_language;
247
		}
248
249
		return false;
250
	}
251
252
	/**
253
	 * Load both core and plugin translations
254
	 *
255
	 * By default this loads only English and the language of the logged
256
	 * in user.
257
	 *
258
	 * The optional $language argument can be used to load translations
259
	 * on-demand in case we need to translate something to a language not
260
	 * loaded by default for the current request.
261
	 *
262
	 * @param string $language Language code
263
	 *
264
	 * @return void
265
	 *
266
	 * @access private
267
	 */
268 4778
	public function loadTranslations($language = null) {
269 4778
		if (elgg_is_system_cache_enabled()) {
270 14
			$loaded = true;
271
272 14
			if ($language) {
0 ignored issues
show
Bug Best Practice introduced by Juho Jaakkola
The expression $language of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
273 1
				$languages = [$language];
274
			} else {
275 13
				$languages = array_unique(['en', $this->getCurrentLanguage()]);
276
			}
277
278 14
			foreach ($languages as $language) {
279 14
				$data = elgg_load_system_cache("$language.lang");
280 14
				if ($data) {
281 13
					$this->addTranslation($language, unserialize($data));
282
				} else {
283 14
					$loaded = false;
284
				}
285
			}
286
287 14
			if ($loaded) {
288 13
				$this->loaded_from_cache = true;
289 13
				$this->language_paths[$this->defaultPath] = true;
290 13
				$this->is_initialized = true;
291 13
				return;
292
			}
293
		}
294
295
		// load core translations from languages directory
296 4766
		$this->registerTranslations($this->defaultPath, false, $language);
297
298
		// Plugin translation have already been loaded for the default
299
		// languages by ElggApplication::bootCore(), so there's no need
300
		// to continue unless loading a specific language on-demand
301 4766
		if ($language) {
302 989
			$this->loadPluginTranslations($language);
303
		}
304 4766
	}
305
306
	/**
307
	 * Load plugin translations for a language
308
	 *
309
	 * This is needed only if the current request uses a language
310
	 * that is neither English of the same as the language of the
311
	 * logged in user.
312
	 *
313
	 * @param string $language Language code
314
	 * @return void
315
	 * @throws \PluginException
316
	 */
317 989
	private function loadPluginTranslations($language) {
318
		// Get active plugins
319 989
		$plugins = _elgg_services()->plugins->find('active');
320
321 989
		if (!$plugins) {
0 ignored issues
show
Bug Best Practice introduced by Juho Jaakkola
The expression $plugins of type ElggPlugin[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
322
			// Active plugins were not found, so no need to register plugin translations
323 987
			return;
324
		}
325
326 2
		foreach ($plugins as $plugin) {
327 2
			$languages_path = "{$plugin->getPath()}languages/";
328
329 2
			if (!is_dir($languages_path)) {
330
				// This plugin doesn't have anything to translate
331 2
				continue;
332
			}
333
334 2
			$language_file = "{$languages_path}{$language}.php";
335
336 2
			if (!file_exists($language_file)) {
337
				// This plugin doesn't have translations for the requested language
338
339
				$name = $plugin->getDisplayName();
340
				_elgg_services()->logger->notice("Plugin $name is missing translations for $language language");
341
342
				continue;
343
			}
344
345
			// Register translations from the plugin languages directory
346 2
			if (!$this->registerTranslations($languages_path, false, $language)) {
347
				throw new \PluginException(sprintf('Cannot register languages for plugin %s (guid: %s) at %s.',
348 2
					[$plugin->getID(), $plugin->guid, $languages_path]));
349
			}
350
		}
351 2
	}
352
353
	/**
354
	 * Registers translations in a directory assuming the standard plugin layout.
355
	 *
356
	 * @param string $path Without the trailing slash.
357
	 *
358
	 * @return bool Success
359
	 */
360 24
	public function registerPluginTranslations($path) {
361 24
		$languages_path = rtrim($path, "\\/") . "/languages";
362
363
		// don't need to have translations
364 24
		if (!is_dir($languages_path)) {
365 2
			return true;
366
		}
367
368 24
		return $this->registerTranslations($languages_path);
369
	}
370
371
	/**
372
	 * When given a full path, finds translation files and loads them
373
	 *
374
	 * @param string $path     Full path
375
	 * @param bool   $load_all If true all languages are loaded, if
376
	 *                         false only the current language + en are loaded
377
	 * @param string $language Language code
378
	 *
379
	 * @return bool success
380
	 */
381 4781
	public function registerTranslations($path, $load_all = false, $language = null) {
382 4781
		$path = \Elgg\Project\Paths::sanitize($path);
383
384
		// Make a note of this path just in case we need to register this language later
385 4781
		$this->language_paths[$path] = true;
386 4781
		$this->is_initialized = true;
387
388 4781
		_elgg_services()->logger->info("Translations loaded from: $path");
389
390 4781
		if ($language) {
0 ignored issues
show
Bug Best Practice introduced by Juho Jaakkola
The expression $language of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
391 989
			$load_language_files = ["$language.php"];
392 989
			$load_all = false;
393
		} else {
394
			// Get the current language based on site defaults and user preference
395 4781
			$current_language = $this->getCurrentLanguage();
396
397
			$load_language_files = [
398 4781
				'en.php',
399 4781
				"$current_language.php"
400
			];
401
402 4781
			$load_language_files = array_unique($load_language_files);
403
		}
404
405 4781
		$handle = opendir($path);
406 4781
		if (!$handle) {
407
			_elgg_services()->logger->error("Could not open language path: $path");
408
			return false;
409
		}
410
411 4781
		$return = true;
412 4781
		while (false !== ($language_file = readdir($handle))) {
413
			// ignore bad files
414 4781
			if (substr($language_file, 0, 1) == '.' || substr($language_file, -4) !== '.php') {
415 4781
				continue;
416
			}
417
418 4781
			if (in_array($language_file, $load_language_files) || $load_all) {
419 4781
				$result = (include $path . $language_file);
420 4781
				if ($result === false) {
421
					$return = false;
422
					continue;
423 4781
				} elseif (is_array($result)) {
424 4781
					$this->addTranslation(basename($language_file, '.php'), $result);
425
				}
426
			}
427
		}
428
429 4781
		return $return;
430
	}
431
432
	/**
433
	 * Reload all translations from all registered paths.
434
	 *
435
	 * This is only called by functions which need to know all possible translations.
436
	 *
437
	 * @todo Better on demand loading based on language_paths array
438
	 *
439
	 * @return void
440
	 */
441 945
	public function reloadAllTranslations() {
442 945
		if ($this->was_reloaded) {
443 455
			return;
444
		}
445
446 945
		if ($this->loaded_from_cache) {
447
			$cache = elgg_get_system_cache();
448
			$cache_dir = $cache->getVariable("cache_path");
449
			$filenames = elgg_get_file_list($cache_dir, [], [], [".lang"]);
450
			foreach ($filenames as $filename) {
451
				// Look for files matching for example 'en.lang', 'cmn.lang' or 'pt_br.lang'.
452
				// Note that this regex is just for the system cache. The original language
453
				// files are allowed to have uppercase letters (e.g. pt_BR.php).
454
				if (preg_match('/(([a-z]{2,3})(_[a-z]{2})?)\.lang$/', $filename, $matches)) {
455
					$language = $matches[1];
456
					$data = elgg_load_system_cache("$language.lang");
457
					if ($data) {
458
						$this->addTranslation($language, unserialize($data));
459
					}
460
				}
461
			}
462
		} else {
463 945
			foreach (array_keys($this->language_paths) as $path) {
464 945
				$this->registerTranslations($path, true);
465
			}
466
		}
467
		
468 945
		_elgg_services()->hooks->getEvents()->triggerAfter('reload', 'translations');
469
470 945
		$this->was_reloaded = true;
471 945
	}
472
473
	/**
474
	 * Return an array of installed translations as an associative
475
	 * array "two letter code" => "native language name".
476
	 *
477
	 * @return array
478
	 */
479 487
	public function getInstalledTranslations() {
480
		// Ensure that all possible translations are loaded
481 487
		$this->reloadAllTranslations();
482
483 487
		$installed = [];
484
485 487
		$admin_logged_in = _elgg_services()->session->isAdminLoggedIn();
486
487 487
		foreach ($this->translations as $k => $v) {
488 487
			if ($this->languageKeyExists($k, $k)) {
489 487
				$lang = $this->translate($k, [], $k);
490
			} else {
491 476
				$lang = $this->translate($k);
492
			}
493
			
494 487
			$installed[$k] = $lang;
495
			
496 487
			if (!$admin_logged_in || ($k === 'en')) {
497 487
				continue;
498
			}
499
			
500
			$completeness = $this->getLanguageCompleteness($k);
501
			if ($completeness < 100) {
502
				$installed[$k] .= " (" . $completeness . "% " . $this->translate('complete') . ")";
503
			}
504
		}
505
506 487
		return $installed;
507
	}
508
509
	/**
510
	 * Return the level of completeness for a given language code (compared to english)
511
	 *
512
	 * @param string $language Language
513
	 *
514
	 * @return float
515
	 */
516 487
	public function getLanguageCompleteness($language) {
517
518 487
		if ($language == 'en') {
519 32
			return (float) 100;
520
		}
521
522
		// Ensure that all possible translations are loaded
523 455
		$this->reloadAllTranslations();
524
525 455
		$language = sanitise_string($language);
526
527 455
		$en = count($this->translations['en']);
528
529 455
		$missing = $this->getMissingLanguageKeys($language);
530 455
		if ($missing) {
531 455
			$missing = count($missing);
532
		} else {
533
			$missing = 0;
534
		}
535
536 455
		$lang = $en - $missing;
537
538 455
		return round(($lang / $en) * 100, 2);
539
	}
540
541
	/**
542
	 * Return the translation keys missing from a given language,
543
	 * or those that are identical to the english version.
544
	 *
545
	 * @param string $language The language
546
	 *
547
	 * @return mixed
548
	 */
549 455
	public function getMissingLanguageKeys($language) {
550
551
552
		// Ensure that all possible translations are loaded
553 455
		$this->reloadAllTranslations();
554
555 455
		$missing = [];
556
557 455
		foreach ($this->translations['en'] as $k => $v) {
558 455
			if ((!isset($this->translations[$language][$k]))
559 455
			|| ($this->translations[$language][$k] == $this->translations['en'][$k])) {
560 455
				$missing[] = $k;
561
			}
562
		}
563
564 455
		if (count($missing)) {
565 455
			return $missing;
566
		}
567
568
		return false;
569
	}
570
571
	/**
572
	 * Check if a given language key exists
573
	 *
574
	 * @param string $key      The translation key
575
	 * @param string $language The specific language to check
576
	 *
577
	 * @return bool
578
	 * @since 1.11
579
	 */
580 538
	function languageKeyExists($key, $language = 'en') {
0 ignored issues
show
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
581 538
		if (empty($key)) {
582
			return false;
583
		}
584
585 538
		$this->ensureTranslationsLoaded($language);
586
587 538
		if (!array_key_exists($language, $this->translations)) {
588 6
			return false;
589
		}
590
591 532
		return array_key_exists($key, $this->translations[$language]);
592
	}
593
594
	/**
595
	 * Make sure translations are loaded
596
	 *
597
	 * @param string $language Language
598
	 * @return void
599
	 */
600 870
	private function ensureTranslationsLoaded($language) {
601 870
		if (!$this->is_initialized) {
602
			// this means we probably had an exception before translations were initialized
603 11
			$this->registerTranslations($this->defaultPath);
604
		}
605
606 870
		if (!isset($this->translations[$language])) {
607
			// The language being requested is not the same as the language of the
608
			// logged in user, so we will have to load it separately. (Most likely
609
			// we're sending a notification and the recipient is using a different
610
			// language than the logged in user.)
611 8
			$this->loadTranslations($language);
612
		}
613 870
	}
614
615
	/**
616
	 * Returns an array of language codes.
617
	 *
618
	 * @return array
619
	 */
620
	public static function getAllLanguageCodes() {
621
		return [
622
			"aa", // "Afar"
623
			"ab", // "Abkhazian"
624
			"af", // "Afrikaans"
625
			"am", // "Amharic"
626
			"ar", // "Arabic"
627
			"as", // "Assamese"
628
			"ay", // "Aymara"
629
			"az", // "Azerbaijani"
630
			"ba", // "Bashkir"
631
			"be", // "Byelorussian"
632
			"bg", // "Bulgarian"
633
			"bh", // "Bihari"
634
			"bi", // "Bislama"
635
			"bn", // "Bengali; Bangla"
636
			"bo", // "Tibetan"
637
			"br", // "Breton"
638
			"ca", // "Catalan"
639
			"cmn", // "Mandarin Chinese" // ISO 639-3
640
			"co", // "Corsican"
641
			"cs", // "Czech"
642
			"cy", // "Welsh"
643
			"da", // "Danish"
644
			"de", // "German"
645
			"dz", // "Bhutani"
646
			"el", // "Greek"
647
			"en", // "English"
648
			"eo", // "Esperanto"
649
			"es", // "Spanish"
650
			"et", // "Estonian"
651
			"eu", // "Basque"
652
			"eu_es", // "Basque (Spain)"
653
			"fa", // "Persian"
654
			"fi", // "Finnish"
655
			"fj", // "Fiji"
656
			"fo", // "Faeroese"
657
			"fr", // "French"
658
			"fy", // "Frisian"
659
			"ga", // "Irish"
660
			"gd", // "Scots / Gaelic"
661
			"gl", // "Galician"
662
			"gn", // "Guarani"
663
			"gu", // "Gujarati"
664
			"he", // "Hebrew"
665
			"ha", // "Hausa"
666
			"hi", // "Hindi"
667
			"hr", // "Croatian"
668
			"hu", // "Hungarian"
669
			"hy", // "Armenian"
670
			"ia", // "Interlingua"
671
			"id", // "Indonesian"
672
			"ie", // "Interlingue"
673
			"ik", // "Inupiak"
674
			"is", // "Icelandic"
675
			"it", // "Italian"
676
			"iu", // "Inuktitut"
677
			"iw", // "Hebrew (obsolete)"
678
			"ja", // "Japanese"
679
			"ji", // "Yiddish (obsolete)"
680
			"jw", // "Javanese"
681
			"ka", // "Georgian"
682
			"kk", // "Kazakh"
683
			"kl", // "Greenlandic"
684
			"km", // "Cambodian"
685
			"kn", // "Kannada"
686
			"ko", // "Korean"
687
			"ks", // "Kashmiri"
688
			"ku", // "Kurdish"
689
			"ky", // "Kirghiz"
690
			"la", // "Latin"
691
			"ln", // "Lingala"
692
			"lo", // "Laothian"
693
			"lt", // "Lithuanian"
694
			"lv", // "Latvian/Lettish"
695
			"mg", // "Malagasy"
696
			"mi", // "Maori"
697
			"mk", // "Macedonian"
698
			"ml", // "Malayalam"
699
			"mn", // "Mongolian"
700
			"mo", // "Moldavian"
701
			"mr", // "Marathi"
702
			"ms", // "Malay"
703
			"mt", // "Maltese"
704
			"my", // "Burmese"
705
			"na", // "Nauru"
706
			"ne", // "Nepali"
707
			"nl", // "Dutch"
708
			"no", // "Norwegian"
709
			"oc", // "Occitan"
710
			"om", // "(Afan) Oromo"
711
			"or", // "Oriya"
712
			"pa", // "Punjabi"
713
			"pl", // "Polish"
714
			"ps", // "Pashto / Pushto"
715
			"pt", // "Portuguese"
716
			"pt_br", // "Portuguese (Brazil)"
717
			"qu", // "Quechua"
718
			"rm", // "Rhaeto-Romance"
719
			"rn", // "Kirundi"
720
			"ro", // "Romanian"
721
			"ro_ro", // "Romanian (Romania)"
722
			"ru", // "Russian"
723
			"rw", // "Kinyarwanda"
724
			"sa", // "Sanskrit"
725
			"sd", // "Sindhi"
726
			"sg", // "Sangro"
727
			"sh", // "Serbo-Croatian"
728
			"si", // "Singhalese"
729
			"sk", // "Slovak"
730
			"sl", // "Slovenian"
731
			"sm", // "Samoan"
732
			"sn", // "Shona"
733
			"so", // "Somali"
734
			"sq", // "Albanian"
735
			"sr", // "Serbian"
736
			"sr_latin", // "Serbian (Latin)"
737
			"ss", // "Siswati"
738
			"st", // "Sesotho"
739
			"su", // "Sundanese"
740
			"sv", // "Swedish"
741
			"sw", // "Swahili"
742
			"ta", // "Tamil"
743
			"te", // "Tegulu"
744
			"tg", // "Tajik"
745
			"th", // "Thai"
746
			"ti", // "Tigrinya"
747
			"tk", // "Turkmen"
748
			"tl", // "Tagalog"
749
			"tn", // "Setswana"
750
			"to", // "Tonga"
751
			"tr", // "Turkish"
752
			"ts", // "Tsonga"
753
			"tt", // "Tatar"
754
			"tw", // "Twi"
755
			"ug", // "Uigur"
756
			"uk", // "Ukrainian"
757
			"ur", // "Urdu"
758
			"uz", // "Uzbek"
759
			"vi", // "Vietnamese"
760
			"vo", // "Volapuk"
761
			"wo", // "Wolof"
762
			"xh", // "Xhosa"
763
			"yi", // "Yiddish"
764
			"yo", // "Yoruba"
765
			"za", // "Zuang"
766
			"zh", // "Chinese"
767
			"zh_hans", // "Chinese Simplified"
768
			"zu", // "Zulu"
769
		];
770
	}
771
772
	/**
773
	 * Normalize a language code (e.g. from Transifex)
774
	 *
775
	 * @param string $code Language code
776
	 *
777
	 * @return string
778
	 */
779
	public static function normalizeLanguageCode($code) {
780
		$code = strtolower($code);
781
		$code = preg_replace('~[^a-z0-9]~', '_', $code);
782
		return $code;
783
	}
784
}
785