Completed
Push — master ( 21ecde...d4facc )
by Nazar
04:59
created

Language::get()   C

Complexity

Conditions 9
Paths 9

Size

Total Lines 23
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 9

Importance

Changes 2
Bugs 0 Features 1
Metric Value
cc 9
eloc 16
c 2
b 0
f 1
nc 9
nop 3
dl 0
loc 23
ccs 16
cts 16
cp 1
crap 9
rs 5.8541
1
<?php
2
/**
3
 * @package   CleverStyle Framework
4
 * @author    Nazar Mokrynskyi <[email protected]>
5
 * @copyright Copyright (c) 2011-2016, Nazar Mokrynskyi
6
 * @license   MIT License, see license.txt
7
 */
8
namespace cs;
9
use
10
	JsonSerializable,
11
	cs\Language\Prefix;
12
13
/**
14
 * Provides next events:
15
 *  System/Language/change/before
16
 *
17
 *  System/Language/change/after
18
 *
19
 *  System/Language/load
20
 *  [
21
 *   'clanguage'        => clanguage
22
 *   'clang'            => clang
23
 *   'cregion'          => cregion
24
 *  ]
25
 *
26
 * @method static $this instance($check = false)
27
 *
28
 * @property string $clanguage
29
 * @property string $clang
30
 * @property string $cregion
31
 * @property string $content_language
32
 * @property string $_datetime_long
33
 * @property string $_datetime
34
 * @property string $_date
35
 * @property string $_time
36
 */
