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