Completed
Push — master ( 5c0dbc...42c9a9 )
by Nazar
04:27
created

Language::construct()   B

Complexity

Conditions 4
Paths 2

Size

Total Lines 29
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 4.0032

Importance

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