Completed
Push — master ( 5b91fe...d7aa39 )
by Nazar
07:05
created

Language::set()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 6
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 2
nop 2
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
ccs 0
cts 5
cp 0
crap 12
1
<?php
2
/**
3
 * @package   CleverStyle Framework
4
 * @author    Nazar Mokrynskyi <[email protected]>
5
 * @copyright Copyright (c) 2011-2017, 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
39
		Singleton;
40
	const INIT_STATE_METHOD = 'init';
41
	/**
42
	 * Callable for time processing
43
	 *
44
	 * @var callable
45
	 */
46
	public $time;
47
	/**
48
	 * @var string
49
	 */
50
	protected $current_language;
51
	/**
52
	 * Local cache of translations
53
	 *
54
	 * @var array
55
	 */
56
	protected $translation = [];
57
	/**
58
	 * Cache to optimize frequent calls
59
	 *
60
	 * @var array
61
	 */
62
	protected $localized_url = [];
63 126
	protected function init () {
64
		/**
65
		 * Initialization: set default language based on system configuration and request-specific parameters
66
		 */
67 126
		$Config = Config::instance(true);
68
		/**
69
		 * We need Config for initialization
70
		 */
71 126
		if (!$Config) {
0 ignored issues
show
introduced by
The condition ! $Config can never be false.
Loading history...
72
			Event::instance()->once(
73
				'System/Config/init/after',
74
				function () {
75
					$this->init_internal();
76
				}
77
			);
78
		} else {
79 126
			$this->init_internal();
80
		}
81 126
	}
82 126
	protected function init_internal () {
83 126
		$Config   = Config::instance();
84 126
		$language = '';
85 126
		if ($Config->core['multilingual']) {
86 18
			$language = User::instance(true)->language;
87
			/**
88
			 * Highest priority - `-Locale` header
89
			 */
90
			/** @noinspection PhpParamsInspection */
91 18
			$language = $language ?: $this->check_locale_header($Config->core['active_languages']);
92
			/**
93
			 * Second priority - URL
94
			 */
95 18
			$language = $language ?: $this->url_language(Request::instance()->path);
96
			/**
97
			 * Third - `Accept-Language` header
98
			 */
99
			/** @noinspection PhpParamsInspection */
100 18
			$language = $language ?: $this->check_accept_header($Config->core['active_languages']);
101
		}
102 126
		$this->current_language = $language ?: $Config->core['language'];
103 126
	}
104
	/**
105
	 * Returns instance for simplified work with translations, when using common prefix
106
	 *
107
	 * @param string $prefix
108
	 *
109
	 * @return Prefix
110
	 */
111 15
	public static function prefix ($prefix) {
112 15
		return new Prefix($prefix);
113
	}
114
	/**
115
	 * Does URL have language prefix
116
	 *
117
	 * @param false|string $url Relative url, `Request::instance()->path` by default
118
	 *
119
	 * @return false|string If there is language prefix - language will be returned, `false` otherwise
120
	 */
121 78
	public function url_language ($url = false) {
122
		/**
123
		 * @var string $url
124
		 */
125 78
		$url = $url ?: Request::instance()->path;
126 78
		if (isset($this->localized_url[$url])) {
127
			return $this->localized_url[$url];
128
		}
129 78
		$aliases = $this->get_aliases();
130 78
		$clang   = explode('?', $url, 2)[0];
131 78
		$clang   = explode('/', trim($clang, '/'), 2)[0];
132 78
		if (isset($aliases[$clang])) {
133 9
			if (count($this->localized_url) > 100) {
134
				$this->localized_url = [];
135
			}
136 9
			return $this->localized_url[$url] = $aliases[$clang];
137
		}
138 75
		return false;
139
	}
140
	/**
141
	 * Checking Accept-Language header for languages that exists in configuration
142
	 *
143
	 * @param array $active_languages
144
	 *
145
	 * @return false|string
146
	 */
147 12
	protected function check_accept_header ($active_languages) {
148 12
		$aliases          = $this->get_aliases();
149 12
		$accept_languages = array_filter(
150 12
			explode(
151 12
				',',
152 12
				strtolower(
153 12
					str_replace('-', '_', Request::instance()->header('accept-language'))
154
				)
155
			)
156
		);
157 12
		foreach ($accept_languages as $language) {
158 9
			$language = explode(';', $language, 2)[0];
159 9
			if (@in_array($aliases[$language], $active_languages)) {
160 9
				return $aliases[$language];
161
			}
162
		}
163 3
		return false;
164
	}
165
	/**
166
	 * Check `*-Locale` header (for instance, `X-Facebook-Locale`) that exists in configuration
167
	 *
168
	 * @param string[] $active_languages
169
	 *
170
	 * @return false|string
171
	 */
