Completed
Push — master ( e929f3...7e5e6c )
by Nazar
05:31
created

Language::get_translation_internal()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 15
nc 6
nop 1
dl 0
loc 31
ccs 15
cts 15
cp 1
crap 4
rs 8.5806
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/Language/change/before
16
 *
17
 *  System/Language/change/after
18
 *
19
 *  System/Language/load
20
 *  [
21
 *   'clanguage'        => clanguage
22
 *   'clang'            => clang
23
 *   'cregion'          => cregion
24
 *  ]
25
 *
26
 * @method static $this instance($check = false)
27
 *
28
 * @property string $clanguage
29
 * @property string $clang
30
 * @property string $cregion
31
 * @property string $content_language
32
 * @property string $_datetime_long
33
 * @property string $_datetime
34
 * @property string $_date
35
 * @property string $_time
36
 */
37
class Language implements JsonSerializable {
38
	use
39
		Singleton;
40
	const INIT_STATE_METHOD = 'init';
41
	/**
42
	 * Callable for time processing
43
	 *
44
	 * @var callable
45
	 */
46
	public $time;
47
	/**
48
	 * @var string
49
	 */
50
	protected $current_language;
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 78
	protected function init () {
64
		/**
65
		 * Initialization: set default language based on system configuration and request-specific parameters
66
		 */
67 78
		$Config = Config::instance(true);
68
		/**
69
		 * We need Config for initialization
70
		 */
71 78
		if (!$Config) {
72
			Event::instance()->once(
73
				'System/Config/init/after',
74
				function () {
75
					$this->init_internal();
76
				}
77
			);
78
		} else {
79 78
			$this->init_internal();
80
		}
81
		/**
82
		 * Change language when configuration changes
83
		 */
84 78
		Event::instance()->on(
85 78
			'System/Config/changed',
86
			function () {
87 2
				$this->init_internal();
88 78
			}
89
		);
90 78
	}
91 78
	protected function init_internal () {
92 78
		$Config   = Config::instance();
93 78
		$language = '';
94 78
		if ($Config->core['multilingual']) {
95 12
			$language = User::instance(true)->language;
96
			/**
97
			 * Highest priority - `-Locale` header
98
			 */
99
			/** @noinspection PhpParamsInspection */
100 12
			$language = $language ?: $this->check_locale_header($Config->core['active_languages']);
101
			/**
102
			 * Second priority - URL
103
			 */
104 12
			$language = $language ?: $this->url_language(Request::instance()->path);
105
			/**
106
			 * Third - `Accept-Language` header
107
			 */
108
			/** @noinspection PhpParamsInspection */
109 12
			$language = $language ?: $this->check_accept_header($Config->core['active_languages']);
110
		}
111 78
		$this->current_language = $language ?: $Config->core['language'];
112 78
	}
113
	/**
114
	 * Returns instance for simplified work with translations, when using common prefix
115
	 *
116
	 * @param string $prefix
117
	 *
118
	 * @return Prefix
119
	 */
120 32
	public static function prefix ($prefix) {
121 32
		return new Prefix($prefix);
122
	}
123
	/**
124
	 * Does URL have language prefix
125
	 *
126
	 * @param false|string $url Relative url, `Request::instance()->path` by default
127
	 *
128
	 * @return false|string If there is language prefix - language will be returned, `false` otherwise
129
	 */
130 52
	public function url_language ($url = false) {
131
		/**
132
		 * @var string $url
133
		 */
134 52
		$url = $url ?: Request::instance()->path;
135 52
		if (isset($this->localized_url[$url])) {
136
			return $this->localized_url[$url];
137
		}
138 52
		$aliases = $this->get_aliases();
139 52
		$clang   = explode('?', $url, 2)[0];
140 52
		$clang   = explode('/', trim($clang, '/'), 2)[0];
141 52
		if (isset($aliases[$clang])) {
142 6
			if (count($this->localized_url) > 100) {
143
				$this->localized_url = [];
144
			}
145 6
			return $this->localized_url[$url] = $aliases[$clang];
146
		}
147 50
		return false;
148
	}
149
	/**
150
	 * Checking Accept-Language header for languages that exists in configuration
151
	 *
152
	 * @param array $active_languages
153
	 *
154
	 * @return false|string
155
	 */
156 8
	protected function check_accept_header ($active_languages) {
157 8
		$aliases          = $this->get_aliases();
158 8
		$accept_languages = array_filter(
159
			explode(
160 8
				',',
161
				strtolower(
162 8
					str_replace('-', '_', Request::instance()->header('accept-language'))
163
				)
164
			)
165
		);
166 8
		foreach ($accept_languages as $language) {
167 6
			$language = explode(';', $language, 2)[0];
168 6
			if (@in_array($aliases[$language], $active_languages)) {
169 6
				return $aliases[$language];
170
			}
171
		}
172 2
		return false;
173
	}
174
	/**
175
	 * Check `*-Locale` header (for instance, `X-Facebook-Locale`) that exists in configuration
176
	 *
177
	 * @param string[] $active_languages
178
	 *
179
	 * @return false|string
180
	 */
181 12
	protected function check_locale_header ($active_languages) {
182 12
		$aliases = $this->get_aliases();
183
		/**
184
		 * For `X-Facebook-Locale` and other similar
185
		 */
186 12
		foreach (Request::instance()->headers ?: [] as $i => $v) {
187 10
			if (stripos($i, '-locale') !== false) {
188 2
				$language = strtolower($v);
189 2
				if (@in_array($aliases[$language], $active_languages)) {
190 2
					return $aliases[$language];
191
				}
192 10
				return false;
193
			}
194
		}
195 10
		return false;
196
	}
197
	/**
198
	 * Get languages aliases
199
	 *
200
	 * @return array|false
201
	 */
202 52
	protected function get_aliases () {
203 52
		return Cache::instance()->get(
204 52
			'languages/aliases',
205
			function () {
206 4
				$aliases = [];
207
				/**
208
				 * @var string[] $aliases_list
209
				 */
210 4
				$aliases_list = _strtolower(get_files_list(LANGUAGES.'/aliases'));
211 4
				foreach ($aliases_list as $alias) {
212 4
					$aliases[$alias] = trim(file_get_contents(LANGUAGES."/aliases/$alias"));
213
				}
214 4
				return $aliases;
215 52
			}
216
		);
217
	}
218
	/**
219
	 * Get translation
220
	 *
221
	 * @param bool|string  $item
222
	 * @param false|string $language If specified - translation for specified language will be returned, otherwise for current
223
	 * @param string       $prefix   Used by `\cs\Language\Prefix`, usually no need to use it directly
224
	 *
225
	 * @return string
226
	 */
227 64
	public function get ($item, $language = false, $prefix = '') {
228
		/**
229
		 * Small optimization, we can actually return value without translations
230
		 */
231 64
		if ($item == 'clanguage' && $this->current_language && $language === false && !$prefix) {
232 54
			return $this->current_language;
233
		}
234 64
		$language = $language ?: $this->current_language;
235 64
		if (isset($this->translation[$language])) {
236 64
			$translation = $this->translation[$language];
237 64
			if (isset($translation[$prefix.$item])) {
238 64
				return $translation[$prefix.$item];
239 8
			} elseif (isset($translation[$item])) {
240 6
				return $translation[$item];
241
			}
242 2
			return ucfirst(str_replace('_', ' ', $item));
243
		}
244 60
		$current_language = $this->current_language;
245 60
		$this->change($language);
246 60
		$return = $this->get($item, $this->current_language, $prefix);
247 60
		$this->change($current_language);
248 60
		return $return;
249
	}
250
	/**
251
	 * Set translation
252
	 *
253
	 * @param array|string $item Item string, or key-value array
254
	 * @param null|string  $value
255
	 *
256
	 * @return void
257
	 */
258
	public function set ($item, $value = null) {
259
		$translate = &$this->translation[$this->current_language];
260
		if (is_array($item)) {
261
			$translate = $item + ($translate ?: []);
262
		} else {
263
			$translate[$item] = $value;
264
		}
265
	}
266
	/**
267
	 * Get translation
268
	 *
269
	 * @param string $item
270
	 *
271
	 * @return string
272
	 */
273 64
	public function __get ($item) {
274 64
		return $this->get($item);
275
	}
276
	/**
277
	 * Set translation
278
	 *
279
	 * @param array|string $item
280
	 * @param null|string  $value
281
	 */
282
	public 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 64
	public function change ($language) {
293
		/**
294
		 * Already set to specified language
295
		 */
296 64
		if ($language == $this->current_language && isset($this->translation[$language])) {
297 60
			return true;
298
		}
299 64
		$Config = Config::instance(true);
300
		/**
301
		 * @var string $language
302
		 */
303 64
		$language = $language ?: $Config->core['language'];
304 64
		if (!$this->can_be_changed_to($Config, $language)) {
305 4
			return false;
306
		}
307 64
		$Event = Event::instance();
308 64
		$Event->fire('System/Language/change/before');
309 64
		if (!isset($this->translation[$language])) {
310 64
			$this->translation[$language] = $this->get_translation($language);
311
		}
312
		/**
313
		 * Change current language to `$language`
314
		 */
315 64
		$this->current_language = $language;
316 64
		_include(LANGUAGES."/$language.php", false, false);
317 64
		$Request = Request::instance();
318 64
		if ($Request->regular_path && $Config->core['multilingual']) {
319 8
			Response::instance()->header('content-language', $this->content_language);
320
		}
321 64
		$Event->fire('System/Language/change/after');
322 64
		return true;
323
	}
324
	/**
325
	 * Check whether it is allowed to change to specified language according to configuration
326
	 *
327
	 * @param Config $Config
328
	 * @param string $language
329
	 *
330
	 * @return bool
331
	 */
332 64
	protected function can_be_changed_to ($Config, $language) {
333 64
		if (!$language) {
334
			return false;
335
		}
336
		return
337
			// Config not loaded yet
338 64
			!$Config->core ||
339
			// Set to language that is configured on system level
340 64
			$language == $Config->core['language'] ||
341
			// Set to active language
342
			(
343 22
				$Config->core['multilingual'] &&
344 64
				in_array($language, $Config->core['active_languages'])
345
			);
346
	}
347 64
	protected function get_translation ($language) {
348 64
		return Cache::instance()->get(
349 64
			"languages/$language",
350 64
			function () use ($language) {
351 10
				return $this->get_translation_internal($language);
352 64
			}
353
		);
354
	}
355
	/**
356
	 * Load translation from all over the system, set `$this->translation[$language]` and return it
357
	 *
358
	 * @param $language
359
	 *
360
	 * @return string[]
361
	 */
362 10
	protected function get_translation_internal ($language) {
363
		/**
364
		 * Get current system translations
365
		 */
366 10
		$translation = $this->get_translation_from_json(LANGUAGES."/$language.json");
367 10
		$translation = $this->fill_required_translation_keys($translation, $language);
368
		/**
369
		 * Set modules' translations
370
		 */
371 10
		foreach (get_files_list(MODULES, false, 'd', true) as $module_dir) {
372 10
			if (file_exists("$module_dir/languages/$language.json")) {
373 10
				$translation = $this->get_translation_from_json("$module_dir/languages/$language.json") + $translation;
374
			}
375
		}
376 10
		Event::instance()->fire(
377 10
			'System/Language/load',
378
			[
379 10
				'clanguage' => $language,
380 10
				'clang'     => $translation['clang'],
381 10
				'cregion'   => $translation['cregion']
382
			]
383
		);
384
		/**
385
		 * Append translations from core language to fill potentially missing keys
386
		 */
387 10
		$core_language = Core::instance()->language;
388 10
		if ($language != $core_language) {
389 6
			$translation += $this->get_translation($core_language);
390
		}
391 10
		return $translation;
392
	}
393
	/**
394
	 * @param string $filename
395
	 *
396
	 * @return string[]
397
	 */
398 10
	protected function get_translation_from_json ($filename) {
399 10
		return $this->get_translation_from_json_internal(
400
			file_get_json_nocomments($filename)
401
		);
402
	}
403
	/**
404
	 * @param string[]|string[][] $translation
405
	 *
406
	 * @return string[]
407
	 */
408 10
	protected function get_translation_from_json_internal ($translation) {
409
		// Nested structure processing
410 10
		foreach ($translation as $item => $value) {
411 10
			if (is_array_assoc($value)) {
412 10
				unset($translation[$item]);
413 10
				foreach ($value as $sub_item => $sub_value) {
414 10
					$translation[$item.$sub_item] = $sub_value;
415
				}
416 10
				return $this->get_translation_from_json_internal($translation);
417
			}
418
		}
419 10
		return $translation;
420
	}
421
	/**
422
	 * Some required keys might be missing in translation, this functions tries to guess and fill them automatically
423
	 *
424
	 * @param string[] $translation
425
	 * @param string   $language
426
	 *
427
	 * @return string[]
428
	 */
429 10
	protected function fill_required_translation_keys ($translation, $language) {
430
		$translation += [
431 10
			'clanguage' => $language,
432 10
			'clang'     => mb_strtolower(mb_substr($language, 0, 2)),
433 10
			'clocale'   => $translation['clang'].'_'.mb_strtoupper($translation['cregion'])
434
		];
435
		$translation += [
436 10
			'content_language' => $translation['clang'],
437 10
			'cregion'          => $translation['clang']
438
		];
439 10
		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 10
	public function time ($in, $type) {
451 10
		if (is_callable($this->time)) {
452
			$time = $this->time;
453
			return $time($in, $type);
454
		} else {
455
			switch ($type) {
456 10
				case 's':
457 4
					return "$in $this->system_time_seconds";
458 6
				case 'm':
459 2
					return "$in $this->system_time_minutes";
460 4
				case 'h':
461
					return "$in $this->system_time_hours";
462 4
				case 'd':
463 4
					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 2
	public function __call ($item, $arguments) {
483 2
		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 12
	public function format ($item, $arguments, $language = false, $prefix = '') {
496 12
		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 2
	public function to_locale ($data, $short_may = false) {
508 2
		if (is_array($data)) {
509
			foreach ($data as &$item) {
510
				$item = $this->to_locale($item, $short_may);
511
			}
512
			return $data;
513
		}
514 2
		if ($short_may) {
515
			$data = str_replace('May', 'May_short', $data);
516
		}
517
		$from = [
518 2
			'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 2
		foreach ($from as $f) {
558 2
			$data = str_replace($f, $this->get("l_$f"), $data);
559
		}
560 2
		return $data;
561
	}
562
	/**
563
	 * Implementation of JsonSerializable interface
564
	 *
565
	 * @return string[]
566
	 */
567 4
	public function jsonSerialize () {
568 4
		return $this->translation[$this->current_language];
569
	}
570
}
571