1 <?php
  2 
  3 /**
  4  * Class for internationalization.
  5  *
  6  * @package redaxo\core
  7  */
  8 class rex_i18n
  9 {
 10     /**
 11      * @var string[]
 12      */
 13     private static $locales = [];
 14     /**
 15      * @var string[]
 16      */
 17     private static $directories = [];
 18     /**
 19      * @var boolean[string] Holds which locales are loaded. keyed by locale
 20      */
 21     private static $loaded = [];
 22     /**
 23      * @var string|null
 24      */
 25     private static $locale = null;
 26     /**
 27      * @var string[][]
 28      */
 29     private static $msg = [];
 30 
 31     /**
 32      * Switches the current locale.
 33      *
 34      * @param string $locale       The new locale
 35      * @param bool   $phpSetLocale When TRUE, php function setlocale() will be called
 36      *
 37      * @return string The last locale
 38      */
 39     public static function setLocale($locale, $phpSetLocale = true)
 40     {
 41         $saveLocale = self::$locale;
 42         self::$locale = $locale;
 43 
 44         if (empty(self::$loaded[$locale])) {
 45             self::loadAll($locale);
 46         }
 47 
 48         if ($phpSetLocale) {
 49             $locales = [];
 50             foreach (explode(',', trim(self::msg('setlocale'))) as $locale) {
 51                 $locales[] = $locale . '.UTF-8';
 52                 $locales[] = $locale . '.UTF8';
 53                 $locales[] = $locale . '.utf-8';
 54                 $locales[] = $locale . '.utf8';
 55                 $locales[] = $locale;
 56             }
 57 
 58             setlocale(LC_ALL, $locales);
 59         }
 60 
 61         return $saveLocale;
 62     }
 63 
 64     /**
 65      * Returns the current locale, e.g. de_de.
 66      *
 67      * @return string The current locale
 68      */
 69     public static function getLocale()
 70     {
 71         return self::$locale;
 72     }
 73 
 74     /**
 75      * Returns the current language, e.g. "de".
 76      *
 77      * @return string The current language
 78      */
 79     public static function getLanguage()
 80     {
 81         list($lang, $country) = explode('_', self::$locale, 2);
 82         return $lang;
 83     }
 84 
 85     /**
 86      * Adds a directory with lang files.
 87      *
 88      * @param string $dir Path to the directory
 89      */
 90     public static function addDirectory($dir)
 91     {
 92         $dir = rtrim($dir, DIRECTORY_SEPARATOR);
 93 
 94         if (in_array($dir, self::$directories, true)) {
 95             return;
 96         }
 97 
 98         self::$directories[] = $dir;
 99 
100         foreach (self::$loaded as $locale => $_) {
101             self::loadFile($dir, $locale);
102         }
103     }
104 
105     /**
106      * Returns the translation htmlspecialchared for the given key.
107      *
108      * @param string $key             A Language-Key
109      * @param string ...$replacements A arbritary number of strings used for interpolating within the resolved message
110      *
111      * @return string Translation for the key
112      */
113     public static function msg($key)
114     {
115         return self::getMsg($key, true, func_get_args());
116     }
117 
118     /**
119      * Returns the translation for the given key.
120      *
121      * @param string $key             A Language-Key
122      * @param string ...$replacements A arbritary number of strings used for interpolating within the resolved message
123      *
124      * @return string Translation for the key
125      */
126     public static function rawMsg($key)
127     {
128         return self::getMsg($key, false, func_get_args());
129     }
130 
131     /**
132      * Returns the translation htmlspecialchared for the given key and locale.
133      *
134      * @param string $key             A Language-Key
135      * @param string $locale          A Locale
136      * @param string ...$replacements A arbritary number of strings used for interpolating within the resolved message
137      *
138      * @return string Translation for the key
139      */
140     public static function msgInLocale($key, $locale)
141     {
142         $args = func_get_args();
143         $args[1] = $key;
144         // for BC we need to strip the 1st arg
145         array_shift($args);
146         return self::getMsg($key, true, $args, $locale);
147     }
148 
149     /**
150      * Returns the translation for the given key and locale.
151      *
152      * @param string $key             A Language-Key
153      * @param string $locale          A Locale
154      * @param string ...$replacements A arbritary number of strings used for interpolating within the resolved message
155      *
156      * @return string Translation for the key
157      */
158     public static function rawMsgInLocale($key, $locale)
159     {
160         $args = func_get_args();
161         $args[1] = $key;
162         // for BC we need to strip the 1st arg
163         array_shift($args);
164         return self::getMsg($key, false, $args, $locale);
165     }
166 
167     /**
168      * Returns the message fallback for a missing key in main locale.
169      *
170      * @param string $key
171      * @param array  $args
172      * @param string $locale A Locale
173      *
174      * @return string
175      */
176     private static function getMsgFallback($key, array $args, $locale)
177     {
178         $fallback = "[translate:$key]";
179 
180         $msg = rex_extension::registerPoint(new rex_extension_point('I18N_MISSING_TRANSLATION', $fallback, [
181             'key' => $key,
182             'args' => $args,
183         ]));
184 
185         if ($msg !== $fallback) {
186             return $msg;
187         }
188 
189         foreach (rex::getProperty('lang_fallback', []) as $fallbackLocale) {
190             if ($locale === $fallbackLocale) {
191                 continue;
192             }
193 
194             if (empty(self::$loaded[$fallbackLocale])) {
195                 self::loadAll($fallbackLocale);
196             }
197 
198             if (isset(self::$msg[$fallbackLocale][$key])) {
199                 return self::$msg[$fallbackLocale][$key];
200             }
201         }
202 
203         return $fallback;
204     }
205 
206     /**
207      * Checks if there is a translation for the given key.
208      *
209      * @param string $key Key
210      *
211      * @return bool TRUE on success, else FALSE
212      */
213     public static function hasMsg($key)
214     {
215         return isset(self::$msg[self::$locale][$key]);
216     }
217 
218     /**
219      * Returns the translation for the given key.
220      *
221      * @param string $key
222      * @param bool   $htmlspecialchars
223      * @param array  $args
224      * @param string $locale           A Locale
225      *
226      * @return mixed
227      */
228     private static function getMsg($key, $htmlspecialchars, array $args, $locale = null)
229     {
230         if (!self::$locale) {
231             self::$locale = rex::getProperty('lang');
232         }
233 
234         if (!$locale) {
235             $locale = self::$locale;
236         }
237 
238         if (empty(self::$loaded[$locale])) {
239             self::loadAll($locale);
240         }
241 
242         if (isset(self::$msg[$locale][$key])) {
243             $msg = self::$msg[$locale][$key];
244         } else {
245             $msg = self::getMsgFallback($key, $args, $locale);
246         }
247 
248         $patterns = [];
249         $replacements = [];
250         $argNum = count($args);
251         if ($argNum > 1) {
252             for ($i = 1; $i < $argNum; ++$i) {
253                 // zero indexed
254                 $patterns[] = '/\{' . ($i - 1) . '\}/';
255                 $replacements[] = $args[$i];
256             }
257         }
258 
259         $msg = preg_replace($patterns, $replacements, $msg);
260 
261         if ($htmlspecialchars) {
262             $msg = rex_escape($msg);
263             $msg = preg_replace('@&lt;(/?(?:b|i|code|kbd|var)|br ?/?)&gt;@i', '<$1>', $msg);
264         }
265 
266         return $msg;
267     }
268 
269     /**
270      * Checks if there is a translation for the given key in current language or any fallback language.
271      *
272      * @param string $key Key
273      *
274      * @return bool TRUE on success, else FALSE
275      */
276     public static function hasMsgOrFallback($key)
277     {
278         if (isset(self::$msg[self::$locale][$key])) {
279             return true;
280         }
281 
282         foreach (rex::getProperty('lang_fallback', []) as $locale) {
283             if (self::$locale === $locale) {
284                 continue;
285             }
286 
287             if (empty(self::$loaded[$locale])) {
288                 self::loadAll($locale);
289             }
290 
291             if (isset(self::$msg[$locale][$key])) {
292                 return true;
293             }
294         }
295 
296         return false;
297     }
298 
299     /**
300      * Adds a new translation to the catalogue.
301      *
302      * @param string $key Key
303      * @param string $msg Message for the key
304      */
305     public static function addMsg($key, $msg)
306     {
307         self::$msg[self::$locale][$key] = $msg;
308     }
309 
310     /**
311      * Returns the locales.
312      *
313      * @return array Array of Locales
314      */
315     public static function getLocales()
316     {
317         if (empty(self::$locales) && isset(self::$directories[0]) && is_readable(self::$directories[0])) {
318             self::$locales = [];
319 
320             foreach (rex_finder::factory(self::$directories[0])->filesOnly() as $file) {
321                 if (preg_match("/^(\w+)\.lang$/", $file->getFilename(), $matches)) {
322                     self::$locales[] = $matches[1];
323                 }
324             }
325         }
326 
327         return self::$locales;
328     }
329 
330     /**
331      * Translates the $text, if it begins with 'translate:', else it returns $text.
332      *
333      * @param string   $text                 The text for translation
334      * @param bool     $use_htmlspecialchars Flag whether the translated text should be passed to htmlspecialchars()
335      * @param callable $i18nFunction         Function that returns the translation for the i18n key
336      *
337      * @throws InvalidArgumentException
338      *
339      * @return string Translated text
340      */
341     public static function translate($text, $use_htmlspecialchars = true, callable $i18nFunction = null)
342     {
343         if (!is_string($text)) {
344             throw new InvalidArgumentException('Expecting $text to be a String, "' . gettype($text) . '" given!');
345         }
346 
347         $tranKey = 'translate:';
348         $transKeyLen = strlen($tranKey);
349         if (substr($text, 0, $transKeyLen) == $tranKey) {
350             if (!$i18nFunction) {
351                 if ($use_htmlspecialchars) {
352                     return self::msg(substr($text, $transKeyLen));
353                 }
354                 return self::rawMsg(substr($text, $transKeyLen));
355             }
356             // cuf() required for php5 compat to support 'class::method' like callables
357             return call_user_func($i18nFunction, substr($text, $transKeyLen));
358         }
359         if ($use_htmlspecialchars) {
360             return rex_escape($text);
361         }
362         return $text;
363     }
364 
365     /**
366      * Translates all array elements.
367      *
368      * @param mixed    $array                The Array of Strings for translation
369      * @param bool     $use_htmlspecialchars Flag whether the translated text should be passed to htmlspecialchars()
370      * @param callable $i18nFunction         Function that returns the translation for the i18n key
371      *
372      * @throws InvalidArgumentException
373      *
374      * @return mixed
375      */
376     public static function translateArray($array, $use_htmlspecialchars = true, callable $i18nFunction = null)
377     {
378         if (is_array($array)) {
379             foreach ($array as $key => $value) {
380                 if (is_string($value)) {
381                     $array[$key] = self::translate($value, $use_htmlspecialchars, $i18nFunction);
382                 } else {
383                     $array[$key] = self::translateArray($value, $use_htmlspecialchars, $i18nFunction);
384                 }
385             }
386             return $array;
387         }
388         if (is_string($array)) {
389             return self::translate($array, $use_htmlspecialchars, $i18nFunction);
390         }
391         if (null === $array || is_scalar($array)) {
392             return $array;
393         }
394         throw new InvalidArgumentException('Expecting $text to be a String or Array of Scalar, "' . gettype($array) . '" given!');
395     }
396 
397     /**
398      * Loads the translation definitions of the given file.
399      *
400      * @param string $dir    Path to the directory
401      * @param string $locale Locale
402      */
403     private static function loadFile($dir, $locale)
404     {
405         $file = $dir.DIRECTORY_SEPARATOR.$locale.'.lang';
406 
407         if (
408             ($content = rex_file::get($file)) &&
409             preg_match_all('/^([^=\s]+)\h*=\h*(\S.*)(?<=\S)/m', $content, $matches, PREG_SET_ORDER)
410         ) {
411             foreach ($matches as $match) {
412                 self::$msg[$locale][$match[1]] = $match[2];
413             }
414         }
415     }
416 
417     /**
418      * Loads all translation defintions.
419      *
420      * @param string $locale Locale
421      */
422     private static function loadAll($locale)
423     {
424         foreach (self::$directories as $dir) {
425             self::loadFile($dir, $locale);
426         }
427 
428         self::$loaded[$locale] = true;
429     }
430 }
431