172 18
	protected function check_locale_header ($active_languages) {
173 18
		$aliases = $this->get_aliases();
174
		/**
175
		 * For `X-Facebook-Locale` and other similar
176
		 */
177 18
		foreach (Request::instance()->headers ?: [] as $i => $v) {
178 15
			if (stripos($i, '-locale') !== false) {
179 3
				$language = strtolower($v);
180 3
				if (@in_array($aliases[$language], $active_languages)) {
181 3
					return $aliases[$language];
182
				}
183 15
				return false;
184
			}
185
		}
186 15
		return false;
187
	}
188
	/**
189
	 * Get languages aliases
190
	 *
191
	 * @return array|false
192
	 */
193 78
	protected function get_aliases () {
194 78
		return Cache::instance()->get(
195 78
			'languages/aliases',
196 78
			function () {
197 6
				$aliases = [];
198
				/**
199
				 * @var string[] $aliases_list
200
				 */
201 6
				$aliases_list = _strtolower(get_files_list(LANGUAGES.'/aliases'));
202 6
				foreach ($aliases_list as $alias) {
203 6
					$aliases[$alias] = trim(file_get_contents(LANGUAGES."/aliases/$alias"));
204
				}
205 6
				return $aliases;
206 78
			}
207
		);
208
	}
209
	/**
210
	 * Get translation
211
	 *
212
	 * @param bool|string  $item
213
	 * @param false|string $language If specified - translation for specified language will be returned, otherwise for current
214
	 * @param string       $prefix   Used by `\cs\Language\Prefix`, usually no need to use it directly
215
	 *
216
	 * @return string
217
	 */
218 123
	public function get ($item, $language = false, $prefix = '') {
219
		/**
220
		 * Small optimization, we can actually return value without translations
221
		 */
222 123
		if ($item == 'clanguage' && $this->current_language && $language === false && !$prefix) {
223 108
			return $this->current_language;
224
		}
225 96
		$language = $language ?: $this->current_language;
226 96
		if (isset($this->translation[$language])) {
227 96
			$translation = $this->translation[$language];
228 96
			if (isset($translation[$prefix.$item])) {
229 96
				return $translation[$prefix.$item];
230 6
			} elseif (isset($translation[$item])) {
231 3
				return $translation[$item];
232
			}
233 3
			return ucfirst(str_replace('_', ' ', $item));
234
		}
235 90
		$current_language = $this->current_language;
236 90
		$this->change($language);
237 90
		$return = $this->get($item, $this->current_language, $prefix);
238 90
		$this->change($current_language);
239 90
		return $return;
240
	}
241
	/**
242
	 * Set translation
243
	 *
244
	 * @param array|string $item Item string, or key-value array
245
	 * @param null|string  $value
246
	 *
247
	 * @return void
248
	 */
249
	public function set ($item, $value = null) {
250
		$translate = &$this->translation[$this->current_language];
251
		if (is_array($item)) {
252
			$translate = $item + ($translate ?: []);
253
		} else {
254
			$translate[$item] = $value;
255
		}
256
	}
257
	/**
258
	 * Get translation
259
	 *
260
	 * @param string $item
261
	 *
262
	 * @return string
263
	 */
264 123
	public function __get ($item) {
265 123
		return $this->get($item);
266
	}
267
	/**
268
	 * Set translation
269
	 *
270
	 * @param array|string $item
271
	 * @param null|string  $value
272
	 */
273
	public function __set ($item, $value = null) {
274
		$this->set($item, $value);
275
	}
276
	/**
277
	 * Change language
278
	 *
279
	 * @param string $language
280
	 *
281
	 * @return bool
282
	 */
283 99
	public function change ($language) {
284
		/**
285
		 * Already set to specified language
286
		 */
287 99
		if ($language == $this->current_language && isset($this->translation[$language])) {
288 93
			return true;
289
		}
290 99
		$Config = Config::instance(true);
291
		/**
292
		 * @var string $language
293
		 */
294 99
		$language = $language ?: $Config->core['language'];
295 99
		if (!$this->can_be_changed_to($Config, $language)) {
296 6
			return false;
297
		}
298 99
		$Event = Event::instance();
299 99
		$Event->fire('System/Language/change/before');
300 99
		if (!isset($this->translation[$language])) {
301 99
			$this->translation[$language] = $this->get_translation($language);
302
		}
303
		/**
304
		 * Change current language to `$language`
305
		 */
306 99
		$this->current_language = $language;
307 99
		_include(LANGUAGES."/$language.php", false, false);
308 99
		$Request = Request::instance();
309 99
		if ($Request->regular_path && $Config->core['multilingual']) {
310 12
			Response::instance()->header('content-language', $this->content_language);
0 ignored issues
show
Bug introduced by
The method header() does not exist on cs\False_class. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

310
			Response::instance()->/** @scrutinizer ignore-call */ header('content-language', $this->content_language);
Loading history...
311
		}
312 99
		$Event->fire('System/Language/change/after');
313 99
		return true;
314
	}
