Completed
Push — master ( 69a790...3f79ab )
by Nazar
04:58
created

Language::init_internal()   B

Complexity

Conditions 6
Paths 18

Size

Total Lines 22
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 6
eloc 9
c 1
b 1
f 0
nc 18
nop 0
dl 0
loc 22
ccs 10
cts 10
cp 1
crap 6
rs 8.6737
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 10
			$language = User::instance(true)->language;
96
			/**
97
			 * Highest priority - `-Locale` header
98
			 */
99
			/** @noinspection PhpParamsInspection */
100 10
			$language = $language ?: $this->check_locale_header($Config->core['active_languages']);
101
			/**
102
			 * Second priority - URL
103
			 */
104 10
			$language = $language ?: $this->url_language(Request::instance()->path);
105
			/**
106
			 * Third - `Accept-Language` header
107
			 */
108
			/** @noinspection PhpParamsInspection */
109 10
			$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 4
			if (count($this->localized_url) > 100) {
143
				$this->localized_url = [];
144
			}
145 4
			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 6
	protected function check_accept_header ($active_languages) {
157 6
		$aliases          = $this->get_aliases();
158 6
		$accept_languages = array_filter(
159
			explode(
160 6
				',',
161
				strtolower(
162 6
					strtr(Request::instance()->header('accept-language'), '-', '_')
163
				)
164
			)
165
		);
166 6
		foreach ($accept_languages as $language) {
167 4
			$language = explode(';', $language, 2)[0];
168 4
			if (@in_array($aliases[$language], $active_languages)) {
169 4
				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 10
	protected function check_locale_header ($active_languages) {
182 10
		$aliases = $this->get_aliases();
183
		/**
184
		 * For `X-Facebook-Locale` and other similar
185
		 */
186 10
		foreach (Request::instance()->headers ?: [] as $i => $v) {
187 8
			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 8
				return false;
193
			}
194
		}
195 8
		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
	 * @return string
283
	 */
284
	public function __set ($item, $value = null) {
285
		$this->set($item, $value);
286
	}
287
	/**
288
	 * Change language
289
	 *
290
	 * @param string $language
291
	 *
292
	 * @return bool
293
	 */
294 54
	public function change ($language) {
295
		/**
296
		 * Already set to specified language
297
		 */
298 54
		if ($language == $this->current_language && isset($this->translation[$language])) {
299 50
			return true;
300
		}
301 54
		$Config = Config::instance(true);
302
		/**
303
		 * @var string $language
304
		 */
305 54
		$language = $language ?: $Config->core['language'];
306 54
		if (!$this->can_be_changed_to($Config, $language)) {
307 2
			return false;
308
		}
309 54
		$Event = Event::instance();
310 54
		$Event->fire('System/Language/change/before');
311 54
		if (!isset($this->translation[$language])) {
312 54
			$this->translation[$language] = $this->get_translation($language);
313
		}
314
		/**
315
		 * Change current language to `$language`
316
		 */
317 54
		$this->current_language = $language;
318 54
		_include(LANGUAGES."/$language.php", false, false);
319 54
		if ($Config->core['multilingual']) {
320 20
			Response::instance()->header('content-language', $this->content_language);
321
		}
322 54
		$Event->fire('System/Language/change/after');
323 54
		return true;
324
	}
325
	/**
326
	 * Check whether it is allowed to change to specified language according to configuration
327
	 *
328
	 * @param Config $Config
329
	 * @param string $language
330
	 *
331
	 * @return bool
332
	 */
333 54
	protected function can_be_changed_to ($Config, $language) {
334 54
		if (!$language) {
335
			return false;
336
		}
337
		return
338
			// Config not loaded yet
339 54
			!$Config->core ||
340
			// Set to language that is configured on system level
341 54
			$language == $Config->core['language'] ||
342
			// Set to active language
343
			(
344 18
				$Config->core['multilingual'] &&
345 54
				in_array($language, $Config->core['active_languages'])
346
			);
347
	}
348 54
	protected function get_translation ($language) {
349 54
		return Cache::instance()->get(
350 54
			"languages/$language",
351 54
			function () use ($language) {
352 10
				return $this->get_translation_internal($language);
353 54
			}
354
		);
355
	}
356
	/**
357
	 * Load translation from all over the system, set `$this->translation[$language]` and return it
358
	 *
359
	 * @param $language
360
	 *
361
	 * @return string[]
362
	 */
363 10
	protected function get_translation_internal ($language) {
364
		/**
365
		 * Get current system translations
366
		 */
367 10
		$translation = $this->get_translation_from_json(LANGUAGES."/$language.json");
368 10
		$translation = $this->fill_required_translation_keys($translation, $language);
369
		/**
370
		 * Set modules' translations
371
		 */
372 10
		foreach (get_files_list(MODULES, false, 'd', true) as $module_dir) {
373 10
			if (file_exists("$module_dir/languages/$language.json")) {
374 10
				$translation = $this->get_translation_from_json("$module_dir/languages/$language.json") + $translation;
375
			}
376
		}
377 10
		Event::instance()->fire(
378 10
			'System/Language/load',
379
			[
380 10
				'clanguage' => $language,
381 10
				'clang'     => $translation['clang'],
382 10
				'cregion'   => $translation['cregion']
383
			]
384
		);
385
		// TODO: Remove in 6.x
386 10
		Event::instance()->fire(
387 10
			'System/general/languages/load',
388
			[
389 10
				'clanguage' => $language,
390 10
				'clang'     => $translation['clang'],
391 10
				'cregion'   => $translation['cregion']
392
			]
393
		);
394
		/**
395
		 * Append translations from core language to fill potentially missing keys
396
		 */
397 10
		$core_language = Core::instance()->language;
398 10
		if ($language != $core_language) {
399 6
			$translation += $this->get_translation($core_language);
400
		}
401 10
		return $translation;
402
	}
403
	/**
404
	 * @param string $filename
405
	 *
406
	 * @return string[]
407
	 */
408 10
	protected function get_translation_from_json ($filename) {
409 10
		return $this->get_translation_from_json_internal(
410
			file_get_json_nocomments($filename)
411
		);
412
	}
413
	/**
414
	 * @param string[]|string[][] $translation
415
	 *
416
	 * @return string[]
417
	 */
418 10
	protected function get_translation_from_json_internal ($translation) {
419
		// Nested structure processing
420 10
		foreach ($translation as $item => $value) {
421 10
			if (is_array_assoc($value)) {
422 10
				unset($translation[$item]);
423 10
				foreach ($value as $sub_item => $sub_value) {
424 10
					$translation[$item.$sub_item] = $sub_value;
425
				}
426 10
				return $this->get_translation_from_json_internal($translation);
427
			}
428
		}
429 10
		return $translation;
430
	}
431
	/**
432
	 * Some required keys might be missing in translation, this functions tries to guess and fill them automatically
433
	 *
434
	 * @param string[] $translation
435
	 * @param string   $language
436
	 *
437
	 * @return string[]
438
	 */
439 10
	protected function fill_required_translation_keys ($translation, $language) {
440
		$translation += [
441 10
			'clanguage' => $language,
442 10
			'clang'     => mb_strtolower(mb_substr($language, 0, 2)),
443 10
			'clocale'   => $translation['clang'].'_'.mb_strtoupper($translation['cregion'])
444
		];
445
		$translation += [
446 10
			'content_language' => $translation['clang'],
447 10
			'cregion'          => $translation['clang']
448
		];
449 10
		return $translation;
450
	}
451
	/**
452
	 * Time formatting according to the current language (adding correct endings)
453
	 *
454
	 * @param int    $in          time (number)
455
	 * @param string $type        Type of formatting<br>
456
	 *                            s - seconds<br>m - minutes<br>h - hours<br>d - days<br>M - months<br>y - years
457
	 *
458
	 * @return string
459
	 */
460 4
	public function time ($in, $type) {
461 4
		if (is_callable($this->time)) {
462
			$time = $this->time;
463
			return $time($in, $type);
464
		} else {
465
			switch ($type) {
466 4
				case 's':
467 4
					return "$in $this->system_time_seconds";
468
				case 'm':
469
					return "$in $this->system_time_minutes";
470
				case 'h':
471
					return "$in $this->system_time_hours";
472
				case 'd':
473
					return "$in $this->system_time_days";
474
				case 'M':
475
					return "$in $this->system_time_months";
476
				case 'y':
477
					return "$in $this->system_time_years";
478
			}
479
		}
480
		return $in;
481
	}
482
	/**
483
	 * Allows to use formatted strings in translations
484
	 *
485
	 * @see format()
486
	 *
487
	 * @param string $item
488
	 * @param array  $arguments
489
	 *
490
	 * @return string
491
	 */
492 2
	public function __call ($item, $arguments) {
493 2
		return $this->format($item, $arguments);
494
	}
495
	/**
496
	 * Allows to use formatted strings in translations
497
	 *
498
	 * @param string       $item
499
	 * @param string[]     $arguments
500
	 * @param false|string $language If specified - translation for specified language will be returned, otherwise for current
501
	 * @param string       $prefix   Used by `\cs\Language\Prefix`, usually no need to use it directly
502
	 *
503
	 * @return string
504
	 */
505 6
	public function format ($item, $arguments, $language = false, $prefix = '') {
506 6
		return vsprintf($this->get($item, $language, $prefix), $arguments);
507
	}
508
	/**
509
	 * Formatting date according to language locale (translating months names, days of week, etc.)
510
	 *
511
	 * @param string|string[] $data
512
	 * @param bool            $short_may When in date() or similar functions "M" format option is used, third month "May" have the same short textual
513
	 *                                   representation as full, so, this option allows to specify, which exactly form of representation do you want
514
	 *
515
	 * @return string|string[]
516
	 */
517 2
	public function to_locale ($data, $short_may = false) {
518 2
		if (is_array($data)) {
519
			foreach ($data as &$item) {
520
				$item = $this->to_locale($item, $short_may);
521
			}
522
			return $data;
523
		}
524 2
		if ($short_may) {
525
			$data = str_replace('May', 'May_short', $data);
526
		}
527
		$from = [
528 2
			'January',
529
			'February',
530
			'March',
531
			'April',
532
			'May_short',
533
			'June',
534
			'July',
535
			'August',
536
			'September',
537
			'October',
538
			'November',
539
			'December',
540
			'Jan',
541
			'Feb',
542
			'Mar',
543
			'Apr',
544
			'May',
545
			'Jun',
546
			'Jul',
547
			'Aug',
548
			'Sep',
549
			'Oct',
550
			'Nov',
551
			'Dec',
552
			'Sunday',
553
			'Monday',
554
			'Tuesday',
555
			'Wednesday',
556
			'Thursday',
557
			'Friday',
558
			'Saturday',
559
			'Sun',
560
			'Mon',
561
			'Tue',
562
			'Wed',
563
			'Thu',
564
			'Fri',
565
			'Sat'
566
		];
567 2
		foreach ($from as $f) {
568 2
			$data = str_replace($f, $this->get("l_$f"), $data);
569
		}
570 2
		return $data;
571
	}
572
	/**
573
	 * Implementation of JsonSerializable interface
574
	 *
575
	 * @return string[]
576
	 */
577 4
	public function jsonSerialize () {
578 4
		return $this->translation[$this->current_language];
579
	}
580
}
581