Translator.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Translation;
  11. use Symfony\Component\Config\ConfigCacheFactory;
  12. use Symfony\Component\Config\ConfigCacheFactoryInterface;
  13. use Symfony\Component\Config\ConfigCacheInterface;
  14. use Symfony\Component\Translation\Exception\InvalidArgumentException;
  15. use Symfony\Component\Translation\Exception\LogicException;
  16. use Symfony\Component\Translation\Exception\NotFoundResourceException;
  17. use Symfony\Component\Translation\Exception\RuntimeException;
  18. use Symfony\Component\Translation\Formatter\ChoiceMessageFormatterInterface;
  19. use Symfony\Component\Translation\Formatter\IntlFormatterInterface;
  20. use Symfony\Component\Translation\Formatter\MessageFormatter;
  21. use Symfony\Component\Translation\Formatter\MessageFormatterInterface;
  22. use Symfony\Component\Translation\Loader\LoaderInterface;
  23. use Symfony\Component\Translation\TranslatorInterface as LegacyTranslatorInterface;
  24. use Symfony\Contracts\Translation\TranslatorInterface;
  25. /**
  26. * @author Fabien Potencier <fabien@symfony.com>
  27. */
  28. class Translator implements LegacyTranslatorInterface, TranslatorInterface, TranslatorBagInterface
  29. {
  30. /**
  31. * @var MessageCatalogueInterface[]
  32. */
  33. protected $catalogues = [];
  34. /**
  35. * @var string
  36. */
  37. private $locale;
  38. /**
  39. * @var array
  40. */
  41. private $fallbackLocales = [];
  42. /**
  43. * @var LoaderInterface[]
  44. */
  45. private $loaders = [];
  46. /**
  47. * @var array
  48. */
  49. private $resources = [];
  50. /**
  51. * @var MessageFormatterInterface
  52. */
  53. private $formatter;
  54. /**
  55. * @var string
  56. */
  57. private $cacheDir;
  58. /**
  59. * @var bool
  60. */
  61. private $debug;
  62. /**
  63. * @var ConfigCacheFactoryInterface|null
  64. */
  65. private $configCacheFactory;
  66. /**
  67. * @var array|null
  68. */
  69. private $parentLocales;
  70. private $hasIntlFormatter;
  71. /**
  72. * @throws InvalidArgumentException If a locale contains invalid characters
  73. */
  74. public function __construct(?string $locale, MessageFormatterInterface $formatter = null, string $cacheDir = null, bool $debug = false)
  75. {
  76. $this->setLocale($locale);
  77. if (null === $formatter) {
  78. $formatter = new MessageFormatter();
  79. }
  80. $this->formatter = $formatter;
  81. $this->cacheDir = $cacheDir;
  82. $this->debug = $debug;
  83. $this->hasIntlFormatter = $formatter instanceof IntlFormatterInterface;
  84. }
  85. public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFactory)
  86. {
  87. $this->configCacheFactory = $configCacheFactory;
  88. }
  89. /**
  90. * Adds a Loader.
  91. *
  92. * @param string $format The name of the loader (@see addResource())
  93. * @param LoaderInterface $loader A LoaderInterface instance
  94. */
  95. public function addLoader($format, LoaderInterface $loader)
  96. {
  97. $this->loaders[$format] = $loader;
  98. }
  99. /**
  100. * Adds a Resource.
  101. *
  102. * @param string $format The name of the loader (@see addLoader())
  103. * @param mixed $resource The resource name
  104. * @param string $locale The locale
  105. * @param string $domain The domain
  106. *
  107. * @throws InvalidArgumentException If the locale contains invalid characters
  108. */
  109. public function addResource($format, $resource, $locale, $domain = null)
  110. {
  111. if (null === $domain) {
  112. $domain = 'messages';
  113. }
  114. $this->assertValidLocale($locale);
  115. $this->resources[$locale][] = [$format, $resource, $domain];
  116. if (\in_array($locale, $this->fallbackLocales)) {
  117. $this->catalogues = [];
  118. } else {
  119. unset($this->catalogues[$locale]);
  120. }
  121. }
  122. /**
  123. * {@inheritdoc}
  124. */
  125. public function setLocale($locale)
  126. {
  127. $this->assertValidLocale($locale);
  128. $this->locale = $locale;
  129. }
  130. /**
  131. * {@inheritdoc}
  132. */
  133. public function getLocale()
  134. {
  135. return $this->locale;
  136. }
  137. /**
  138. * Sets the fallback locales.
  139. *
  140. * @param array $locales The fallback locales
  141. *
  142. * @throws InvalidArgumentException If a locale contains invalid characters
  143. */
  144. public function setFallbackLocales(array $locales)
  145. {
  146. // needed as the fallback locales are linked to the already loaded catalogues
  147. $this->catalogues = [];
  148. foreach ($locales as $locale) {
  149. $this->assertValidLocale($locale);
  150. }
  151. $this->fallbackLocales = $locales;
  152. }
  153. /**
  154. * Gets the fallback locales.
  155. *
  156. * @internal since Symfony 4.2
  157. *
  158. * @return array The fallback locales
  159. */
  160. public function getFallbackLocales()
  161. {
  162. return $this->fallbackLocales;
  163. }
  164. /**
  165. * {@inheritdoc}
  166. */
  167. public function trans($id, array $parameters = [], $domain = null, $locale = null)
  168. {
  169. if (null === $domain) {
  170. $domain = 'messages';
  171. }
  172. $id = (string) $id;
  173. $catalogue = $this->getCatalogue($locale);
  174. $locale = $catalogue->getLocale();
  175. while (!$catalogue->defines($id, $domain)) {
  176. if ($cat = $catalogue->getFallbackCatalogue()) {
  177. $catalogue = $cat;
  178. $locale = $catalogue->getLocale();
  179. } else {
  180. break;
  181. }
  182. }
  183. if ($this->hasIntlFormatter && $catalogue->defines($id, $domain.MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
  184. return $this->formatter->formatIntl($catalogue->get($id, $domain), $locale, $parameters);
  185. }
  186. return $this->formatter->format($catalogue->get($id, $domain), $locale, $parameters);
  187. }
  188. /**
  189. * {@inheritdoc}
  190. *
  191. * @deprecated since Symfony 4.2, use the trans() method instead with a %count% parameter
  192. */
  193. public function transChoice($id, $number, array $parameters = [], $domain = null, $locale = null)
  194. {
  195. @trigger_error(sprintf('The "%s()" method is deprecated since Symfony 4.2, use the trans() one instead with a "%%count%%" parameter.', __METHOD__), E_USER_DEPRECATED);
  196. if (!$this->formatter instanceof ChoiceMessageFormatterInterface) {
  197. throw new LogicException(sprintf('The formatter "%s" does not support plural translations.', \get_class($this->formatter)));
  198. }
  199. if (null === $domain) {
  200. $domain = 'messages';
  201. }
  202. $id = (string) $id;
  203. $catalogue = $this->getCatalogue($locale);
  204. $locale = $catalogue->getLocale();
  205. while (!$catalogue->defines($id, $domain)) {
  206. if ($cat = $catalogue->getFallbackCatalogue()) {
  207. $catalogue = $cat;
  208. $locale = $catalogue->getLocale();
  209. } else {
  210. break;
  211. }
  212. }
  213. if ($this->hasIntlFormatter && $catalogue->defines($id, $domain.MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
  214. return $this->formatter->formatIntl($catalogue->get($id, $domain), $locale, ['%count%' => $number] + $parameters);
  215. }
  216. return $this->formatter->choiceFormat($catalogue->get($id, $domain), $number, $locale, $parameters);
  217. }
  218. /**
  219. * {@inheritdoc}
  220. */
  221. public function getCatalogue($locale = null)
  222. {
  223. if (null === $locale) {
  224. $locale = $this->getLocale();
  225. } else {
  226. $this->assertValidLocale($locale);
  227. }
  228. if (!isset($this->catalogues[$locale])) {
  229. $this->loadCatalogue($locale);
  230. }
  231. return $this->catalogues[$locale];
  232. }
  233. /**
  234. * Gets the loaders.
  235. *
  236. * @return array LoaderInterface[]
  237. */
  238. protected function getLoaders()
  239. {
  240. return $this->loaders;
  241. }
  242. /**
  243. * @param string $locale
  244. */
  245. protected function loadCatalogue($locale)
  246. {
  247. if (null === $this->cacheDir) {
  248. $this->initializeCatalogue($locale);
  249. } else {
  250. $this->initializeCacheCatalogue($locale);
  251. }
  252. }
  253. /**
  254. * @param string $locale
  255. */
  256. protected function initializeCatalogue($locale)
  257. {
  258. $this->assertValidLocale($locale);
  259. try {
  260. $this->doLoadCatalogue($locale);
  261. } catch (NotFoundResourceException $e) {
  262. if (!$this->computeFallbackLocales($locale)) {
  263. throw $e;
  264. }
  265. }
  266. $this->loadFallbackCatalogues($locale);
  267. }
  268. private function initializeCacheCatalogue(string $locale): void
  269. {
  270. if (isset($this->catalogues[$locale])) {
  271. /* Catalogue already initialized. */
  272. return;
  273. }
  274. $this->assertValidLocale($locale);
  275. $cache = $this->getConfigCacheFactory()->cache($this->getCatalogueCachePath($locale),
  276. function (ConfigCacheInterface $cache) use ($locale) {
  277. $this->dumpCatalogue($locale, $cache);
  278. }
  279. );
  280. if (isset($this->catalogues[$locale])) {
  281. /* Catalogue has been initialized as it was written out to cache. */
  282. return;
  283. }
  284. /* Read catalogue from cache. */
  285. $this->catalogues[$locale] = include $cache->getPath();
  286. }
  287. private function dumpCatalogue($locale, ConfigCacheInterface $cache): void
  288. {
  289. $this->initializeCatalogue($locale);
  290. $fallbackContent = $this->getFallbackContent($this->catalogues[$locale]);
  291. $content = sprintf(<<<EOF
  292. <?php
  293. use Symfony\Component\Translation\MessageCatalogue;
  294. \$catalogue = new MessageCatalogue('%s', %s);
  295. %s
  296. return \$catalogue;
  297. EOF
  298. ,
  299. $locale,
  300. var_export($this->getAllMessages($this->catalogues[$locale]), true),
  301. $fallbackContent
  302. );
  303. $cache->write($content, $this->catalogues[$locale]->getResources());
  304. }
  305. private function getFallbackContent(MessageCatalogue $catalogue): string
  306. {
  307. $fallbackContent = '';
  308. $current = '';
  309. $replacementPattern = '/[^a-z0-9_]/i';
  310. $fallbackCatalogue = $catalogue->getFallbackCatalogue();
  311. while ($fallbackCatalogue) {
  312. $fallback = $fallbackCatalogue->getLocale();
  313. $fallbackSuffix = ucfirst(preg_replace($replacementPattern, '_', $fallback));
  314. $currentSuffix = ucfirst(preg_replace($replacementPattern, '_', $current));
  315. $fallbackContent .= sprintf(<<<'EOF'
  316. $catalogue%s = new MessageCatalogue('%s', %s);
  317. $catalogue%s->addFallbackCatalogue($catalogue%s);
  318. EOF
  319. ,
  320. $fallbackSuffix,
  321. $fallback,
  322. var_export($this->getAllMessages($fallbackCatalogue), true),
  323. $currentSuffix,
  324. $fallbackSuffix
  325. );
  326. $current = $fallbackCatalogue->getLocale();
  327. $fallbackCatalogue = $fallbackCatalogue->getFallbackCatalogue();
  328. }
  329. return $fallbackContent;
  330. }
  331. private function getCatalogueCachePath($locale)
  332. {
  333. return $this->cacheDir.'/catalogue.'.$locale.'.'.strtr(substr(base64_encode(hash('sha256', serialize($this->fallbackLocales), true)), 0, 7), '/', '_').'.php';
  334. }
  335. /**
  336. * @internal
  337. */
  338. protected function doLoadCatalogue($locale): void
  339. {
  340. $this->catalogues[$locale] = new MessageCatalogue($locale);
  341. if (isset($this->resources[$locale])) {
  342. foreach ($this->resources[$locale] as $resource) {
  343. if (!isset($this->loaders[$resource[0]])) {
  344. throw new RuntimeException(sprintf('The "%s" translation loader is not registered.', $resource[0]));
  345. }
  346. $this->catalogues[$locale]->addCatalogue($this->loaders[$resource[0]]->load($resource[1], $locale, $resource[2]));
  347. }
  348. }
  349. }
  350. private function loadFallbackCatalogues($locale): void
  351. {
  352. $current = $this->catalogues[$locale];
  353. foreach ($this->computeFallbackLocales($locale) as $fallback) {
  354. if (!isset($this->catalogues[$fallback])) {
  355. $this->initializeCatalogue($fallback);
  356. }
  357. $fallbackCatalogue = new MessageCatalogue($fallback, $this->getAllMessages($this->catalogues[$fallback]));
  358. foreach ($this->catalogues[$fallback]->getResources() as $resource) {
  359. $fallbackCatalogue->addResource($resource);
  360. }
  361. $current->addFallbackCatalogue($fallbackCatalogue);
  362. $current = $fallbackCatalogue;
  363. }
  364. }
  365. protected function computeFallbackLocales($locale)
  366. {
  367. if (null === $this->parentLocales) {
  368. $parentLocales = json_decode(file_get_contents(__DIR__.'/Resources/data/parents.json'), true);
  369. }
  370. $locales = [];
  371. foreach ($this->fallbackLocales as $fallback) {
  372. if ($fallback === $locale) {
  373. continue;
  374. }
  375. $locales[] = $fallback;
  376. }
  377. while ($locale) {
  378. $parent = $parentLocales[$locale] ?? null;
  379. if (!$parent && false !== strrchr($locale, '_')) {
  380. $locale = substr($locale, 0, -\strlen(strrchr($locale, '_')));
  381. } elseif ('root' !== $parent) {
  382. $locale = $parent;
  383. } else {
  384. $locale = null;
  385. }
  386. if (null !== $locale) {
  387. array_unshift($locales, $locale);
  388. }
  389. }
  390. return array_unique($locales);
  391. }
  392. /**
  393. * Asserts that the locale is valid, throws an Exception if not.
  394. *
  395. * @param string $locale Locale to tests
  396. *
  397. * @throws InvalidArgumentException If the locale contains invalid characters
  398. */
  399. protected function assertValidLocale($locale)
  400. {
  401. if (1 !== preg_match('/^[a-z0-9@_\\.\\-]*$/i', $locale)) {
  402. throw new InvalidArgumentException(sprintf('Invalid "%s" locale.', $locale));
  403. }
  404. }
  405. /**
  406. * Provides the ConfigCache factory implementation, falling back to a
  407. * default implementation if necessary.
  408. */
  409. private function getConfigCacheFactory(): ConfigCacheFactoryInterface
  410. {
  411. if (!$this->configCacheFactory) {
  412. $this->configCacheFactory = new ConfigCacheFactory($this->debug);
  413. }
  414. return $this->configCacheFactory;
  415. }
  416. private function getAllMessages(MessageCatalogueInterface $catalogue): array
  417. {
  418. $allMessages = [];
  419. foreach ($catalogue->all() as $domain => $messages) {
  420. if ($intlMessages = $catalogue->all($domain.MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
  421. $allMessages[$domain.MessageCatalogue::INTL_DOMAIN_SUFFIX] = $intlMessages;
  422. $messages = array_diff_key($messages, $intlMessages);
  423. }
  424. if ($messages) {
  425. $allMessages[$domain] = $messages;
  426. }
  427. }
  428. return $allMessages;
  429. }
  430. }