1<?php
2
3namespace Punic;
4
5/**
6 * Common data helper stuff.
7 */
8class Data
9{
10    /**
11     * Let's cache already loaded files (locale-specific).
12     *
13     * @var array
14     */
15    protected static $cache = array();
16
17    /**
18     * Let's cache already loaded files (not locale-specific).
19     *
20     * @var array
21     */
22    protected static $cacheGeneric = array();
23
24    /**
25     * The current default locale.
26     *
27     * @var string
28     */
29    protected static $defaultLocale = 'en_US';
30
31    /**
32     * The fallback locale (used if default locale is not found).
33     *
34     * @var string
35     */
36    protected static $fallbackLocale = 'en_US';
37
38    /**
39     * Return the current default locale.
40     *
41     * @return string
42     */
43    public static function getDefaultLocale()
44    {
45        return static::$defaultLocale;
46    }
47
48    /**
49     * Return the current default language.
50     *
51     * @return string
52     */
53    public static function getDefaultLanguage()
54    {
55        $info = static::explodeLocale(static::$defaultLocale);
56
57        return $info['language'];
58    }
59
60    /**
61     * Set the current default locale and language.
62     *
63     * @param string $locale
64     *
65     * @throws \Punic\Exception\InvalidLocale Throws an exception if $locale is not a valid string
66     */
67    public static function setDefaultLocale($locale)
68    {
69        if (static::explodeLocale($locale) === null) {
70            throw new Exception\InvalidLocale($locale);
71        }
72        static::$defaultLocale = $locale;
73    }
74
75    /**
76     * Return the current fallback locale (used if default locale is not found).
77     *
78     * @return string
79     */
80    public static function getFallbackLocale()
81    {
82        return static::$fallbackLocale;
83    }
84
85    /**
86     * Return the current fallback language (used if default locale is not found).
87     *
88     * @return string
89     */
90    public static function getFallbackLanguage()
91    {
92        $info = static::explodeLocale(static::$fallbackLocale);
93
94        return $info['language'];
95    }
96
97    /**
98     * Set the current fallback locale and language.
99     *
100     * @param string $locale
101     *
102     * @throws \Punic\Exception\InvalidLocale Throws an exception if $locale is not a valid string
103     */
104    public static function setFallbackLocale($locale)
105    {
106        if (static::explodeLocale($locale) === null) {
107            throw new Exception\InvalidLocale($locale);
108        }
109        if (static::$fallbackLocale !== $locale) {
110            static::$fallbackLocale = $locale;
111            static::$cache = array();
112        }
113    }
114
115    /**
116     * Get the locale data.
117     *
118     * @param string $identifier The data identifier
119     * @param string $locale The locale identifier (if empty we'll use the current default locale)
120     *
121     * @return array
122     *
123     * @throws \Punic\Exception Throws an exception in case of problems
124     *
125     * @internal
126     */
127    public static function get($identifier, $locale = '')
128    {
129        if (!(is_string($identifier) && isset($identifier[0]))) {
130            throw new Exception\InvalidDataFile($identifier);
131        }
132        if (empty($locale)) {
133            $locale = static::$defaultLocale;
134        }
135        if (!isset(static::$cache[$locale])) {
136            static::$cache[$locale] = array();
137        }
138        if (!isset(static::$cache[$locale][$identifier])) {
139            if (!@preg_match('/^[a-zA-Z0-9_\\-]+$/', $identifier)) {
140                throw new Exception\InvalidDataFile($identifier);
141            }
142            $dir = static::getLocaleFolder($locale);
143            if (!isset($dir[0])) {
144                throw new Exception\DataFolderNotFound($locale, static::$fallbackLocale);
145            }
146            $file = $dir.DIRECTORY_SEPARATOR.$identifier.'.json';
147            if (!is_file(__DIR__.DIRECTORY_SEPARATOR.$file)) {
148                throw new Exception\DataFileNotFound($identifier, $locale, static::$fallbackLocale);
149            }
150            $json = @file_get_contents(__DIR__.DIRECTORY_SEPARATOR.$file);
151            //@codeCoverageIgnoreStart
152            // In test enviro we can't replicate this problem
153            if ($json === false) {
154                throw new Exception\DataFileNotReadable($file);
155            }
156            //@codeCoverageIgnoreEnd
157            $data = @json_decode($json, true);
158            //@codeCoverageIgnoreStart
159            // In test enviro we can't replicate this problem
160            if (!is_array($data)) {
161                throw new Exception\BadDataFileContents($file, $json);
162            }
163            //@codeCoverageIgnoreEnd
164            static::$cache[$locale][$identifier] = $data;
165        }
166
167        return static::$cache[$locale][$identifier];
168    }
169
170    /**
171     * Get the generic data.
172     *
173     * @param string $identifier The data identifier
174     *
175     * @return array
176     *
177     * @throws Exception Throws an exception in case of problems
178     *
179     * @internal
180     */
181    public static function getGeneric($identifier)
182    {
183        if (!(is_string($identifier) && isset($identifier[0]))) {
184            throw new Exception\InvalidDataFile($identifier);
185        }
186        if (isset(static::$cacheGeneric[$identifier])) {
187            return static::$cacheGeneric[$identifier];
188        }
189        if (!preg_match('/^[a-zA-Z0-9_\\-]+$/', $identifier)) {
190            throw new Exception\InvalidDataFile($identifier);
191        }
192        $file = 'data'.DIRECTORY_SEPARATOR."$identifier.json";
193        if (!is_file(__DIR__.DIRECTORY_SEPARATOR.$file)) {
194            throw new Exception\DataFileNotFound($identifier);
195        }
196        $json = @file_get_contents(__DIR__.DIRECTORY_SEPARATOR.$file);
197        //@codeCoverageIgnoreStart
198        // In test enviro we can't replicate this problem
199        if ($json === false) {
200            throw new Exception\DataFileNotReadable($file);
201        }
202        //@codeCoverageIgnoreEnd
203        $data = @json_decode($json, true);
204        //@codeCoverageIgnoreStart
205        // In test enviro we can't replicate this problem
206        if (!is_array($data)) {
207            throw new Exception\BadDataFileContents($file, $json);
208        }
209        //@codeCoverageIgnoreEnd
210        static::$cacheGeneric[$identifier] = $data;
211
212        return $data;
213    }
214
215    /**
216     * Return a list of available locale identifiers.
217     *
218     * @param bool $allowGroups Set to true if you want to retrieve locale groups (eg. 'en-001'), false otherwise
219     *
220     * @return array
221     */
222    public static function getAvailableLocales($allowGroups = false)
223    {
224        $locales = array();
225        $dir = __DIR__.DIRECTORY_SEPARATOR.'data';
226        if (is_dir($dir) && is_readable($dir)) {
227            $contents = @scandir($dir);
228            if (is_array($contents)) {
229                foreach (array_diff($contents, array('.', '..')) as $item) {
230                    if (is_dir($dir.DIRECTORY_SEPARATOR.$item)) {
231                        if ($item === 'root') {
232                            $item = 'en-US';
233                        }
234                        $info = static::explodeLocale($item);
235                        if (is_array($info)) {
236                            if ((!$allowGroups) && preg_match('/^[0-9]{3}$/', $info['territory'])) {
237                                foreach (Territory::getChildTerritoryCodes($info['territory'], true) as $territory) {
238                                    if (isset($info['script'][0])) {
239                                        $locales[] = "{$info['language']}-{$info['script']}-$territory";
240                                    } else {
241                                        $locales[] = "{$info['language']}-$territory";
242                                    }
243                                }
244                                $locales[] = $item;
245                            } else {
246                                $locales[] = $item;
247                            }
248                        }
249                    }
250                }
251            }
252        }
253
254        return $locales;
255    }
256
257    /**
258     * Try to guess the full locale (with script and territory) ID associated to a language.
259     *
260     * @param string $language The language identifier (if empty we'll use the current default language)
261     * @param string $script The script identifier (if $language is empty we'll use the current default script)
262     *
263     * @return string Returns an empty string if the territory was not found, the territory ID otherwise
264     */
265    public static function guessFullLocale($language = '', $script = '')
266    {
267        $result = '';
268        if (empty($language)) {
269            $defaultInfo = static::explodeLocale(static::$defaultLocale);
270            $language = $defaultInfo['language'];
271            $script = $defaultInfo['script'];
272        }
273        $data = static::getGeneric('likelySubtags');
274        $keys = array();
275        if (!empty($script)) {
276            $keys[] = "$language-$script";
277        }
278        $keys[] = $language;
279        foreach ($keys as $key) {
280            if (isset($data[$key])) {
281                $result = $data[$key];
282                if (isset($script[0]) && (stripos($result, "$language-$script-") !== 0)) {
283                    $parts = static::explodeLocale($result);
284                    if ($parts !== null) {
285                        $result = "{$parts['language']}-$script-{$parts['territory']}";
286                    }
287                }
288                break;
289            }
290        }
291
292        return $result;
293    }
294
295    /**
296     * Return the terrotory associated to the locale (guess it if it's not present in $locale).
297     *
298     * @param string $locale The locale identifier (if empty we'll use the current default locale)
299     * @param bool $checkFallbackLocale Set to true to check the fallback locale if $locale (or the default locale) don't have an associated territory, false to don't fallback to fallback locale
300     *
301     * @return string
302     */
303    public static function getTerritory($locale = '', $checkFallbackLocale = true)
304    {
305        $result = '';
306        if (empty($locale)) {
307            $locale = static::$defaultLocale;
308        }
309        $info = static::explodeLocale($locale);
310        if (is_array($info)) {
311            if (!isset($info['territory'][0])) {
312                $fullLocale = static::guessFullLocale($info['language'], $info['script']);
313                if (strlen($fullLocale)) {
314                    $info = static::explodeLocale($fullLocale);
315                }
316            }
317            if (isset($info['territory'][0])) {
318                $result = $info['territory'];
319            } elseif ($checkFallbackLocale) {
320                $result = static::getTerritory(static::$fallbackLocale, false);
321            }
322        }
323
324        return $result;
325    }
326
327    /**
328     * @deprecated
329     */
330    protected static function getParentTerritory($territory)
331    {
332        return Territory::getParentTerritoryCode($territory);
333    }
334
335    /**
336     * @deprecated
337     */
338    protected static function expandTerritoryGroup($parentTerritory)
339    {
340        return Territory::getChildTerritoryCodes($parentTerritory, true);
341    }
342
343    /**
344     * Return the node associated to the locale territory.
345     *
346     * @param array $data The parent array for which you want the territory node
347     * @param string $locale The locale identifier (if empty we'll use the current default locale)
348     *
349     * @return mixed Returns null if the node was not found, the node data otherwise
350     *
351     * @internal
352     */
353    public static function getTerritoryNode($data, $locale = '')
354    {
355        $result = null;
356        $territory = static::getTerritory($locale);
357        while (isset($territory[0])) {
358            if (isset($data[$territory])) {
359                $result = $data[$territory];
360                break;
361            }
362            $territory = Territory::getParentTerritoryCode($territory);
363        }
364
365        return $result;
366    }
367
368    /**
369     * Return the node associated to the language (not locale) territory.
370     *
371     * @param array $data The parent array for which you want the language node
372     * @param string $locale The locale identifier (if empty we'll use the current default locale)
373     *
374     * @return mixed Returns null if the node was not found, the node data otherwise
375     *
376     * @internal
377     */
378    public static function getLanguageNode($data, $locale = '')
379    {
380        $result = null;
381        if (empty($locale)) {
382            $locale = static::$defaultLocale;
383        }
384        foreach (static::getLocaleAlternatives($locale) as $l) {
385            if (isset($data[$l])) {
386                $result = $data[$l];
387                break;
388            }
389        }
390
391        return $result;
392    }
393
394    /**
395     * Returns the item of an array associated to a locale.
396     *
397     * @param array $data The data containing the locale info
398     * @param string $locale The locale identifier (if empty we'll use the current default locale)
399     *
400     * @return mixed Returns null if $data is not an array or it does not contain locale info, the array item otherwise
401     *
402     * @internal
403     */
404    public static function getLocaleItem($data, $locale = '')
405    {
406        $result = null;
407        if (is_array($data)) {
408            if (empty($locale)) {
409                $locale = static::$defaultLocale;
410            }
411            foreach (static::getLocaleAlternatives($locale) as $alternative) {
412                if (isset($data[$alternative])) {
413                    $result = $data[$alternative];
414                    break;
415                }
416            }
417        }
418
419        return $result;
420    }
421
422    /**
423     * Parse a string representing a locale and extract its components.
424     *
425     * @param string $locale
426     *
427     * @return null|string[] Return null if $locale is not valid; if $locale is valid returns an array with keys 'language', 'script', 'territory', 'parentLocale'
428     *
429     * @internal
430     */
431    public static function explodeLocale($locale)
432    {
433        $result = null;
434        if (is_string($locale)) {
435            if ($locale === 'root') {
436                $locale = 'en-US';
437            }
438            $chunks = explode('-', str_replace('_', '-', strtolower($locale)));
439            if (count($chunks) <= 3) {
440                if (preg_match('/^[a-z]{2,3}$/', $chunks[0])) {
441                    $language = $chunks[0];
442                    $script = '';
443                    $territory = '';
444                    $parentLocale = '';
445                    $ok = true;
446                    $chunkCount = count($chunks);
447                    for ($i = 1; $ok && ($i < $chunkCount); ++$i) {
448                        if (preg_match('/^[a-z]{4}$/', $chunks[$i])) {
449                            if (isset($script[0])) {
450                                $ok = false;
451                            } else {
452                                $script = ucfirst($chunks[$i]);
453                            }
454                        } elseif (preg_match('/^([a-z]{2})|([0-9]{3})$/', $chunks[$i])) {
455                            if (isset($territory[0])) {
456                                $ok = false;
457                            } else {
458                                $territory = strtoupper($chunks[$i]);
459                            }
460                        } else {
461                            $ok = false;
462                        }
463                    }
464                    if ($ok) {
465                        $parentLocales = static::getGeneric('parentLocales');
466                        if (isset($script[0]) && isset($territory[0]) && isset($parentLocales["$language-$script-$territory"])) {
467                            $parentLocale = $parentLocales["$language-$script-$territory"];
468                        } elseif (isset($script[0]) && isset($parentLocales["$language-$script"])) {
469                            $parentLocale = $parentLocales["$language-$script"];
470                        } elseif (isset($territory[0]) && isset($parentLocales["$language-$territory"])) {
471                            $parentLocale = $parentLocales["$language-$territory"];
472                        } elseif (isset($parentLocales[$language])) {
473                            $parentLocale = $parentLocales[$language];
474                        }
475                        $result = array(
476                            'language' => $language,
477                            'script' => $script,
478                            'territory' => $territory,
479                            'parentLocale' => $parentLocale,
480                        );
481                    }
482                }
483            }
484        }
485
486        return $result;
487    }
488
489    /**
490     * Returns the path of the locale-specific data, looking also for the fallback locale.
491     *
492     * @param string $locale The locale for which you want the data folder
493     *
494     * @return string Returns an empty string if the folder is not found, the absolute path to the folder otherwise
495     */
496    protected static function getLocaleFolder($locale)
497    {
498        static $cache = array();
499        $result = '';
500        if (is_string($locale)) {
501            $key = $locale.'/'.static::$fallbackLocale;
502            if (!isset($cache[$key])) {
503                foreach (static::getLocaleAlternatives($locale) as $alternative) {
504                    $dir = 'data'.DIRECTORY_SEPARATOR.$alternative;
505                    if (is_dir(__DIR__.DIRECTORY_SEPARATOR.$dir)) {
506                        $result = $dir;
507                        break;
508                    }
509                }
510                $cache[$key] = $result;
511            }
512            $result = $cache[$key];
513        }
514
515        return $result;
516    }
517
518    /**
519     * Returns a list of locale identifiers associated to a locale.
520     *
521     * @param string $locale The locale for which you want the alternatives
522     * @param string $addFallback Set to true to add the fallback locale to the result, false otherwise
523     *
524     * @return array
525     */
526    protected static function getLocaleAlternatives($locale, $addFallback = true)
527    {
528        $result = array();
529        $localeInfo = static::explodeLocale($locale);
530        if (!is_array($localeInfo)) {
531            throw new Exception\InvalidLocale($locale);
532        }
533        $language = $localeInfo['language'];
534        $script = $localeInfo['script'];
535        $territory = $localeInfo['territory'];
536        $parentLocale = $localeInfo['parentLocale'];
537        if (!isset($territory[0])) {
538            $fullLocale = static::guessFullLocale($language, $script);
539            if (isset($fullLocale[0])) {
540                $localeInfo = static::explodeLocale($fullLocale);
541                $language = $localeInfo['language'];
542                $script = $localeInfo['script'];
543                $territory = $localeInfo['territory'];
544                $parentLocale = $localeInfo['parentLocale'];
545            }
546        }
547        $territories = array();
548        while (isset($territory[0])) {
549            $territories[] = $territory;
550            $territory = Territory::getParentTerritoryCode($territory);
551        }
552        if (isset($script[0])) {
553            foreach ($territories as $territory) {
554                $result[] = "{$language}-{$script}-{$territory}";
555            }
556        }
557        if (isset($script[0])) {
558            $result[] = "{$language}-{$script}";
559        }
560        foreach ($territories as $territory) {
561            $result[] = "{$language}-{$territory}";
562            if ("{$language}-{$territory}" === 'en-US') {
563                $result[] = 'root';
564            }
565        }
566        if (isset($parentLocale[0])) {
567            $result = array_merge($result, static::getLocaleAlternatives($parentLocale, false));
568        }
569        $result[] = $language;
570        if ($addFallback && ($locale !== static::$fallbackLocale)) {
571            $result = array_merge($result, static::getLocaleAlternatives(static::$fallbackLocale, false));
572        }
573        for ($i = count($result) - 1; $i > 1; --$i) {
574            for ($j = 0; $j < $i; ++$j) {
575                if ($result[$i] === $result[$j]) {
576                    array_splice($result, $i, 1);
577                    break;
578                }
579            }
580        }
581        $i = array_search('root', $result, true);
582        if ($i !== false) {
583            array_splice($result, $i, 1);
584            $result[] = 'root';
585        }
586
587        return $result;
588    }
589}
590