Completed
Push — master ( ae87f2...118397 )
by Nazar
04:02
created

Language::check_locale_header()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
cc 5
eloc 9
c 0
b 0
f 0
nc 4
nop 1
dl 0
loc 16
rs 8.8571
ccs 0
cts 0
cp 0
crap 30
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
	protected function construct () {
67
		$Config                 = Config::instance(true);
68
		$Core                   = Core::instance();
69
		$this->current_language = $Core->language;
70
		/**
71
		 * We need Config for initialization
72
		 */
73
		if (!$Config) {
74
			Event::instance()->once(
75
				'System/Config/init/after',
76
				function () {
77
					$this->init();
78
				}
79
			);
80
		} else {
81
			$this->init();
82
		}
83
		Event::instance()->on(
84
			'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
			}
93
		);
94
	}
95
	/**
96
	 * Initialization: set default language based on system configuration and request-specific parameters
97
	 */
98
	protected function init () {
99
		$Config = Config::instance();
100
		if ($Config->core['multilingual']) {
101
			/**
102
			 * Highest priority - `-Locale` header
103
			 */
104
			/** @noinspection PhpParamsInspection */
105
			$language = $this->check_locale_header($Config->core['active_languages']);
106
			/**
107
			 * Second priority - URL
108
			 */
109
			$language = $language ?: $this->url_language(Request::instance()->path);
110
			/**
111
			 * Third - `Accept-Language` header
112
			 */
113
			/** @noinspection PhpParamsInspection */
114
			$language = $language ?: $this->check_accept_header($Config->core['active_languages']);
115
		} else {
116
			$language = $Config->core['language'];
117
		}
118
		$this->set_language($language ?: '');
119
	}
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 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
	protected function check_accept_header ($active_languages) {
164
		$aliases          = $this->get_aliases();
165
		$accept_languages = array_filter(
166
			explode(
167
				',',
168
				strtolower(
169
					strtr(Request::instance()->header('accept-language'), '-', '_')
170
				)
171
			)
172
		);
173
		foreach ($accept_languages as $language) {
174
			$language = explode(';', $language, 2)[0];
175
			if (@in_array($aliases[$language], $active_languages)) {
176
				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
	protected function check_locale_header ($active_languages) {
189
		$aliases = $this->get_aliases();
190
		/**
191
		 * For `X-Facebook-Locale` and other similar
192
		 */
193
		foreach (Request::instance()->headers ?: [] as $i => $v) {
194
			if (stripos($i, '-locale') !== false) {
195
				$language = strtolower($v);
196
				if (@in_array($aliases[$language], $active_languages)) {
197
					return $aliases[$language];
198
				}
199
				return false;
200
			}
201
		}
202
		return false;
203
	}
204
	/**
205
	 * Get languages aliases
206
	 *
207
	 * @return array|false
208
	 */
209
	protected function get_aliases () {
210
		return Cache::instance()->get(
211
			'languages/aliases',
212
			function () {
213
				$aliases      = [];
214
				$aliases_list = _strtolower(get_files_list(LANGUAGES.'/aliases'));
215
				foreach ($aliases_list as $alias) {
216
					$aliases[$alias] = trim(file_get_contents(LANGUAGES."/aliases/$alias"));
217
				}
218
				return $aliases;
219
			}
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
	function get ($item, $language = false, $prefix = '') {
232
		$language = $language ?: $this->current_language;
233
		if (isset($this->translation[$language])) {
234
			$translation = $this->translation[$language];
235
			if (isset($translation[$prefix.$item])) {
236
				return $translation[$prefix.$item];
237
			} elseif (isset($translation[$item])) {
238
				return $translation[$item];
239
			}
240
			return ucfirst(str_replace('_', ' ', $item));
241
		}
242
		$current_language = $this->current_language;
243
		$this->change($language);
244
		$return = $this->get($item, $this->current_language, $prefix);
245
		$this->change($current_language);
246
		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
	function __get ($item) {
272
		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
	 * Will set language, but not actually change to it until requested for the first time
287
	 *
288
	 * @param string $language
289
	 */
290
	protected function set_language ($language) {
291
		$this->current_language = $language;
292
	}
293
	/**
294
	 * Change language
295
	 *
296
	 * @param string $language
297
	 *
298
	 * @return bool
299
	 */
300
	function change ($language) {
301
		/**
302
		 * Already set to specified language
303
		 */
304
		if ($language == $this->current_language && isset($this->translation[$language])) {
305
			return true;
306
		}
307
		$Config = Config::instance(true);
308
		/**
309
		 * @var string $language
310
		 */
311
		$language = $language ?: $Config->core['language'];
312
		if (
313
			!$language ||
314
			!$this->can_be_changed_to($Config, $language)
315
		) {
316
			return false;
317
		}
318
		if (!isset($this->translation[$language])) {
319
			$Cache       = Cache::instance();
320
			$translation = $Cache->{"languages/$language"};
321
			if ($translation) {
322
				$this->translation[$language] = $translation;
323
			} else {
324
				/**
325
				 * `$this->get_translation()` will implicitly change `$this->translation`, so we do not need to assign new translation there manually
326
				 */
327
				$Cache->{"languages/$language"} = $this->get_translation($language);
328
			}
329
		}
330
		/**
331
		 * Change current language to `$language`
332
		 */
333
		$this->current_language = $language;
334
		_include(LANGUAGES."/$language.php", false, false);
335
		Response::instance()->header('content-language', $this->content_language);
336
		return true;
337
	}
338
	/**
339
	 * Check whether it is allowed to change to specified language according to configuration
340
	 *
341
	 * @param Config $Config
342
	 * @param string $language
343
	 *
344
	 * @return bool
345
	 */
346
	protected function can_be_changed_to ($Config, $language) {
347
		return
348
			// Config not loaded yet
349
			!$Config->core ||
350
			// Set to language that is configured on system level
351
			$language == $Config->core['language'] ||
352
			// Set to active language
353
			(
354
				$Config->core['multilingual'] &&
355
				in_array($language, $Config->core['active_languages'])
356
			);
357
	}
358
	/**
359
	 * Load translation from all over the system, set `$this->translation[$language]` and return it
360
	 *
361
	 * @param $language
362
	 *
363
	 * @return string[]
1 ignored issue
show
Documentation introduced by
Should the return type not be array<*,string>?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
364
	 */
365
	protected function get_translation ($language) {
366
		/**
367
		 * Get current system translations
368
		 */
369
		$translation = &$this->translation[$language];
370
		$translation = $this->get_translation_from_json(LANGUAGES."/$language.json");
371
		$translation = $this->fill_required_translation_keys($translation, $language);
372
		/**
373
		 * Set modules' translations
374
		 */
375
		foreach (get_files_list(MODULES, false, 'd', true) as $module_dir) {
376
			if (file_exists("$module_dir/languages/$language.json")) {
377
				$translation = $this->get_translation_from_json("$module_dir/languages/$language.json") + $translation;
378
			}
379
		}
380
		Event::instance()->fire(
381
			'System/general/languages/load',
382
			[
383
				'clanguage' => $language,
384
				'clang'     => $translation['clang'],
385
				'cregion'   => $translation['cregion']
386
			]
387
		);
388
		/**
389
		 * If current language was set - append its translation to fill potentially missing keys
390
		 */
391
		if ($this->current_language) {
392
			$translation = $translation + $this->translation[$this->current_language];
393
		}
394
		return $translation;
395
	}
396
	/**
397
	 * @param string $filename
398
	 *
399
	 * @return string[]
400
	 */
401
	protected function get_translation_from_json ($filename) {
402
		$translation = file_get_json_nocomments($filename);
403
		return $this->get_translation_from_json_internal($translation);
404
	}
405
	/**
406
	 * @param string[]|string[][] $translation
407
	 *
408
	 * @return string[]
409
	 */
410
	protected function get_translation_from_json_internal ($translation) {
411
		// Nested structure processing
412
		foreach ($translation as $item => $value) {
413
			if (is_array_assoc($value)) {
414
				unset($translation[$item]);
415
				foreach ($value as $sub_item => $sub_value) {
416
					$translation[$item.$sub_item] = $sub_value;
417
				}
418
				return $this->get_translation_from_json_internal($translation);
419
			}
420
		}
421
		return $translation;
422
	}
423
	/**
424
	 * Some required keys might be missing in translation, this functions tries to guess and fill them automatically
425
	 *
426
	 * @param string[] $translation
427
	 * @param string   $language
428
	 *
429
	 * @return string[]
1 ignored issue
show
Documentation introduced by
Should the return type not be array<*,string>?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
430
	 */
431
	protected function fill_required_translation_keys ($translation, $language) {
432
		$translation += [
433
			'clanguage' => $language,
434
			'clang'     => mb_strtolower(mb_substr($language, 0, 2)),
435
			'clocale'   => $translation['clang'].'_'.mb_strtoupper($translation['cregion'])
436
		];
437
		$translation += [
438
			'content_language' => $translation['clang'],
439
			'cregion'          => $translation['clang']
440
		];
441
		return $translation;
442
	}
443
	/**
444
	 * Time formatting according to the current language (adding correct endings)
445
	 *
446
	 * @param int    $in          time (number)
447
	 * @param string $type        Type of formatting<br>
448
	 *                            s - seconds<br>m - minutes<br>h - hours<br>d - days<br>M - months<br>y - years
449
	 *
450
	 * @return string
451
	 */
452
	function time ($in, $type) {
453
		if (is_callable($this->time)) {
454
			$time = $this->time;
455
			return $time($in, $type);
456
		} else {
457
			switch ($type) {
458
				case 's':
459
					return "$in $this->system_time_seconds";
460
				case 'm':
461
					return "$in $this->system_time_minutes";
462
				case 'h':
463
					return "$in $this->system_time_hours";
464
				case 'd':
465
					return "$in $this->system_time_days";
466
				case 'M':
467
					return "$in $this->system_time_months";
468
				case 'y':
469
					return "$in $this->system_time_years";
470
			}
471
		}
472
		return $in;
473
	}
474
	/**
475
	 * Allows to use formatted strings in translations
476
	 *
477
	 * @see format()
478
	 *
479
	 * @param string $item
480
	 * @param array  $arguments
481
	 *
482
	 * @return string
483
	 */
484
	function __call ($item, $arguments) {
485
		return $this->format($item, $arguments);
486
	}
487
	/**
488
	 * Allows to use formatted strings in translations
489
	 *
490
	 * @param string       $item
491
	 * @param string[]     $arguments
492
	 * @param false|string $language If specified - translation for specified language will be returned, otherwise for current
493
	 * @param string       $prefix   Used by `\cs\Language\Prefix`, usually no need to use it directly
494
	 *
495
	 * @return string
496
	 */
497
	function format ($item, $arguments, $language = false, $prefix = '') {
498
		return vsprintf($this->get($item, $language, $prefix), $arguments);
499
	}
500
	/**
501
	 * Formatting date according to language locale (translating months names, days of week, etc.)
502
	 *
503
	 * @param string|string[] $data
504
	 * @param bool            $short_may When in date() or similar functions "M" format option is used, third month "May" have the same short textual
505
	 *                                   representation as full, so, this option allows to specify, which exactly form of representation do you want
506
	 *
507
	 * @return string|string[]
508
	 */
509
	function to_locale ($data, $short_may = false) {
510
		if (is_array($data)) {
511
			foreach ($data as &$item) {
512
				$item = $this->to_locale($item, $short_may);
513
			}
514
			return $data;
515
		}
516
		if ($short_may) {
517
			$data = str_replace('May', 'May_short', $data);
518
		}
519
		$from = [
520
			'January',
521
			'February',
522
			'March',
523
			'April',
524
			'May_short',
525
			'June',
526
			'July',
527
			'August',
528
			'September',
529
			'October',
530
			'November',
531
			'December',
532
			'Jan',
533
			'Feb',
534
			'Mar',
535
			'Apr',
536
			'May',
537
			'Jun',
538
			'Jul',
539
			'Aug',
540
			'Sep',
541
			'Oct',
542
			'Nov',
543
			'Dec',
544
			'Sunday',
545
			'Monday',
546
			'Tuesday',
547
			'Wednesday',
548
			'Thursday',
549
			'Friday',
550
			'Saturday',
551
			'Sun',
552
			'Mon',
553
			'Tue',
554
			'Wed',
555
			'Thu',
556
			'Fri',
557
			'Sat'
558
		];
559
		foreach ($from as $f) {
560
			$data = str_replace($f, $this->get("l_$f"), $data);
561
		}
562
		return $data;
563
	}
564
	/**
565
	 * Implementation of JsonSerializable interface
566
	 *
567
	 * @return string[]
568
	 */
569
	function jsonSerialize () {
570
		return $this->translation[$this->current_language];
571
	}
572
}
573