Completed
Push — master ( 312807...b11528 )
by Nazar
04:22
created

Language::change()   C

Complexity

Conditions 8
Paths 11

Size

Total Lines 32
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 8

Importance

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