Completed
Push — master ( b962b5...e0bada )
by Nazar
04:35
created

Language::can_be_changed_to()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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