1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\I18n\Translator;
11
12use Locale;
13use Traversable;
14use Zend\Cache;
15use Zend\Cache\Storage\StorageInterface as CacheStorage;
16use Zend\EventManager\EventManager;
17use Zend\EventManager\EventManagerInterface;
18use Zend\I18n\Exception;
19use Zend\I18n\Translator\Loader\FileLoaderInterface;
20use Zend\I18n\Translator\Loader\RemoteLoaderInterface;
21use Zend\Stdlib\ArrayUtils;
22
23/**
24 * Translator.
25 */
26class Translator implements TranslatorInterface
27{
28    /**
29     * Event fired when the translation for a message is missing.
30     */
31    const EVENT_MISSING_TRANSLATION = 'missingTranslation';
32
33    /**
34     * Event fired when no messages were loaded for a locale/text-domain combination.
35     */
36    const EVENT_NO_MESSAGES_LOADED = 'noMessagesLoaded';
37
38    /**
39     * Messages loaded by the translator.
40     *
41     * @var array
42     */
43    protected $messages = array();
44
45    /**
46     * Files used for loading messages.
47     *
48     * @var array
49     */
50    protected $files = array();
51
52    /**
53     * Patterns used for loading messages.
54     *
55     * @var array
56     */
57    protected $patterns = array();
58
59    /**
60     * Remote locations for loading messages.
61     *
62     * @var array
63     */
64    protected $remote = array();
65
66    /**
67     * Default locale.
68     *
69     * @var string
70     */
71    protected $locale;
72
73    /**
74     * Locale to use as fallback if there is no translation.
75     *
76     * @var string
77     */
78    protected $fallbackLocale;
79
80    /**
81     * Translation cache.
82     *
83     * @var CacheStorage
84     */
85    protected $cache;
86
87    /**
88     * Plugin manager for translation loaders.
89     *
90     * @var LoaderPluginManager
91     */
92    protected $pluginManager;
93
94    /**
95     * Event manager for triggering translator events.
96     *
97     * @var EventManagerInterface
98     */
99    protected $events;
100
101    /**
102     * Whether events are enabled
103     *
104     * @var bool
105     */
106    protected $eventsEnabled = false;
107
108    /**
109     * Instantiate a translator
110     *
111     * @param  array|Traversable                  $options
112     * @return Translator
113     * @throws Exception\InvalidArgumentException
114     */
115    public static function factory($options)
116    {
117        if ($options instanceof Traversable) {
118            $options = ArrayUtils::iteratorToArray($options);
119        } elseif (!is_array($options)) {
120            throw new Exception\InvalidArgumentException(sprintf(
121                '%s expects an array or Traversable object; received "%s"',
122                __METHOD__,
123                (is_object($options) ? get_class($options) : gettype($options))
124            ));
125        }
126
127        $translator = new static();
128
129        // locales
130        if (isset($options['locale'])) {
131            $locales = (array) $options['locale'];
132            $translator->setLocale(array_shift($locales));
133            if (count($locales) > 0) {
134                $translator->setFallbackLocale(array_shift($locales));
135            }
136        }
137
138        // file patterns
139        if (isset($options['translation_file_patterns'])) {
140            if (!is_array($options['translation_file_patterns'])) {
141                throw new Exception\InvalidArgumentException(
142                    '"translation_file_patterns" should be an array'
143                );
144            }
145
146            $requiredKeys = array('type', 'base_dir', 'pattern');
147            foreach ($options['translation_file_patterns'] as $pattern) {
148                foreach ($requiredKeys as $key) {
149                    if (!isset($pattern[$key])) {
150                        throw new Exception\InvalidArgumentException(
151                            "'{$key}' is missing for translation pattern options"
152                        );
153                    }
154                }
155
156                $translator->addTranslationFilePattern(
157                    $pattern['type'],
158                    $pattern['base_dir'],
159                    $pattern['pattern'],
160                    isset($pattern['text_domain']) ? $pattern['text_domain'] : 'default'
161                );
162            }
163        }
164
165        // files
166        if (isset($options['translation_files'])) {
167            if (!is_array($options['translation_files'])) {
168                throw new Exception\InvalidArgumentException(
169                    '"translation_files" should be an array'
170                );
171            }
172
173            $requiredKeys = array('type', 'filename');
174            foreach ($options['translation_files'] as $file) {
175                foreach ($requiredKeys as $key) {
176                    if (!isset($file[$key])) {
177                        throw new Exception\InvalidArgumentException(
178                            "'{$key}' is missing for translation file options"
179                        );
180                    }
181                }
182
183                $translator->addTranslationFile(
184                    $file['type'],
185                    $file['filename'],
186                    isset($file['text_domain']) ? $file['text_domain'] : 'default',
187                    isset($file['locale']) ? $file['locale'] : null
188                );
189            }
190        }
191
192        // remote
193        if (isset($options['remote_translation'])) {
194            if (!is_array($options['remote_translation'])) {
195                throw new Exception\InvalidArgumentException(
196                    '"remote_translation" should be an array'
197                );
198            }
199
200            $requiredKeys = array('type');
201            foreach ($options['remote_translation'] as $remote) {
202                foreach ($requiredKeys as $key) {
203                    if (!isset($remote[$key])) {
204                        throw new Exception\InvalidArgumentException(
205                            "'{$key}' is missing for remote translation options"
206                        );
207                    }
208                }
209
210                $translator->addRemoteTranslations(
211                    $remote['type'],
212                    isset($remote['text_domain']) ? $remote['text_domain'] : 'default'
213                );
214            }
215        }
216
217        // cache
218        if (isset($options['cache'])) {
219            if ($options['cache'] instanceof CacheStorage) {
220                $translator->setCache($options['cache']);
221            } else {
222                $translator->setCache(Cache\StorageFactory::factory($options['cache']));
223            }
224        }
225
226        // event manager enabled
227        if (isset($options['event_manager_enabled']) && $options['event_manager_enabled']) {
228            $translator->enableEventManager();
229        }
230
231        return $translator;
232    }
233
234    /**
235     * Set the default locale.
236     *
237     * @param  string     $locale
238     * @return Translator
239     */
240    public function setLocale($locale)
241    {
242        $this->locale = $locale;
243
244        return $this;
245    }
246
247    /**
248     * Get the default locale.
249     *
250     * @return string
251     * @throws Exception\ExtensionNotLoadedException if ext/intl is not present and no locale set
252     */
253    public function getLocale()
254    {
255        if ($this->locale === null) {
256            if (!extension_loaded('intl')) {
257                throw new Exception\ExtensionNotLoadedException(sprintf(
258                    '%s component requires the intl PHP extension',
259                    __NAMESPACE__
260                ));
261            }
262            $this->locale = Locale::getDefault();
263        }
264
265        return $this->locale;
266    }
267
268    /**
269     * Set the fallback locale.
270     *
271     * @param  string     $locale
272     * @return Translator
273     */
274    public function setFallbackLocale($locale)
275    {
276        $this->fallbackLocale = $locale;
277
278        return $this;
279    }
280
281    /**
282     * Get the fallback locale.
283     *
284     * @return string
285     */
286    public function getFallbackLocale()
287    {
288        return $this->fallbackLocale;
289    }
290
291    /**
292     * Sets a cache
293     *
294     * @param  CacheStorage $cache
295     * @return Translator
296     */
297    public function setCache(CacheStorage $cache = null)
298    {
299        $this->cache = $cache;
300
301        return $this;
302    }
303
304    /**
305     * Returns the set cache
306     *
307     * @return CacheStorage The set cache
308     */
309    public function getCache()
310    {
311        return $this->cache;
312    }
313
314    /**
315     * Set the plugin manager for translation loaders
316     *
317     * @param  LoaderPluginManager $pluginManager
318     * @return Translator
319     */
320    public function setPluginManager(LoaderPluginManager $pluginManager)
321    {
322        $this->pluginManager = $pluginManager;
323
324        return $this;
325    }
326
327    /**
328     * Retrieve the plugin manager for translation loaders.
329     *
330     * Lazy loads an instance if none currently set.
331     *
332     * @return LoaderPluginManager
333     */
334    public function getPluginManager()
335    {
336        if (!$this->pluginManager instanceof LoaderPluginManager) {
337            $this->setPluginManager(new LoaderPluginManager());
338        }
339
340        return $this->pluginManager;
341    }
342
343    /**
344     * Translate a message.
345     *
346     * @param  string $message
347     * @param  string $textDomain
348     * @param  string $locale
349     * @return string
350     */
351    public function translate($message, $textDomain = 'default', $locale = null)
352    {
353        $locale      = ($locale ?: $this->getLocale());
354        $translation = $this->getTranslatedMessage($message, $locale, $textDomain);
355
356        if ($translation !== null && $translation !== '') {
357            return $translation;
358        }
359
360        if (null !== ($fallbackLocale = $this->getFallbackLocale())
361            && $locale !== $fallbackLocale
362        ) {
363            return $this->translate($message, $textDomain, $fallbackLocale);
364        }
365
366        return $message;
367    }
368
369    /**
370     * Translate a plural message.
371     *
372     * @param  string                         $singular
373     * @param  string                         $plural
374     * @param  int                            $number
375     * @param  string                         $textDomain
376     * @param  string|null                    $locale
377     * @return string
378     * @throws Exception\OutOfBoundsException
379     */
380    public function translatePlural(
381        $singular,
382        $plural,
383        $number,
384        $textDomain = 'default',
385        $locale = null
386    ) {
387        $locale      = $locale ?: $this->getLocale();
388        $translation = $this->getTranslatedMessage($singular, $locale, $textDomain);
389
390        if ($translation === null || $translation === '') {
391            if (null !== ($fallbackLocale = $this->getFallbackLocale())
392                && $locale !== $fallbackLocale
393            ) {
394                return $this->translatePlural(
395                    $singular,
396                    $plural,
397                    $number,
398                    $textDomain,
399                    $fallbackLocale
400                );
401            }
402
403            return ($number == 1 ? $singular : $plural);
404        } elseif (is_string($translation)) {
405            $translation = array($translation);
406        }
407
408        $index = $this->messages[$textDomain][$locale]
409                      ->getPluralRule()
410                      ->evaluate($number);
411
412        if (!isset($translation[$index])) {
413            throw new Exception\OutOfBoundsException(
414                sprintf('Provided index %d does not exist in plural array', $index)
415            );
416        }
417
418        return $translation[$index];
419    }
420
421    /**
422     * Get a translated message.
423     *
424     * @triggers getTranslatedMessage.missing-translation
425     * @param    string $message
426     * @param    string $locale
427     * @param    string $textDomain
428     * @return   string|null
429     */
430    protected function getTranslatedMessage(
431        $message,
432        $locale,
433        $textDomain = 'default'
434    ) {
435        if ($message === '') {
436            return '';
437        }
438
439        if (!isset($this->messages[$textDomain][$locale])) {
440            $this->loadMessages($textDomain, $locale);
441        }
442
443        if (isset($this->messages[$textDomain][$locale][$message])) {
444            return $this->messages[$textDomain][$locale][$message];
445        }
446
447        if ($this->isEventManagerEnabled()) {
448            $results = $this->getEventManager()->trigger(
449                self::EVENT_MISSING_TRANSLATION,
450                $this,
451                array(
452                    'message'     => $message,
453                    'locale'      => $locale,
454                    'text_domain' => $textDomain,
455                ),
456                function ($r) {
457                    return is_string($r);
458                }
459            );
460            $last = $results->last();
461            if (is_string($last)) {
462                return $last;
463            }
464        }
465
466        return;
467    }
468
469    /**
470     * Add a translation file.
471     *
472     * @param  string     $type
473     * @param  string     $filename
474     * @param  string     $textDomain
475     * @param  string     $locale
476     * @return Translator
477     */
478    public function addTranslationFile(
479        $type,
480        $filename,
481        $textDomain = 'default',
482        $locale = null
483    ) {
484        $locale = $locale ?: '*';
485
486        if (!isset($this->files[$textDomain])) {
487            $this->files[$textDomain] = array();
488        }
489
490        $this->files[$textDomain][$locale][] = array(
491            'type' => $type,
492            'filename' => $filename,
493        );
494
495        return $this;
496    }
497
498    /**
499     * Add multiple translations with a file pattern.
500     *
501     * @param  string     $type
502     * @param  string     $baseDir
503     * @param  string     $pattern
504     * @param  string     $textDomain
505     * @return Translator
506     */
507    public function addTranslationFilePattern(
508        $type,
509        $baseDir,
510        $pattern,
511        $textDomain = 'default'
512    ) {
513        if (!isset($this->patterns[$textDomain])) {
514            $this->patterns[$textDomain] = array();
515        }
516
517        $this->patterns[$textDomain][] = array(
518            'type'    => $type,
519            'baseDir' => rtrim($baseDir, '/'),
520            'pattern' => $pattern,
521        );
522
523        return $this;
524    }
525
526    /**
527     * Add remote translations.
528     *
529     * @param  string     $type
530     * @param  string     $textDomain
531     * @return Translator
532     */
533    public function addRemoteTranslations($type, $textDomain = 'default')
534    {
535        if (!isset($this->remote[$textDomain])) {
536            $this->remote[$textDomain] = array();
537        }
538
539        $this->remote[$textDomain][] = $type;
540
541        return $this;
542    }
543
544    /**
545     * Load messages for a given language and domain.
546     *
547     * @triggers loadMessages.no-messages-loaded
548     * @param    string $textDomain
549     * @param    string $locale
550     * @throws   Exception\RuntimeException
551     * @return   void
552     */
553    protected function loadMessages($textDomain, $locale)
554    {
555        if (!isset($this->messages[$textDomain])) {
556            $this->messages[$textDomain] = array();
557        }
558
559        if (null !== ($cache = $this->getCache())) {
560            $cacheId = 'Zend_I18n_Translator_Messages_' . md5($textDomain . $locale);
561
562            if (null !== ($result = $cache->getItem($cacheId))) {
563                $this->messages[$textDomain][$locale] = $result;
564
565                return;
566            }
567        }
568
569        $messagesLoaded  = false;
570        $messagesLoaded |= $this->loadMessagesFromRemote($textDomain, $locale);
571        $messagesLoaded |= $this->loadMessagesFromPatterns($textDomain, $locale);
572        $messagesLoaded |= $this->loadMessagesFromFiles($textDomain, $locale);
573
574        if (!$messagesLoaded) {
575            $discoveredTextDomain = null;
576            if ($this->isEventManagerEnabled()) {
577                $results = $this->getEventManager()->trigger(
578                    self::EVENT_NO_MESSAGES_LOADED,
579                    $this,
580                    array(
581                        'locale'      => $locale,
582                        'text_domain' => $textDomain,
583                    ),
584                    function ($r) {
585                        return ($r instanceof TextDomain);
586                    }
587                );
588                $last = $results->last();
589                if ($last instanceof TextDomain) {
590                    $discoveredTextDomain = $last;
591                }
592            }
593
594            $this->messages[$textDomain][$locale] = $discoveredTextDomain;
595            $messagesLoaded = true;
596        }
597
598        if ($messagesLoaded && $cache !== null) {
599            $cache->setItem($cacheId, $this->messages[$textDomain][$locale]);
600        }
601    }
602
603    /**
604     * Load messages from remote sources.
605     *
606     * @param  string $textDomain
607     * @param  string $locale
608     * @return bool
609     * @throws Exception\RuntimeException When specified loader is not a remote loader
610     */
611    protected function loadMessagesFromRemote($textDomain, $locale)
612    {
613        $messagesLoaded = false;
614
615        if (isset($this->remote[$textDomain])) {
616            foreach ($this->remote[$textDomain] as $loaderType) {
617                $loader = $this->getPluginManager()->get($loaderType);
618
619                if (!$loader instanceof RemoteLoaderInterface) {
620                    throw new Exception\RuntimeException('Specified loader is not a remote loader');
621                }
622
623                if (isset($this->messages[$textDomain][$locale])) {
624                    $this->messages[$textDomain][$locale]->merge($loader->load($locale, $textDomain));
625                } else {
626                    $this->messages[$textDomain][$locale] = $loader->load($locale, $textDomain);
627                }
628
629                $messagesLoaded = true;
630            }
631        }
632
633        return $messagesLoaded;
634    }
635
636    /**
637     * Load messages from patterns.
638     *
639     * @param  string $textDomain
640     * @param  string $locale
641     * @return bool
642     * @throws Exception\RuntimeException When specified loader is not a file loader
643     */
644    protected function loadMessagesFromPatterns($textDomain, $locale)
645    {
646        $messagesLoaded = false;
647
648        if (isset($this->patterns[$textDomain])) {
649            foreach ($this->patterns[$textDomain] as $pattern) {
650                $filename = $pattern['baseDir'] . '/' . sprintf($pattern['pattern'], $locale);
651
652                if (is_file($filename)) {
653                    $loader = $this->getPluginManager()->get($pattern['type']);
654
655                    if (!$loader instanceof FileLoaderInterface) {
656                        throw new Exception\RuntimeException('Specified loader is not a file loader');
657                    }
658
659                    if (isset($this->messages[$textDomain][$locale])) {
660                        $this->messages[$textDomain][$locale]->merge($loader->load($locale, $filename));
661                    } else {
662                        $this->messages[$textDomain][$locale] = $loader->load($locale, $filename);
663                    }
664
665                    $messagesLoaded = true;
666                }
667            }
668        }
669
670        return $messagesLoaded;
671    }
672
673    /**
674     * Load messages from files.
675     *
676     * @param  string $textDomain
677     * @param  string $locale
678     * @return bool
679     * @throws Exception\RuntimeException When specified loader is not a file loader
680     */
681    protected function loadMessagesFromFiles($textDomain, $locale)
682    {
683        $messagesLoaded = false;
684
685        foreach (array($locale, '*') as $currentLocale) {
686            if (!isset($this->files[$textDomain][$currentLocale])) {
687                continue;
688            }
689
690            foreach ($this->files[$textDomain][$currentLocale] as $file) {
691                $loader = $this->getPluginManager()->get($file['type']);
692
693                if (!$loader instanceof FileLoaderInterface) {
694                    throw new Exception\RuntimeException('Specified loader is not a file loader');
695                }
696
697                if (isset($this->messages[$textDomain][$locale])) {
698                    $this->messages[$textDomain][$locale]->merge($loader->load($locale, $file['filename']));
699                } else {
700                    $this->messages[$textDomain][$locale] = $loader->load($locale, $file['filename']);
701                }
702
703                $messagesLoaded = true;
704            }
705
706            unset($this->files[$textDomain][$currentLocale]);
707        }
708
709        return $messagesLoaded;
710    }
711
712    /**
713     * Get the event manager.
714     *
715     * @return EventManagerInterface|null
716     */
717    public function getEventManager()
718    {
719        if (!$this->events instanceof EventManagerInterface) {
720            $this->setEventManager(new EventManager());
721        }
722
723        return $this->events;
724    }
725
726    /**
727     * Set the event manager instance used by this translator.
728     *
729     * @param  EventManagerInterface $events
730     * @return Translator
731     */
732    public function setEventManager(EventManagerInterface $events)
733    {
734        $events->setIdentifiers(array(
735            __CLASS__,
736            get_class($this),
737            'translator',
738        ));
739        $this->events = $events;
740        return $this;
741    }
742
743    /**
744     * Check whether the event manager is enabled.
745     *
746     * @return boolean
747     */
748    public function isEventManagerEnabled()
749    {
750        return $this->eventsEnabled;
751    }
752
753    /**
754     * Enable the event manager.
755     *
756     * @return Translator
757     */
758    public function enableEventManager()
759    {
760        $this->eventsEnabled = true;
761        return $this;
762    }
763
764    /**
765     * Disable the event manager.
766     *
767     * @return Translator
768     */
769    public function disableEventManager()
770    {
771        $this->eventsEnabled = false;
772        return $this;
773    }
774}
775