37
class Language implements JsonSerializable {
38
	use Singleton;
39
	/**
40
	 * Callable for time processing
41
	 *
42
	 * @var callable
43
	 */
44
	public $time;
45
	/**
46
	 * @var string
47
	 */
48
	protected $current_language;
49
	/**
50
	 * For single initialization
51
	 *
52
	 * @var bool
53
	 */
54
	protected $init = false;
55
	/**
56
	 * Local cache of translations
57
	 *
58
	 * @var array
59
	 */
60
	protected $translation = [];
61
	/**
62
	 * Cache to optimize frequent calls
63
	 *
64
	 * @var array
65
	 */
66
	protected $localized_url = [];
67
	/**
68
	 * Set basic language
69
	 */
70 64
	protected function construct () {
71 64
		$Config                 = Config::instance(true);
72 64
		$Core                   = Core::instance();
73 64
		$this->current_language = $Core->language;
74
		/**
75
		 * We need Config for initialization
76
		 */
77 64
		if (!$Config) {
78 2
			Event::instance()->once(
79 2
				'System/Config/init/after',
80
				function () {
81
					$this->init();
82 2
				}
83
			);
84
		} else {
85 62
			$this->init();
86
		}
87 64
		Event::instance()->on(
88 64
			'System/Config/changed',
89
			function () {
90 2
				$Config = Config::instance();
91 2
				if ($Config->core['multilingual'] && User::instance(true)) {
92
					$this->current_language = User::instance()->language;
93
				} else {
94 2
					$this->current_language = $Config->core['language'];
95
				}
96 64
			}
97
		);
98 64
	}
99
	/**
100
	 * Initialization: set default language based on system configuration and request-specific parameters
101
	 */
102 62
	protected function init () {
103 62
		$Config = Config::instance();
104 62
		if ($Config->core['multilingual']) {
105
			/**
106
			 * Highest priority - `-Locale` header
107
			 */
108
			/** @noinspection PhpParamsInspection */
109 8
			$language = $this->check_locale_header($Config->core['active_languages']);
110
			/**
111
			 * Second priority - URL
112
			 */
113 8
			$language = $language ?: $this->url_language(Request::instance()->path);
114
			/**
115
			 * Third - `Accept-Language` header
116
			 */
117
			/** @noinspection PhpParamsInspection */
118 8
			$language = $language ?: $this->check_accept_header($Config->core['active_languages']);
119
		} else {
120 54
			$language = $Config->core['language'];
121
		}
122 62
		$this->current_language = $language ?: '';
123 62
	}
124
	/**
125
	 * Returns instance for simplified work with translations, when using common prefix
126
	 *
127
	 * @param string $prefix
128
	 *
129
	 * @return Prefix
130
	 */
131 22
	public static function prefix ($prefix) {
132 22
		return new Prefix($prefix);
133
	}
134
	/**
135
	 * Does URL have language prefix
136
	 *
137
	 * @param false|string $url Relative url, `Request::instance()->path` by default
138
	 *
139
	 * @return false|string If there is language prefix - language will be returned, `false` otherwise
140
	 */
141 34
	public function url_language ($url = false) {
142
		/**
143
		 * @var string $url
144
		 */
145 34
		$url = $url ?: Request::instance()->path;
146 34
		if (isset($this->localized_url[$url])) {
147
			return $this->localized_url[$url];
148
		}
149 34
		$aliases = $this->get_aliases();
150 34
		$clang   = explode('?', $url, 2)[0];
151 34
		$clang   = explode('/', trim($clang, '/'), 2)[0];
152 34
		if (isset($aliases[$clang])) {
153 4
			if (count($this->localized_url) > 100) {
154
				$this->localized_url = [];
155
			}
156 4
			return $this->localized_url[$url] = $aliases[$clang];
157
		}
158 32
		return false;
159
	}
160
	/**
161
	 * Checking Accept-Language header for languages that exists in configuration
162
	 *
163
	 * @param array $active_languages
164
	 *
165
	 * @return false|string
166
	 */
167 4
	protected function check_accept_header ($active_languages) {
168 4
		$aliases          = $this->get_aliases();
169 4
		$accept_languages = array_filter(
170
			explode(
171 4
				',',
172
				strtolower(
173 4
					strtr(Request::instance()->header('accept-language'), '-', '_')
174
				)
175
			)
176
		);
177 4
		foreach ($accept_languages as $language) {
178 4
			$language = explode(';', $language, 2)[0];
179 4
			if (@in_array($aliases[$language], $active_languages)) {
180 4
				return $aliases[$language];
181
			}
182
		}
183
		return false;
184
	}
185
	/**
186
	 * Check `*-Locale` header (for instance, `X-Facebook-Locale`) that exists in configuration
187
	 *
188
	 * @param string[] $active_languages
189
	 *
190
	 * @return false|string
191
	 */
192 8
	protected function check_locale_header ($active_languages) {
193 8
		$aliases = $this->get_aliases();
194
		/**
195
		 * For `X-Facebook-Locale` and other similar
196
		 */
197 8
		foreach (Request::instance()->headers ?: [] as $i => $v) {
198 8
			if (stripos($i, '-locale') !== false) {
199 2
				$language = strtolower($v);
200 2
				if (@in_array($aliases[$language], $active_languages)) {
201 2
					return $aliases[$language];
202
				}
203 8
				return false;
204
			}
205
		}
206 6
		return false;
207
	}
208
	/**
209
	 * Get languages aliases
210
	 *
211
	 * @return array|false
212
	 */
213 34
	protected function get_aliases () {
214 34
		return Cache::instance()->get(
215 34
			'languages/aliases',
216
			function () {
217 4
				$aliases      = [];
218 4
				$aliases_list = _strtolower(get_files_list(LANGUAGES.'/aliases'));
219 4
				foreach ($aliases_list as $alias) {
220 4
					$aliases[$alias] = trim(file_get_contents(LANGUAGES."/aliases/$alias"));
221
				}
222 4
				return $aliases;
223 34
			}
224
		);
225
	}
226
	/**
227
	 * Get translation
228
	 *
229
	 * @param bool|string  $item
230
	 * @param false|string $language If specified - translation for specified language will be returned, otherwise for current
231
	 * @param string       $prefix   Used by `\cs\Language\Prefix`, usually no need to use it directly
232
	 *
233
	 * @return string
234
	 */
235 64
	public function get ($item, $language = false, $prefix = '') {
236
		/**
237
		 * Small optimization, we can actually return value without translations
238
		 */
239 64
		if ($item == 'clanguage' && $this->current_language && $language === false && !$prefix) {
240 54
			return $this->current_language;
241
		}
242 54
		$language = $language ?: $this->current_language;
243 54
		if (isset($this->translation[$language])) {
244 54
			$translation = $this->translation[$language];
245 54
			if (isset($translation[$prefix.$item])) {
246 54
				return $translation[$prefix.$item];
247 6
			} elseif (isset($translation[$item])) {
248 4
				return $translation[$item];
249
			}
250 2
			return ucfirst(str_replace('_', ' ', $item));
251
		}
252 50
		$current_language = $this->current_language;
253 50
		$this->change($language);
254 50
		$return = $this->get($item, $this->current_language, $prefix);
255 50
		$this->change($current_language);
256 50
		return $return;
257
	}
258
	/**
259
	 * Set translation
260
	 *
261
	 * @param array|string $item Item string, or key-value array
262
	 * @param null|string  $value
263
	 *
264
	 * @return void
265
	 */
266
	public function set ($item, $value = null) {
267
		$translate = &$this->translation[$this->current_language];
268
		if (is_array($item)) {
269
			$translate = $item + ($translate ?: []);
270
		} else {
271
			$translate[$item] = $value;
272
		}
273
	}
274
	/**
275
	 * Get translation
276
	 *
277
	 * @param string $item
278
	 *
279
	 * @return string
280
	 */
281 64
	public function __get ($item) {
282 64
		return $this->get($item);
283
	}
284
	/**
285
	 * Set translation
286
	 *
287
	 * @param array|string $item
288
	 * @param null|string  $value
289
	 *
290
	 * @return string
291
	 */
292
	public function __set ($item, $value = null) {
293
		$this->set($item, $value);
294
	}
295
	/**
296
	 * Change language
297
	 *
298
	 * @param string $language
299
	 *
300
	 * @return bool
301
	 */
302 54
	public function change ($language) {
303
		/**
304
		 * Already set to specified language
305
		 */
306 54
		if ($language == $this->current_language && isset($this->translation[$language])) {
307 50
			return true;
308
		}
309 54
		$Config = Config::instance(true);
310
		/**
311
		 * @var string $language
312
		 */
313 54
		$language = $language ?: $Config->core['language'];
314 54
		if (!$this->can_be_changed_to($Config, $language)) {
315 2
			return false;
316
		}
317 54
		$Event = Event::instance();
318 54
		$Event->fire('System/Language/change/before');
319 54
		if (!isset($this->translation[$language])) {
320 54
			$this->translation[$language] = $this->get_translation($language);
321
		}
322
		/**
323
		 * Change current language to `$language`
324
		 */
325 54
		$this->current_language = $language;
326 54
		_include(LANGUAGES."/$language.php", false, false);
327 54
		if ($Config->core['multilingual']) {
328 16
			Response::instance()->header('content-language', $this->content_language);
329
		}
330 54
		$Event->fire('System/Language/change/after');
331 54
		return true;
332
	}
333
	/**
334
	 * Check whether it is allowed to change to specified language according to configuration
335
	 *
336
	 * @param Config $Config
337
	 * @param string $language
338
	 *
339
	 * @return bool
340
	 */
341 54
	protected function can_be_changed_to ($Config, $language) {
342 54
		if (!$language) {
343
			return false;
344
		}
345
		return
346
			// Config not loaded yet
347 54
			!$Config->core ||
348
			// Set to language that is configured on system level
349 52
			$language == $Config->core['language'] ||
350
			// Set to active language
351
			(
352 18
				$Config->core['multilingual'] &&
353 54
				in_array($language, $Config->core['active_languages'])
354
			);
355
	}
356 54
	protected function get_translation ($language) {
357 54
		return Cache::instance()->get(
358 54
			"languages/$language",
359 54
			function () use ($language) {
360 10
				return $this->get_translation_internal($language);
361 54
			}
362
		);
363
	}
364
	/**
365
	 * Load translation from all over the system, set `$this->translation[$language]` and return it
366
	 *
367
	 * @param $language
368
	 *
369
	 * @return string[]
370
	 */
371 10
	protected function get_translation_internal ($language) {
372
		/**
373
		 * Get current system translations
374
		 */
375 10
		$translation = $this->get_translation_from_json(LANGUAGES."/$language.json");
376 10
		$translation = $this->fill_required_translation_keys($translation, $language);
377
		/**
378
		 * Set modules' translations
379
		 */
380 10
		foreach (get_files_list(MODULES, false, 'd', true) as $module_dir) {
381 10
			if (file_exists("$module_dir/languages/$language.json")) {
382 10
				$translation = $this->get_translation_from_json("$module_dir/languages/$language.json") + $translation;
383
			}
384
		}
385 10
		Event::instance()->fire(
386 10
			'System/Language/load',
387
			[
388 10
				'clanguage' => $language,
389 10
				'clang'     => $translation['clang'],
390 10
				'cregion'   => $translation['cregion']
391
			]
392
		);
393
		// TODO: Remove in 6.x
394 10
		Event::instance()->fire(
395 10
			'System/general/languages/load',
396
			[
397 10
				'clanguage' => $language,
398 10
				'clang'     => $translation['clang'],
399 10
				'cregion'   => $translation['cregion']
400
			]
401
		);
402
		/**
403
		 * Append translations from core language to fill potentially missing keys
404
		 */
405 10
		$core_language = Core::instance()->language;
406 10
		if ($language != $core_language) {
407 6
			$translation += $this->get_translation($core_language);
408
		}
409 10
		return $translation;
410
	}
411
	/**
412
	 * @param string $filename
413
	 *
414
	 * @return string[]
415
	 */
416 10
	protected function get_translation_from_json ($filename) {
417 10
		return $this->get_translation_from_json_internal(
418
			file_get_json_nocomments($filename)
419
		);
420
	}
421
	/**
422
	 * @param string[]|string[][] $translation
423
	 *
424
	 * @return string[]
425
	 */
426 10
	protected function get_translation_from_json_internal ($translation) {
427
		// Nested structure processing
428 10
		foreach ($translation as $item => $value) {
429 10
			if (is_array_assoc($value)) {
430 10
				unset($translation[$item]);
431 10
				foreach ($value as $sub_item => $sub_value) {
432 10
					$translation[$item.$sub_item] = $sub_value;
433
				}
434 10
				return $this->get_translation_from_json_internal($translation);
435
			}
436
		}
437 10
		return $translation;
438
	}
439
	/**
440
	 * Some required keys might be missing in translation, this functions tries to guess and fill them automatically
441
	 *
442
	 * @param string[] $translation
443
	 * @param string   $language
444
	 *
445
	 * @return string[]
446
	 */
447 10
	protected function fill_required_translation_keys ($translation, $language) {
448
		$translation += [
449 10
			'clanguage' => $language,
450 10
			'clang'     => mb_strtolower(mb_substr($language, 0, 2)),
451 10
			'clocale'   => $translation['clang'].'_'.mb_strtoupper($translation['cregion'])
452
		];
453
		$translation += [
454 10
			'content_language' => $translation['clang'],
455 10
			'cregion'          => $translation['clang']
456
		];
457 10
		return $translation;
458
	}
459
	/**
460
	 * Time formatting according to the current language (adding correct endings)
461
	 *
462
	 * @param int    $in          time (number)
463
	 * @param string $type        Type of formatting<br>
464
	 *                            s - seconds<br>m - minutes<br>h - hours<br>d - days<br>M - months<br>y - years
465
	 *
466
	 * @return string
467
	 */
468 4
	public function time ($in, $type) {
469 4
		if (is_callable($this->time)) {
470
			$time = $this->time;
471
			return $time($in, $type);
472
		} else {
473
			switch ($type) {
474 4
				case 's':
475 4
					return "$in $this->system_time_seconds";
476
				case 'm':
477
					return "$in $this->system_time_minutes";
478
				case 'h':
479
					return "$in $this->system_time_hours";
480
				case 'd':
481
					return "$in $this->system_time_days";
482
				case 'M':
483
					return "$in $this->system_time_months";
484
				case 'y':
485
					return "$in $this->system_time_years";
486
			}
487
		}
488
		return $in;
489
	}
490
	/**
491
	 * Allows to use formatted strings in translations
492
	 *
493
	 * @see format()
494
	 *
495
	 * @param string $item
496
	 * @param array  $arguments
497
	 *
498
	 * @return string
499
	 */
500 2
	public function __call ($item, $arguments) {
501 2
		return $this->format($item, $arguments);
502
	}
503
	/**
504
	 * Allows to use formatted strings in translations
505
	 *
506
	 * @param string       $item
507
	 * @param string[]     $arguments
508
	 * @param false|string $language If specified - translation for specified language will be returned, otherwise for current
509
	 * @param string       $prefix   Used by `\cs\Language\Prefix`, usually no need to use it directly
510
	 *
511
	 * @return string
512
	 */
513 6
	public function format ($item, $arguments, $language = false, $prefix = '') {
514 6
		return vsprintf($this->get($item, $language, $prefix), $arguments);
515
	}
516
	/**
517
	 * Formatting date according to language locale (translating months names, days of week, etc.)
518
	 *
519
	 * @param string|string[] $data
520
	 * @param bool            $short_may When in date() or similar functions "M" format option is used, third month "May" have the same short textual
521
	 *                                   representation as full, so, this option allows to specify, which exactly form of representation do you want
522
	 *
523
	 * @return string|string[]
524
	 */
525 2
	public function to_locale ($data, $short_may = false) {
526 2
		if (is_array($data)) {
527
			foreach ($data as &$item) {
528
				$item = $this->to_locale($item, $short_may);
529
			}
530
			return $data;
531
		}
532 2
		if ($short_may) {
533
			$data = str_replace('May', 'May_short', $data);
534
		}
535
		$from = [
536 2
			'January',
537
			'February',
538
			'March',
539
			'April',
540
			'May_short',
541
			'June',
542
			'July',
543
			'August',
544
			'September',
545
			'October',
546
			'November',
547
			'December',
548
			'Jan',
549
			'Feb',
550
			'Mar',
551
			'Apr',
552
			'May',
553
			'Jun',
554
			'Jul',
555
			'Aug',
556
			'Sep',
557
			'Oct',
558
			'Nov',
559
			'Dec',
560
			'Sunday',
561
			'Monday',
562
			'Tuesday',
563
			'Wednesday',
564
			'Thursday',
565
			'Friday',
566
			'Saturday',
567
			'Sun',
568
			'Mon',
569
			'Tue',
570
			'Wed',
571
			'Thu',
572
			'Fri',
573
			'Sat'
574
		];
575 2
		foreach ($from as $f) {
576 2
			$data = str_replace($f, $this->get("l_$f"), $data);
577
		}
578 2
		return $data;
579
	}
580
	/**
581
	 * Implementation of JsonSerializable interface
582
	 *
583
	 * @return string[]
584
	 */
585 4
	public function jsonSerialize () {
586 4
		return $this->translation[$this->current_language];
587
	}
588
}
589