Completed
Push — master ( 3472af...45825f )
by Nazar
03:51
created

Language::check_locale_header()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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