315
	/**
316
	 * Check whether it is allowed to change to specified language according to configuration
317
	 *
318
	 * @param Config $Config
319
	 * @param string $language
320
	 *
321
	 * @return bool
322
	 */
323 99
	protected function can_be_changed_to ($Config, $language) {
324 99
		if (!$language) {
325
			return false;
326
		}
327
		return
328
			// Config not loaded yet
329 99
			!$Config->core ||
330
			// Set to language that is configured on system level
331 99
			$language == $Config->core['language'] ||
332
			// Set to active language
333
			(
334 36
				$Config->core['multilingual'] &&
335 99
				in_array($language, $Config->core['active_languages'])
336
			);
337
	}
338 99
	protected function get_translation ($language) {
339 99
		return Cache::instance()->get(
340 99
			"languages/$language",
341 99
			function () use ($language) {
342 18
				return $this->get_translation_internal($language);
343 99
			}
344
		);
345
	}
346
	/**
347
	 * Load translation from all over the system, set `$this->translation[$language]` and return it
348
	 *
349
	 * @param $language
350
	 *
351
	 * @return string[]
352
	 */
353 18
	protected function get_translation_internal ($language) {
354
		/**
355
		 * Get current system translations
356
		 */
357 18
		$translation = $this->get_translation_from_json(LANGUAGES."/$language.json");
358 18
		$translation = $this->fill_required_translation_keys($translation, $language);
359
		/**
360
		 * Set modules' translations
361
		 */
362 18
		foreach (get_files_list(MODULES, false, 'd', true) as $module_dir) {
363 18
			if (file_exists("$module_dir/languages/$language.json")) {
364 18
				$translation = $this->get_translation_from_json("$module_dir/languages/$language.json") + $translation;
365
			}
366
		}
367 18
		Event::instance()->fire(
368 18
			'System/Language/load',
369
			[
370 18
				'clanguage' => $language,
371 18
				'clang'     => $translation['clang'],
372 18
				'cregion'   => $translation['cregion']
373
			]
374
		);
375
		/**
376
		 * Append translations from core language to fill potentially missing keys
377
		 */
378 18
		$core_language = Core::instance()->language;
379 18
		if ($language != $core_language) {
380 12
			$translation += $this->get_translation($core_language);
381
		}
382 18
		return $translation;
383
	}
384
	/**
385
	 * @param string $filename
386
	 *
387
	 * @return string[]
388
	 */
389 18
	protected function get_translation_from_json ($filename) {
390 18
		return $this->get_translation_from_json_internal(
391 18
			file_get_json_nocomments($filename)
392
		);
393
	}
394
	/**
395
	 * @param string[]|string[][] $translation
396
	 *
397
	 * @return string[]
398
	 */
399 18
	protected function get_translation_from_json_internal ($translation) {
400
		// Nested structure processing
401 18
		foreach ($translation as $item => $value) {
402 18
			if (is_array_assoc($value)) {
403 18
				unset($translation[$item]);
404 18
				foreach ($value as $sub_item => $sub_value) {
405 18
					$translation[$item.$sub_item] = $sub_value;
406
				}
407 18
				return $this->get_translation_from_json_internal($translation);
408
			}
409
		}
410 18
		return $translation;
411
	}
412
	/**
413
	 * Some required keys might be missing in translation, this functions tries to guess and fill them automatically
414
	 *
415
	 * @param string[] $translation
416
	 * @param string   $language
417
	 *
418
	 * @return string[]
419
	 */
420 18
	protected function fill_required_translation_keys ($translation, $language) {
421
		$translation += [
422 18
			'clanguage' => $language,
423 18
			'clang'     => mb_strtolower(mb_substr($language, 0, 2)),
424 18
			'clocale'   => $translation['clang'].'_'.mb_strtoupper($translation['cregion'])
425
		];
426
		$translation += [
427 18
			'content_language' => $translation['clang'],
428 18
			'cregion'          => $translation['clang']
429
		];
430 18
		return $translation;
431
	}
432
	/**
433
	 * Time formatting according to the current language (adding correct endings)
434
	 *
435
	 * @param int    $in   time (number)
436
	 * @param string $type Type of formatting<br>
437
	 *                     s - seconds<br>m - minutes<br>h - hours<br>d - days<br>M - months<br>y - years
438
	 *
439
	 * @return string
440
	 */
