Completed
Push — master ( a8898a...5c0dbc )
by Nazar
04:15
created

Language::construct()   B

Complexity

Conditions 4
Paths 2

Size

Total Lines 29
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 19
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 29
ccs 13
cts 13
cp 1
crap 4
rs 8.5806
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 10
	protected function construct () {
67 10
		$Config                 = Config::instance(true);
68 10
		$Core                   = Core::instance();
69 10
		$this->current_language = $Core->language;
70
		/**
71
		 * We need Config for initialization
72
		 */
73 10
		if (!$Config) {
74 2
			Event::instance()->once(
75 2
				'System/Config/init/after',
76
				function () {
77
					$this->init();
78 2
				}
79
			);
80
		} else {
81 8
			$this->init();
82
		}
83 10
		Event::instance()->on(
84 10
			'System/Config/changed',
85
			function () {
86
				$Config = Config::instance();
87
				if ($Config->core['multilingual'] && User::instance(true)) {
88
					$this->current_language = User::instance()->language;
89
				} else {
90
					$this->current_language = $Config->core['language'];
91
				}
92 10
			}
93
		);
94 10
	}
95
	/**
96
	 * Initialization: set default language based on system configuration and request-specific parameters
97
	 */
98 8
	protected function init () {
99 8
		$Config = Config::instance();
100 8
		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
			$language = $Config->core['language'];
117
		}
118 8
		$this->current_language = $language ?: '';
119 8
	}
120
	/**
121
	 * Returns instance for simplified work with translations, when using common prefix
122
	 *
123
	 * @param string $prefix
124
	 *
125
	 * @return Prefix
126
	 */
127
	static function prefix ($prefix) {
128
		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 8
	function url_language ($url = false) {
138
		/**
139
		 * @var string $url
140
		 */
141 8
		$url = $url ?: Request::instance()->path;
142 8
		if (isset($this->localized_url[$url])) {
143
			return $this->localized_url[$url];
144
		}
145 8
		$aliases = $this->get_aliases();
146 8
		$clang   = explode('?', $url, 2)[0];
147 8
		$clang   = explode('/', trim($clang, '/'), 2)[0];
148 8
		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 6
		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 8
	protected function get_aliases () {
210 8
		return Cache::instance()->get(
211 8
			'languages/aliases',
212
			function () {
213 2
				$aliases      = [];
214 2
				$aliases_list = _strtolower(get_files_list(LANGUAGES.'/aliases'));
215 2
				foreach ($aliases_list as $alias) {
216 2
					$aliases[$alias] = trim(file_get_contents(LANGUAGES."/aliases/$alias"));
217
				}
218 2
				return $aliases;
219 8
			}
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 10
	function get ($item, $language = false, $prefix = '') {
232 10
		$language = $language ?: $this->current_language;
233 10
		if (isset($this->translation[$language])) {
234 10
			$translation = $this->translation[$language];
235 10
			if (isset($translation[$prefix.$item])) {
236 10
				return $translation[$prefix.$item];
237 4
			} elseif (isset($translation[$item])) {
238 2
				return $translation[$item];
239
			}
240 2
			return ucfirst(str_replace('_', ' ', $item));
241
		}
242 10
		$current_language = $this->current_language;
243 10
		$this->change($language);
244 10
		$return = $this->get($item, $this->current_language, $prefix);
245 10
		$this->change($current_language);
246 10
		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 10
	function __get ($item) {
272 10
		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 10
	function change ($language) {
293
		/**
294
		 * Already set to specified language
295
		 */
296 10
		if ($language == $this->current_language && isset($this->translation[$language])) {
297 10
			return true;
298
		}
299 10
		$Config = Config::instance(true);
300
		/**
301
		 * @var string $language
302
		 */
303 10
		$language = $language ?: $Config->core['language'];
304 10
		if (!$this->can_be_changed_to($Config, $language)) {
305
			return false;
306
		}
307 10
		if (!isset($this->translation[$language])) {
308 10
			$this->translation[$language] = $this->get_translation($language);
309
		}
310
		/**
311
		 * Change current language to `$language`
312
		 */
313 10
		$this->current_language = $language;
314 10
		_include(LANGUAGES."/$language.php", false, false);
315 10
		if ($Config->core['multilingual']) {
316 8
			Response::instance()->header('content-language', $this->content_language);
317
		}
318 10
		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 10
	protected function can_be_changed_to ($Config, $language) {
329 10
		if (!$language) {
330
			return false;
331
		}
332
		return
333
			// Config not loaded yet
334 10
			!$Config->core ||
335
			// Set to language that is configured on system level
336 8
			$language == $Config->core['language'] ||
337
			// Set to active language
338
			(
339 8
				$Config->core['multilingual'] &&
340 10
				in_array($language, $Config->core['active_languages'])
341
			);
342
	}
343 10
	protected function get_translation ($language) {
344 10
		return Cache::instance()->get(
345 10
			"languages/$language",
346 10
			function () use ($language) {
347 2
				return $this->get_translation_internal($language);
348 10
			}
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 2
	protected function get_translation_internal ($language) {
359
		/**
360
		 * Get current system translations
361
		 */
362 2
		$translation = $this->get_translation_from_json(LANGUAGES."/$language.json");
363 2
		$translation = $this->fill_required_translation_keys($translation, $language);
364
		/**
365
		 * Set modules' translations
366
		 */
367 2
		foreach (get_files_list(MODULES, false, 'd', true) as $module_dir) {
368 2
			if (file_exists("$module_dir/languages/$language.json")) {
369 2
				$translation = $this->get_translation_from_json("$module_dir/languages/$language.json") + $translation;
370
			}
371
		}
372 2
		Event::instance()->fire(
373 2
			'System/general/languages/load',
374
			[
375 2
				'clanguage' => $language,
376 2
				'clang'     => $translation['clang'],
377 2
				'cregion'   => $translation['cregion']
378
			]
379
		);
380
		/**
381
		 * Append translations from core language to fill potentially missing keys
382
		 */
383 2
		$core_language = Core::instance()->language;
384 2
		if ($language != $core_language) {
385 2
			$translation += $this->get_translation($core_language);
386
		}
387 2
		return $translation;
388
	}
389
	/**
390
	 * @param string $filename
391
	 *
392
	 * @return string[]
393
	 */
394 2
	protected function get_translation_from_json ($filename) {
395 2
		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 2
	protected function get_translation_from_json_internal ($translation) {
405
		// Nested structure processing
406 2
		foreach ($translation as $item => $value) {
407 2
			if (is_array_assoc($value)) {
408 2
				unset($translation[$item]);
409 2
				foreach ($value as $sub_item => $sub_value) {
410 2
					$translation[$item.$sub_item] = $sub_value;
411
				}
412 2
				return $this->get_translation_from_json_internal($translation);
413
			}
414
		}
415 2
		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 2
	protected function fill_required_translation_keys ($translation, $language) {
426
		$translation += [
427 2
			'clanguage' => $language,
428 2
			'clang'     => mb_strtolower(mb_substr($language, 0, 2)),
429 2
			'clocale'   => $translation['clang'].'_'.mb_strtoupper($translation['cregion'])
430
		];
431
		$translation += [
432 2
			'content_language' => $translation['clang'],
433 2
			'cregion'          => $translation['clang']
434
		];
435 2
		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 4
	function format ($item, $arguments, $language = false, $prefix = '') {
492 4
		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
	function jsonSerialize () {
564
		return $this->translation[$this->current_language];
565
	}
566
}
567