Language::__set()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

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

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