441 12
	public function time ($in, $type) {
442 12
		if (is_callable($this->time)) {
443
			$time = $this->time;
444
			return $time($in, $type);
445
		}
446
		$types = [
447 12
			's' => $this->system_time_seconds,
0 ignored issues
show
Bug Best Practice introduced by
The property system_time_seconds does not exist on cs\Language. Since you implemented __get, consider adding a @property annotation.
Loading history...
448 12
			'm' => $this->system_time_minutes,
0 ignored issues
show
Bug Best Practice introduced by
The property system_time_minutes does not exist on cs\Language. Since you implemented __get, consider adding a @property annotation.
Loading history...
449 12
			'h' => $this->system_time_hours,
0 ignored issues
show
Bug Best Practice introduced by
The property system_time_hours does not exist on cs\Language. Since you implemented __get, consider adding a @property annotation.
Loading history...
450 12
			'd' => $this->system_time_days,
0 ignored issues
show
Bug Best Practice introduced by
The property system_time_days does not exist on cs\Language. Since you implemented __get, consider adding a @property annotation.
Loading history...
451 12
			'M' => $this->system_time_months,
0 ignored issues
show
Bug Best Practice introduced by
The property system_time_months does not exist on cs\Language. Since you implemented __get, consider adding a @property annotation.
Loading history...
452 12
			'y' => $this->system_time_years
0 ignored issues
show
Bug Best Practice introduced by
The property system_time_years does not exist on cs\Language. Since you implemented __get, consider adding a @property annotation.
Loading history...
453
		];
454 12
		return $in.' '.$types[$type];
455
	}
456
	/**
457
	 * Allows to use formatted strings in translations
458
	 *
459
	 * @see format()
460
	 *
461
	 * @param string $item
462
	 * @param array  $arguments
463
	 *
464
	 * @return string
465
	 */
466 3
	public function __call ($item, $arguments) {
467 3
		return $this->format($item, $arguments);
468
	}
469
	/**
470
	 * Allows to use formatted strings in translations
471
	 *
472
	 * @param string       $item
473
	 * @param string[]     $arguments
474
	 * @param false|string $language If specified - translation for specified language will be returned, otherwise for current
475
	 * @param string       $prefix   Used by `\cs\Language\Prefix`, usually no need to use it directly
476
	 *
477
	 * @return string
478
	 */
479 12
	public function format ($item, $arguments, $language = false, $prefix = '') {
480 12
		return vsprintf($this->get($item, $language, $prefix), $arguments);
481
	}
482
	/**
483
	 * Formatting date according to language locale (translating months names, days of week, etc.)
484
	 *
485
	 * @param string|string[] $data
486
	 * @param bool            $short_may When in date() or similar functions "M" format option is used, third month "May" have the same short textual
487
	 *                                   representation as full, so, this option allows to specify, which exactly form of representation do you want
488
	 *
489
	 * @return string|string[]
490
	 */
491 3
	public function to_locale ($data, $short_may = false) {
492 3
		if (is_array($data)) {
493
			return array_map_arguments([$this, 'to_locale'], $data, $short_may);
0 ignored issues
show
Bug introduced by
$short_may of type boolean is incompatible with the type array expected by parameter $additional_arguments of array_map_arguments(). ( Ignorable by Annotation )

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

493
			return array_map_arguments([$this, 'to_locale'], $data, /** @scrutinizer ignore-type */ $short_may);
Loading history...
494
		}
495 3
		if ($short_may) {
496
			$data = str_replace('May', 'May_short', $data);
497
		}
498
		$from = [
499 3
			'January',
500
			'February',
501
			'March',
502
			'April',
503
			'May_short',
504
			'June',
505
			'July',
506
			'August',
507
			'September',
508
			'October',
509
			'November',
510
			'December',
511
			'Jan',
512
			'Feb',
513
			'Mar',
514
			'Apr',
515
			'May',
516
			'Jun',
517
			'Jul',
518
			'Aug',
519
			'Sep',
520
			'Oct',
521
			'Nov',
522
			'Dec',
523
			'Sunday',
524
			'Monday',
525
			'Tuesday',
526
			'Wednesday',
527
			'Thursday',
528
			'Friday',
529
			'Saturday',
530
			'Sun',
531
			'Mon',
532
			'Tue',
533
			'Wed',
534
			'Thu',
535
			'Fri',
536
			'Sat'
537
		];
538 3
		foreach ($from as $f) {
539 3
			$data = str_replace($f, $this->get("l_$f"), $data);
540
		}
541 3
		return $data;
542
	}
543
	/**
544
	 * Implementation of JsonSerializable interface
545
	 *
546
	 * @return string[]
547
	 */
548 6
	public function jsonSerialize () {
549
		// Ensure translations were loaded
550 6
		$this->change($this->current_language);
551 6
		return $this->translation[$this->current_language];
552
	}
553
}
554