Upgrade framework

This commit is contained in:
2023-11-14 16:54:35 +01:00
parent 1648a5cd42
commit 4fcf6fffcc
10548 changed files with 693138 additions and 466698 deletions

View File

@@ -1,3 +0,0 @@
vendor/
composer.lock
phpunit.xml

View File

@@ -1,6 +1,103 @@
CHANGELOG
=========
5.4
---
* Add `github` format & autodetection to render errors as annotations when
running the XLIFF linter command in a Github Actions environment.
* Translation providers are not experimental anymore
5.3
---
* Add `translation:pull` and `translation:push` commands to manage translations with third-party providers
* Add `TranslatorBagInterface::getCatalogues` method
* Add support to load XLIFF string in `XliffFileLoader`
5.2.0
-----
* added support for calling `trans` with ICU formatted messages
* added `PseudoLocalizationTranslator`
* added `TranslatableMessage` objects that represent a message that can be translated
* added the `t()` function to easily create `TranslatableMessage` objects
* Added support for extracting messages from `TranslatableMessage` objects
5.1.0
-----
* added support for `name` attribute on `unit` element from xliff2 to be used as a translation key instead of always the `source` element
5.0.0
-----
* removed support for using `null` as the locale in `Translator`
* removed `TranslatorInterface`
* removed `MessageSelector`
* removed `ChoiceMessageFormatterInterface`
* removed `PluralizationRule`
* removed `Interval`
* removed `transChoice()` methods, use the trans() method instead with a %count% parameter
* removed `FileDumper::setBackup()` and `TranslationWriter::disableBackup()`
* removed `MessageFormatter::choiceFormat()`
* added argument `$filename` to `PhpExtractor::parseTokens()`
* removed support for implicit STDIN usage in the `lint:xliff` command, use `lint:xliff -` (append a dash) instead to make it explicit.
4.4.0
-----
* deprecated support for using `null` as the locale in `Translator`
* deprecated accepting STDIN implicitly when using the `lint:xliff` command, use `lint:xliff -` (append a dash) instead to make it explicit.
* Marked the `TranslationDataCollector` class as `@final`.
4.3.0
-----
* Improved Xliff 1.2 loader to load the original file's metadata
* Added `TranslatorPathsPass`
4.2.0
-----
* Started using ICU parent locales as fallback locales.
* allow using the ICU message format using domains with the "+intl-icu" suffix
* deprecated `Translator::transChoice()` in favor of using `Translator::trans()` with a `%count%` parameter
* deprecated `TranslatorInterface` in favor of `Symfony\Contracts\Translation\TranslatorInterface`
* deprecated `MessageSelector`, `Interval` and `PluralizationRules`; use `IdentityTranslator` instead
* Added `IntlFormatter` and `IntlFormatterInterface`
* added support for multiple files and directories in `XliffLintCommand`
* Marked `Translator::getFallbackLocales()` and `TranslationDataCollector::getFallbackLocales()` as internal
4.1.0
-----
* The `FileDumper::setBackup()` method is deprecated.
* The `TranslationWriter::disableBackup()` method is deprecated.
* The `XliffFileDumper` will write "name" on the "unit" node when dumping XLIFF 2.0.
4.0.0
-----
* removed the backup feature of the `FileDumper` class
* removed `TranslationWriter::writeTranslations()` method
* removed support for passing `MessageSelector` instances to the constructor of the `Translator` class
3.4.0
-----
* Added `TranslationDumperPass`
* Added `TranslationExtractorPass`
* Added `TranslatorPass`
* Added `TranslationReader` and `TranslationReaderInterface`
* Added `<notes>` section to the Xliff 2.0 dumper.
* Improved Xliff 2.0 loader to load `<notes>` section.
* Added `TranslationWriterInterface`
* Deprecated `TranslationWriter::writeTranslations` in favor of `TranslationWriter::write`
* added support for adding custom message formatter and decoupling the default one.
* Added `PhpExtractor`
* Added `PhpStringTokenParser`
3.2.0
-----

View File

@@ -11,10 +11,10 @@
namespace Symfony\Component\Translation\Catalogue;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Translation\Exception\LogicException;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
/**
* Base catalogues binary operation class.
@@ -26,23 +26,16 @@ use Symfony\Component\Translation\Exception\LogicException;
*/
abstract class AbstractOperation implements OperationInterface
{
/**
* @var MessageCatalogueInterface The source catalogue
*/
public const OBSOLETE_BATCH = 'obsolete';
public const NEW_BATCH = 'new';
public const ALL_BATCH = 'all';
protected $source;
/**
* @var MessageCatalogueInterface The target catalogue
*/
protected $target;
/**
* @var MessageCatalogue The result catalogue
*/
protected $result;
/**
* @var null|array The domains affected by this operation
* @var array|null The domains affected by this operation
*/
private $domains;
@@ -50,30 +43,26 @@ abstract class AbstractOperation implements OperationInterface
* This array stores 'all', 'new' and 'obsolete' messages for all valid domains.
*
* The data structure of this array is as follows:
* ```php
* array(
* 'domain 1' => array(
* 'all' => array(...),
* 'new' => array(...),
* 'obsolete' => array(...)
* ),
* 'domain 2' => array(
* 'all' => array(...),
* 'new' => array(...),
* 'obsolete' => array(...)
* ),
* ...
* )
* ```
*
* [
* 'domain 1' => [
* 'all' => [...],
* 'new' => [...],
* 'obsolete' => [...]
* ],
* 'domain 2' => [
* 'all' => [...],
* 'new' => [...],
* 'obsolete' => [...]
* ],
* ...
* ]
*
* @var array The array that stores 'all', 'new' and 'obsolete' messages
*/
protected $messages;
/**
* @param MessageCatalogueInterface $source The source catalogue
* @param MessageCatalogueInterface $target The target catalogue
*
* @throws LogicException
*/
public function __construct(MessageCatalogueInterface $source, MessageCatalogueInterface $target)
@@ -85,16 +74,27 @@ abstract class AbstractOperation implements OperationInterface
$this->source = $source;
$this->target = $target;
$this->result = new MessageCatalogue($source->getLocale());
$this->messages = array();
$this->messages = [];
}
/**
* {@inheritdoc}
*/
public function getDomains()
public function getDomains(): array
{
if (null === $this->domains) {
$this->domains = array_values(array_unique(array_merge($this->source->getDomains(), $this->target->getDomains())));
$domains = [];
foreach ([$this->source, $this->target] as $catalogue) {
foreach ($catalogue->getDomains() as $domain) {
$domains[$domain] = $domain;
if ($catalogue->all($domainIcu = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX)) {
$domains[$domainIcu] = $domainIcu;
}
}
}
$this->domains = array_values($domains);
}
return $this->domains;
@@ -103,55 +103,55 @@ abstract class AbstractOperation implements OperationInterface
/**
* {@inheritdoc}
*/
public function getMessages($domain)
public function getMessages(string $domain): array
{
if (!in_array($domain, $this->getDomains())) {
throw new InvalidArgumentException(sprintf('Invalid domain: %s.', $domain));
if (!\in_array($domain, $this->getDomains())) {
throw new InvalidArgumentException(sprintf('Invalid domain: "%s".', $domain));
}
if (!isset($this->messages[$domain]['all'])) {
if (!isset($this->messages[$domain][self::ALL_BATCH])) {
$this->processDomain($domain);
}
return $this->messages[$domain]['all'];
return $this->messages[$domain][self::ALL_BATCH];
}
/**
* {@inheritdoc}
*/
public function getNewMessages($domain)
public function getNewMessages(string $domain): array
{
if (!in_array($domain, $this->getDomains())) {
throw new InvalidArgumentException(sprintf('Invalid domain: %s.', $domain));
if (!\in_array($domain, $this->getDomains())) {
throw new InvalidArgumentException(sprintf('Invalid domain: "%s".', $domain));
}
if (!isset($this->messages[$domain]['new'])) {
if (!isset($this->messages[$domain][self::NEW_BATCH])) {
$this->processDomain($domain);
}
return $this->messages[$domain]['new'];
return $this->messages[$domain][self::NEW_BATCH];
}
/**
* {@inheritdoc}
*/
public function getObsoleteMessages($domain)
public function getObsoleteMessages(string $domain): array
{
if (!in_array($domain, $this->getDomains())) {
throw new InvalidArgumentException(sprintf('Invalid domain: %s.', $domain));
if (!\in_array($domain, $this->getDomains())) {
throw new InvalidArgumentException(sprintf('Invalid domain: "%s".', $domain));
}
if (!isset($this->messages[$domain]['obsolete'])) {
if (!isset($this->messages[$domain][self::OBSOLETE_BATCH])) {
$this->processDomain($domain);
}
return $this->messages[$domain]['obsolete'];
return $this->messages[$domain][self::OBSOLETE_BATCH];
}
/**
* {@inheritdoc}
*/
public function getResult()
public function getResult(): MessageCatalogueInterface
{
foreach ($this->getDomains() as $domain) {
if (!isset($this->messages[$domain])) {
@@ -162,11 +162,42 @@ abstract class AbstractOperation implements OperationInterface
return $this->result;
}
/**
* @param self::*_BATCH $batch
*/
public function moveMessagesToIntlDomainsIfPossible(string $batch = self::ALL_BATCH): void
{
// If MessageFormatter class does not exists, intl domains are not supported.
if (!class_exists(\MessageFormatter::class)) {
return;
}
foreach ($this->getDomains() as $domain) {
$intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
switch ($batch) {
case self::OBSOLETE_BATCH: $messages = $this->getObsoleteMessages($domain); break;
case self::NEW_BATCH: $messages = $this->getNewMessages($domain); break;
case self::ALL_BATCH: $messages = $this->getMessages($domain); break;
default: throw new \InvalidArgumentException(sprintf('$batch argument must be one of ["%s", "%s", "%s"].', self::ALL_BATCH, self::NEW_BATCH, self::OBSOLETE_BATCH));
}
if (!$messages || (!$this->source->all($intlDomain) && $this->source->all($domain))) {
continue;
}
$result = $this->getResult();
$allIntlMessages = $result->all($intlDomain);
$currentMessages = array_diff_key($messages, $result->all($domain));
$result->replace($currentMessages, $domain);
$result->replace($allIntlMessages + $messages, $intlDomain);
}
}
/**
* Performs operation on source and target catalogues for the given domain and
* stores the results.
*
* @param string $domain The domain which the operation will be performed for
*/
abstract protected function processDomain($domain);
abstract protected function processDomain(string $domain);
}

View File

@@ -11,6 +11,8 @@
namespace Symfony\Component\Translation\Catalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
/**
* Merge operation between two catalogues as follows:
* all = source target = {x: x ∈ source x ∈ target}
@@ -25,19 +27,21 @@ class MergeOperation extends AbstractOperation
/**
* {@inheritdoc}
*/
protected function processDomain($domain)
protected function processDomain(string $domain)
{
$this->messages[$domain] = array(
'all' => array(),
'new' => array(),
'obsolete' => array(),
);
$this->messages[$domain] = [
'all' => [],
'new' => [],
'obsolete' => [],
];
$intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
foreach ($this->source->all($domain) as $id => $message) {
$this->messages[$domain]['all'][$id] = $message;
$this->result->add(array($id => $message), $domain);
if (null !== $keyMetadata = $this->source->getMetadata($id, $domain)) {
$this->result->setMetadata($id, $keyMetadata, $domain);
$d = $this->source->defines($id, $intlDomain) ? $intlDomain : $domain;
$this->result->add([$id => $message], $d);
if (null !== $keyMetadata = $this->source->getMetadata($id, $d)) {
$this->result->setMetadata($id, $keyMetadata, $d);
}
}
@@ -45,9 +49,10 @@ class MergeOperation extends AbstractOperation
if (!$this->source->has($id, $domain)) {
$this->messages[$domain]['all'][$id] = $message;
$this->messages[$domain]['new'][$id] = $message;
$this->result->add(array($id => $message), $domain);
if (null !== $keyMetadata = $this->target->getMetadata($id, $domain)) {
$this->result->setMetadata($id, $keyMetadata, $domain);
$d = $this->target->defines($id, $intlDomain) ? $intlDomain : $domain;
$this->result->add([$id => $message], $d);
if (null !== $keyMetadata = $this->target->getMetadata($id, $d)) {
$this->result->setMetadata($id, $keyMetadata, $d);
}
}
}

View File

@@ -36,42 +36,26 @@ interface OperationInterface
{
/**
* Returns domains affected by operation.
*
* @return array
*/
public function getDomains();
public function getDomains(): array;
/**
* Returns all valid messages ('all') after operation.
*
* @param string $domain
*
* @return array
*/
public function getMessages($domain);
public function getMessages(string $domain): array;
/**
* Returns new messages ('new') after operation.
*
* @param string $domain
*
* @return array
*/
public function getNewMessages($domain);
public function getNewMessages(string $domain): array;
/**
* Returns obsolete messages ('obsolete') after operation.
*
* @param string $domain
*
* @return array
*/
public function getObsoleteMessages($domain);
public function getObsoleteMessages(string $domain): array;
/**
* Returns resulting catalogue ('result').
*
* @return MessageCatalogueInterface
*/
public function getResult();
public function getResult(): MessageCatalogueInterface;
}

View File

@@ -11,6 +11,8 @@
namespace Symfony\Component\Translation\Catalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
/**
* Target operation between two catalogues:
* intersection = source ∩ target = {x: x ∈ source ∧ x ∈ target}
@@ -26,29 +28,31 @@ class TargetOperation extends AbstractOperation
/**
* {@inheritdoc}
*/
protected function processDomain($domain)
protected function processDomain(string $domain)
{
$this->messages[$domain] = array(
'all' => array(),
'new' => array(),
'obsolete' => array(),
);
$this->messages[$domain] = [
'all' => [],
'new' => [],
'obsolete' => [],
];
$intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
// For 'all' messages, the code can't be simplified as ``$this->messages[$domain]['all'] = $target->all($domain);``,
// because doing so will drop messages like {x: x ∈ source ∧ x ∉ target.all ∧ x ∈ target.fallback}
//
// For 'new' messages, the code can't be simplied as ``array_diff_assoc($this->target->all($domain), $this->source->all($domain));``
// For 'new' messages, the code can't be simplified as ``array_diff_assoc($this->target->all($domain), $this->source->all($domain));``
// because doing so will not exclude messages like {x: x ∈ target ∧ x ∉ source.all ∧ x ∈ source.fallback}
//
// For 'obsolete' messages, the code can't be simplifed as ``array_diff_assoc($this->source->all($domain), $this->target->all($domain))``
// For 'obsolete' messages, the code can't be simplified as ``array_diff_assoc($this->source->all($domain), $this->target->all($domain))``
// because doing so will not exclude messages like {x: x ∈ source ∧ x ∉ target.all ∧ x ∈ target.fallback}
foreach ($this->source->all($domain) as $id => $message) {
if ($this->target->has($id, $domain)) {
$this->messages[$domain]['all'][$id] = $message;
$this->result->add(array($id => $message), $domain);
if (null !== $keyMetadata = $this->source->getMetadata($id, $domain)) {
$this->result->setMetadata($id, $keyMetadata, $domain);
$d = $this->source->defines($id, $intlDomain) ? $intlDomain : $domain;
$this->result->add([$id => $message], $d);
if (null !== $keyMetadata = $this->source->getMetadata($id, $d)) {
$this->result->setMetadata($id, $keyMetadata, $d);
}
} else {
$this->messages[$domain]['obsolete'][$id] = $message;
@@ -59,9 +63,10 @@ class TargetOperation extends AbstractOperation
if (!$this->source->has($id, $domain)) {
$this->messages[$domain]['all'][$id] = $message;
$this->messages[$domain]['new'][$id] = $message;
$this->result->add(array($id => $message), $domain);
if (null !== $keyMetadata = $this->target->getMetadata($id, $domain)) {
$this->result->setMetadata($id, $keyMetadata, $domain);
$d = $this->target->defines($id, $intlDomain) ? $intlDomain : $domain;
$this->result->add([$id => $message], $d);
if (null !== $keyMetadata = $this->target->getMetadata($id, $d)) {
$this->result->setMetadata($id, $keyMetadata, $d);
}
}
}

View File

@@ -0,0 +1,187 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Translation\Catalogue\TargetOperation;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Provider\TranslationProviderCollection;
use Symfony\Component\Translation\Reader\TranslationReaderInterface;
use Symfony\Component\Translation\Writer\TranslationWriterInterface;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*/
#[AsCommand(name: 'translation:pull', description: 'Pull translations from a given provider.')]
final class TranslationPullCommand extends Command
{
use TranslationTrait;
private $providerCollection;
private $writer;
private $reader;
private string $defaultLocale;
private array $transPaths;
private array $enabledLocales;
public function __construct(TranslationProviderCollection $providerCollection, TranslationWriterInterface $writer, TranslationReaderInterface $reader, string $defaultLocale, array $transPaths = [], array $enabledLocales = [])
{
$this->providerCollection = $providerCollection;
$this->writer = $writer;
$this->reader = $reader;
$this->defaultLocale = $defaultLocale;
$this->transPaths = $transPaths;
$this->enabledLocales = $enabledLocales;
parent::__construct();
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('provider')) {
$suggestions->suggestValues($this->providerCollection->keys());
return;
}
if ($input->mustSuggestOptionValuesFor('domains')) {
$provider = $this->providerCollection->get($input->getArgument('provider'));
if ($provider && method_exists($provider, 'getDomains')) {
$domains = $provider->getDomains();
$suggestions->suggestValues($domains);
}
return;
}
if ($input->mustSuggestOptionValuesFor('locales')) {
$suggestions->suggestValues($this->enabledLocales);
return;
}
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues(['php', 'xlf', 'xlf12', 'xlf20', 'po', 'mo', 'yml', 'yaml', 'ts', 'csv', 'json', 'ini', 'res']);
}
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$keys = $this->providerCollection->keys();
$defaultProvider = 1 === \count($keys) ? $keys[0] : null;
$this
->setDefinition([
new InputArgument('provider', null !== $defaultProvider ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The provider to pull translations from.', $defaultProvider),
new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with provider ones (it will delete not synchronized messages).'),
new InputOption('intl-icu', null, InputOption::VALUE_NONE, 'Associated to --force option, it will write messages in "%domain%+intl-icu.%locale%.xlf" files.'),
new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull.'),
new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to pull.'),
new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format.', 'xlf12'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</> command pulls translations from the given provider. Only
new translations are pulled, existing ones are not overwritten.
You can overwrite existing translations (and remove the missing ones on local side) by using the <comment>--force</> flag:
<info>php %command.full_name% --force provider</>
Full example:
<info>php %command.full_name% provider --force --domains=messages --domains=validators --locales=en</>
This command pulls all translations associated with the <comment>messages</> and <comment>validators</> domains for the <comment>en</> locale.
Local translations for the specified domains and locale are deleted if they're not present on the provider and overwritten if it's the case.
Local translations for others domains and locales are ignored.
EOF
)
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$provider = $this->providerCollection->get($input->getArgument('provider'));
$force = $input->getOption('force');
$intlIcu = $input->getOption('intl-icu');
$locales = $input->getOption('locales') ?: $this->enabledLocales;
$domains = $input->getOption('domains');
$format = $input->getOption('format');
$xliffVersion = '1.2';
if ($intlIcu && !$force) {
$io->note('--intl-icu option only has an effect when used with --force. Here, it will be ignored.');
}
switch ($format) {
case 'xlf20': $xliffVersion = '2.0';
// no break
case 'xlf12': $format = 'xlf';
}
$writeOptions = [
'path' => end($this->transPaths),
'xliff_version' => $xliffVersion,
'default_locale' => $this->defaultLocale,
];
if (!$domains) {
$domains = $provider->getDomains();
}
$providerTranslations = $provider->read($domains, $locales);
if ($force) {
foreach ($providerTranslations->getCatalogues() as $catalogue) {
$operation = new TargetOperation(new MessageCatalogue($catalogue->getLocale()), $catalogue);
if ($intlIcu) {
$operation->moveMessagesToIntlDomainsIfPossible();
}
$this->writer->write($operation->getResult(), $format, $writeOptions);
}
$io->success(sprintf('Local translations has been updated from "%s" (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains)));
return 0;
}
$localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths);
// Append pulled translations to local ones.
$localTranslations->addBag($providerTranslations->diff($localTranslations));
foreach ($localTranslations->getCatalogues() as $catalogue) {
$this->writer->write($catalogue, $format, $writeOptions);
}
$io->success(sprintf('New translations from "%s" has been written locally (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains)));
return 0;
}
}

View File

@@ -0,0 +1,188 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Translation\Provider\FilteringProvider;
use Symfony\Component\Translation\Provider\TranslationProviderCollection;
use Symfony\Component\Translation\Reader\TranslationReaderInterface;
use Symfony\Component\Translation\TranslatorBag;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*/
#[AsCommand(name: 'translation:push', description: 'Push translations to a given provider.')]
final class TranslationPushCommand extends Command
{
use TranslationTrait;
private $providers;
private $reader;
private array $transPaths;
private array $enabledLocales;
public function __construct(TranslationProviderCollection $providers, TranslationReaderInterface $reader, array $transPaths = [], array $enabledLocales = [])
{
$this->providers = $providers;
$this->reader = $reader;
$this->transPaths = $transPaths;
$this->enabledLocales = $enabledLocales;
parent::__construct();
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('provider')) {
$suggestions->suggestValues($this->providers->keys());
return;
}
if ($input->mustSuggestOptionValuesFor('domains')) {
$provider = $this->providers->get($input->getArgument('provider'));
if ($provider && method_exists($provider, 'getDomains')) {
$domains = $provider->getDomains();
$suggestions->suggestValues($domains);
}
return;
}
if ($input->mustSuggestOptionValuesFor('locales')) {
$suggestions->suggestValues($this->enabledLocales);
}
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$keys = $this->providers->keys();
$defaultProvider = 1 === \count($keys) ? $keys[0] : null;
$this
->setDefinition([
new InputArgument('provider', null !== $defaultProvider ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The provider to push translations to.', $defaultProvider),
new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with local ones (it will delete not synchronized messages).'),
new InputOption('delete-missing', null, InputOption::VALUE_NONE, 'Delete translations available on provider but not locally.'),
new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to push.'),
new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to push.', $this->enabledLocales),
])
->setHelp(<<<'EOF'
The <info>%command.name%</> command pushes translations to the given provider. Only new
translations are pushed, existing ones are not overwritten.
You can overwrite existing translations by using the <comment>--force</> flag:
<info>php %command.full_name% --force provider</>
You can delete provider translations which are not present locally by using the <comment>--delete-missing</> flag:
<info>php %command.full_name% --delete-missing provider</>
Full example:
<info>php %command.full_name% provider --force --delete-missing --domains=messages --domains=validators --locales=en</>
This command pushes all translations associated with the <comment>messages</> and <comment>validators</> domains for the <comment>en</> locale.
Provider translations for the specified domains and locale are deleted if they're not present locally and overwritten if it's the case.
Provider translations for others domains and locales are ignored.
EOF
)
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$provider = $this->providers->get($input->getArgument('provider'));
if (!$this->enabledLocales) {
throw new InvalidArgumentException(sprintf('You must define "framework.translator.enabled_locales" or "framework.translator.providers.%s.locales" config key in order to work with translation providers.', parse_url($provider, \PHP_URL_SCHEME)));
}
$io = new SymfonyStyle($input, $output);
$domains = $input->getOption('domains');
$locales = $input->getOption('locales');
$force = $input->getOption('force');
$deleteMissing = $input->getOption('delete-missing');
if (!$domains && $provider instanceof FilteringProvider) {
$domains = $provider->getDomains();
}
// Reading local translations must be done after retrieving the domains from the provider
// in order to manage only translations from configured domains
$localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths);
if (!$domains) {
$domains = $this->getDomainsFromTranslatorBag($localTranslations);
}
if (!$deleteMissing && $force) {
$provider->write($localTranslations);
$io->success(sprintf('All local translations has been sent to "%s" (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains)));
return 0;
}
$providerTranslations = $provider->read($domains, $locales);
if ($deleteMissing) {
$provider->delete($providerTranslations->diff($localTranslations));
$io->success(sprintf('Missing translations on "%s" has been deleted (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains)));
// Read provider translations again, after missing translations deletion,
// to avoid push freshly deleted translations.
$providerTranslations = $provider->read($domains, $locales);
}
$translationsToWrite = $localTranslations->diff($providerTranslations);
if ($force) {
$translationsToWrite->addBag($localTranslations->intersect($providerTranslations));
}
$provider->write($translationsToWrite);
$io->success(sprintf('%s local translations has been sent to "%s" (for "%s" locale(s), and "%s" domain(s)).', $force ? 'All' : 'New', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains)));
return 0;
}
private function getDomainsFromTranslatorBag(TranslatorBag $translatorBag): array
{
$domains = [];
foreach ($translatorBag->getCatalogues() as $catalogue) {
$domains += $catalogue->getDomains();
}
return array_unique($domains);
}
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Command;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\Component\Translation\TranslatorBag;
/**
* @internal
*/
trait TranslationTrait
{
private function readLocalTranslations(array $locales, array $domains, array $transPaths): TranslatorBag
{
$bag = new TranslatorBag();
foreach ($locales as $locale) {
$catalogue = new MessageCatalogue($locale);
foreach ($transPaths as $path) {
$this->reader->read($path, $catalogue);
}
if ($domains) {
foreach ($domains as $domain) {
$bag->addCatalogue($this->filterCatalogue($catalogue, $domain));
}
} else {
$bag->addCatalogue($catalogue);
}
}
return $bag;
}
private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue
{
$filteredCatalogue = new MessageCatalogue($catalogue->getLocale());
// extract intl-icu messages only
$intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
if ($intlMessages = $catalogue->all($intlDomain)) {
$filteredCatalogue->add($intlMessages, $intlDomain);
}
// extract all messages and subtract intl-icu messages
if ($messages = array_diff($catalogue->all($domain), $intlMessages)) {
$filteredCatalogue->add($messages, $domain);
}
foreach ($catalogue->getResources() as $resource) {
$filteredCatalogue->addResource($resource);
}
if ($metadata = $catalogue->getMetadata('', $intlDomain)) {
foreach ($metadata as $k => $v) {
$filteredCatalogue->setMetadata($k, $v, $intlDomain);
}
}
if ($metadata = $catalogue->getMetadata('', $domain)) {
foreach ($metadata as $k => $v) {
$filteredCatalogue->setMetadata($k, $v, $domain);
}
}
return $filteredCatalogue;
}
}

View File

@@ -11,11 +11,19 @@
namespace Symfony\Component\Translation\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\CI\GithubActionReporter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Translation\Util\XliffUtils;
/**
* Validates XLIFF files syntax and outputs encountered errors.
@@ -24,19 +32,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
* @author Robin Chalas <robin.chalas@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
#[AsCommand(name: 'lint:xliff', description: 'Lint an XLIFF file and outputs encountered errors')]
class XliffLintCommand extends Command
{
private $format;
private $displayCorrectFiles;
private $directoryIteratorProvider;
private $isReadableProvider;
private string $format;
private bool $displayCorrectFiles;
private ?\Closure $directoryIteratorProvider;
private ?\Closure $isReadableProvider;
private bool $requireStrictFileNames;
public function __construct($name = null, $directoryIteratorProvider = null, $isReadableProvider = null)
public function __construct(string $name = null, callable $directoryIteratorProvider = null, callable $isReadableProvider = null, bool $requireStrictFileNames = true)
{
parent::__construct($name);
$this->directoryIteratorProvider = $directoryIteratorProvider;
$this->isReadableProvider = $isReadableProvider;
$this->directoryIteratorProvider = null === $directoryIteratorProvider || $directoryIteratorProvider instanceof \Closure ? $directoryIteratorProvider : \Closure::fromCallable($directoryIteratorProvider);
$this->isReadableProvider = null === $isReadableProvider || $isReadableProvider instanceof \Closure ? $isReadableProvider : \Closure::fromCallable($isReadableProvider);
$this->requireStrictFileNames = $requireStrictFileNames;
}
/**
@@ -45,17 +56,15 @@ class XliffLintCommand extends Command
protected function configure()
{
$this
->setName('lint:xliff')
->setDescription('Lints a XLIFF file and outputs encountered errors')
->addArgument('filename', null, 'A file or a directory or STDIN')
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt')
->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN')
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format')
->setHelp(<<<EOF
The <info>%command.name%</info> command lints a XLIFF file and outputs to STDOUT
The <info>%command.name%</info> command lints an XLIFF file and outputs to STDOUT
the first encountered syntax error.
You can validates XLIFF contents passed from STDIN:
<info>cat filename | php %command.full_name%</info>
<info>cat filename | php %command.full_name% -</info>
You can also validate the syntax of a file:
@@ -71,60 +80,78 @@ EOF
;
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$filename = $input->getArgument('filename');
$this->format = $input->getOption('format');
$filenames = (array) $input->getArgument('filename');
$this->format = $input->getOption('format') ?? (GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt');
$this->displayCorrectFiles = $output->isVerbose();
if (!$filename) {
if (!$stdin = $this->getStdin()) {
throw new \RuntimeException('Please provide a filename or pipe file content to STDIN.');
if (['-'] === $filenames) {
return $this->display($io, [$this->validate(file_get_contents('php://stdin'))]);
}
if (!$filenames) {
throw new RuntimeException('Please provide a filename or pipe file content to STDIN.');
}
$filesInfo = [];
foreach ($filenames as $filename) {
if (!$this->isReadable($filename)) {
throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename));
}
return $this->display($io, array($this->validate($stdin)));
}
if (!$this->isReadable($filename)) {
throw new \RuntimeException(sprintf('File or directory "%s" is not readable.', $filename));
}
$filesInfo = array();
foreach ($this->getFiles($filename) as $file) {
$filesInfo[] = $this->validate(file_get_contents($file), $file);
foreach ($this->getFiles($filename) as $file) {
$filesInfo[] = $this->validate(file_get_contents($file), $file);
}
}
return $this->display($io, $filesInfo);
}
private function validate($content, $file = null)
private function validate(string $content, string $file = null): array
{
$errors = [];
// Avoid: Warning DOMDocument::loadXML(): Empty string supplied as input
if ('' === trim($content)) {
return array('file' => $file, 'valid' => true);
return ['file' => $file, 'valid' => true];
}
libxml_use_internal_errors(true);
$internal = libxml_use_internal_errors(true);
$document = new \DOMDocument();
$document->loadXML($content);
if ($document->schemaValidate(__DIR__.'/../Resources/schemas/xliff-core-1.2-strict.xsd')) {
return array('file' => $file, 'valid' => true);
if (null !== $targetLanguage = $this->getTargetLanguageFromFile($document)) {
$normalizedLocalePattern = sprintf('(%s|%s)', preg_quote($targetLanguage, '/'), preg_quote(str_replace('-', '_', $targetLanguage), '/'));
// strict file names require translation files to be named '____.locale.xlf'
// otherwise, both '____.locale.xlf' and 'locale.____.xlf' are allowed
// also, the regexp matching must be case-insensitive, as defined for 'target-language' values
// http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#target-language
$expectedFilenamePattern = $this->requireStrictFileNames ? sprintf('/^.*\.(?i:%s)\.(?:xlf|xliff)/', $normalizedLocalePattern) : sprintf('/^(?:.*\.(?i:%s)|(?i:%s)\..*)\.(?:xlf|xliff)/', $normalizedLocalePattern, $normalizedLocalePattern);
if (0 === preg_match($expectedFilenamePattern, basename($file))) {
$errors[] = [
'line' => -1,
'column' => -1,
'message' => sprintf('There is a mismatch between the language included in the file name ("%s") and the "%s" value used in the "target-language" attribute of the file.', basename($file), $targetLanguage),
];
}
}
$errorMessages = array_map(function ($error) {
return array(
'line' => $error->line,
'column' => $error->column,
'message' => trim($error->message),
);
}, libxml_get_errors());
foreach (XliffUtils::validateSchema($document) as $xmlError) {
$errors[] = [
'line' => $xmlError['line'],
'column' => $xmlError['column'],
'message' => $xmlError['message'],
];
}
libxml_clear_errors();
libxml_use_internal_errors(false);
libxml_use_internal_errors($internal);
return array('file' => $file, 'valid' => false, 'messages' => $errorMessages);
return ['file' => $file, 'valid' => 0 === \count($errors), 'messages' => $errors];
}
private function display(SymfonyStyle $io, array $files)
@@ -134,15 +161,18 @@ EOF
return $this->displayTxt($io, $files);
case 'json':
return $this->displayJson($io, $files);
case 'github':
return $this->displayTxt($io, $files, true);
default:
throw new \InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format));
throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $this->format));
}
}
private function displayTxt(SymfonyStyle $io, array $filesInfo)
private function displayTxt(SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false)
{
$countFiles = count($filesInfo);
$countFiles = \count($filesInfo);
$erroredFiles = 0;
$githubReporter = $errorAsGithubAnnotations ? new GithubActionReporter($io) : null;
foreach ($filesInfo as $info) {
if ($info['valid'] && $this->displayCorrectFiles) {
@@ -150,9 +180,15 @@ EOF
} elseif (!$info['valid']) {
++$erroredFiles;
$io->text('<error> ERROR </error>'.($info['file'] ? sprintf(' in %s', $info['file']) : ''));
$io->listing(array_map(function ($error) {
$io->listing(array_map(function ($error) use ($info, $githubReporter) {
// general document errors have a '-1' line number
return -1 === $error['line'] ? $error['message'] : sprintf('Line %d, Column %d: %s', $error['line'], $error['column'], $error['message']);
$line = -1 === $error['line'] ? null : $error['line'];
if ($githubReporter) {
$githubReporter->error($error['message'], $info['file'], $line, null !== $line ? $error['column'] : null);
}
return null === $line ? $error['message'] : sprintf('Line %d, Column %d: %s', $line, $error['column'], $error['message']);
}, $info['messages']));
}
}
@@ -177,12 +213,12 @@ EOF
}
});
$io->writeln(json_encode($filesInfo, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$io->writeln(json_encode($filesInfo, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
return min($errors, 1);
}
private function getFiles($fileOrDirectory)
private function getFiles(string $fileOrDirectory)
{
if (is_file($fileOrDirectory)) {
yield new \SplFileInfo($fileOrDirectory);
@@ -191,7 +227,7 @@ EOF
}
foreach ($this->getDirectoryIterator($fileOrDirectory) as $file) {
if (!in_array($file->getExtension(), array('xlf', 'xliff'))) {
if (!\in_array($file->getExtension(), ['xlf', 'xliff'])) {
continue;
}
@@ -199,21 +235,7 @@ EOF
}
}
private function getStdin()
{
if (0 !== ftell(STDIN)) {
return;
}
$inputs = '';
while (!feof(STDIN)) {
$inputs .= fread(STDIN, 1024);
}
return $inputs;
}
private function getDirectoryIterator($directory)
private function getDirectoryIterator(string $directory)
{
$default = function ($directory) {
return new \RecursiveIteratorIterator(
@@ -223,22 +245,40 @@ EOF
};
if (null !== $this->directoryIteratorProvider) {
return call_user_func($this->directoryIteratorProvider, $directory, $default);
return ($this->directoryIteratorProvider)($directory, $default);
}
return $default($directory);
}
private function isReadable($fileOrDirectory)
private function isReadable(string $fileOrDirectory)
{
$default = function ($fileOrDirectory) {
return is_readable($fileOrDirectory);
};
if (null !== $this->isReadableProvider) {
return call_user_func($this->isReadableProvider, $fileOrDirectory, $default);
return ($this->isReadableProvider)($fileOrDirectory, $default);
}
return $default($fileOrDirectory);
}
private function getTargetLanguageFromFile(\DOMDocument $xliffContents): ?string
{
foreach ($xliffContents->getElementsByTagName('file')[0]->attributes ?? [] as $attribute) {
if ('target-language' === $attribute->nodeName) {
return $attribute->nodeValue;
}
}
return null;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues(['txt', 'json', 'github']);
}
}
}

View File

@@ -16,20 +16,17 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Component\VarDumper\Cloner\Data;
/**
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*
* @final
*/
class TranslationDataCollector extends DataCollector implements LateDataCollectorInterface
{
/**
* @var DataCollectorTranslator
*/
private $translator;
/**
* @param DataCollectorTranslator $translator
*/
public function __construct(DataCollectorTranslator $translator)
{
$this->translator = $translator;
@@ -42,7 +39,7 @@ class TranslationDataCollector extends DataCollector implements LateDataCollecto
{
$messages = $this->sanitizeCollectedMessages($this->translator->getCollectedMessages());
$this->data = $this->computeCount($messages);
$this->data += $this->computeCount($messages);
$this->data['messages'] = $messages;
$this->data = $this->cloneVar($this->data);
@@ -51,59 +48,70 @@ class TranslationDataCollector extends DataCollector implements LateDataCollecto
/**
* {@inheritdoc}
*/
public function collect(Request $request, Response $response, \Exception $exception = null)
public function collect(Request $request, Response $response, \Throwable $exception = null)
{
}
/**
* @return array
*/
public function getMessages()
{
return isset($this->data['messages']) ? $this->data['messages'] : array();
}
/**
* @return int
*/
public function getCountMissings()
{
return isset($this->data[DataCollectorTranslator::MESSAGE_MISSING]) ? $this->data[DataCollectorTranslator::MESSAGE_MISSING] : 0;
}
/**
* @return int
*/
public function getCountFallbacks()
{
return isset($this->data[DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK]) ? $this->data[DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK] : 0;
}
/**
* @return int
*/
public function getCountDefines()
{
return isset($this->data[DataCollectorTranslator::MESSAGE_DEFINED]) ? $this->data[DataCollectorTranslator::MESSAGE_DEFINED] : 0;
$this->data['locale'] = $this->translator->getLocale();
$this->data['fallback_locales'] = $this->translator->getFallbackLocales();
}
/**
* {@inheritdoc}
*/
public function getName()
public function reset()
{
$this->data = [];
}
public function getMessages(): array|Data
{
return $this->data['messages'] ?? [];
}
public function getCountMissings(): int
{
return $this->data[DataCollectorTranslator::MESSAGE_MISSING] ?? 0;
}
public function getCountFallbacks(): int
{
return $this->data[DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK] ?? 0;
}
public function getCountDefines(): int
{
return $this->data[DataCollectorTranslator::MESSAGE_DEFINED] ?? 0;
}
public function getLocale()
{
return !empty($this->data['locale']) ? $this->data['locale'] : null;
}
/**
* @internal
*/
public function getFallbackLocales()
{
return (isset($this->data['fallback_locales']) && \count($this->data['fallback_locales']) > 0) ? $this->data['fallback_locales'] : [];
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'translation';
}
private function sanitizeCollectedMessages($messages)
private function sanitizeCollectedMessages(array $messages)
{
$result = array();
$result = [];
foreach ($messages as $key => $message) {
$messageId = $message['locale'].$message['domain'].$message['id'];
if (!isset($result[$messageId])) {
$message['count'] = 1;
$message['parameters'] = !empty($message['parameters']) ? array($message['parameters']) : array();
$message['parameters'] = !empty($message['parameters']) ? [$message['parameters']] : [];
$messages[$key]['translation'] = $this->sanitizeString($message['translation']);
$result[$messageId] = $message;
} else {
@@ -120,13 +128,13 @@ class TranslationDataCollector extends DataCollector implements LateDataCollecto
return $result;
}
private function computeCount($messages)
private function computeCount(array $messages)
{
$count = array(
$count = [
DataCollectorTranslator::MESSAGE_DEFINED => 0,
DataCollectorTranslator::MESSAGE_MISSING => 0,
DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK => 0,
);
];
foreach ($messages as $message) {
++$count[$message['state']];
@@ -135,7 +143,7 @@ class TranslationDataCollector extends DataCollector implements LateDataCollecto
return $count;
}
private function sanitizeString($string, $length = 80)
private function sanitizeString(string $string, int $length = 80)
{
$string = trim(preg_replace('/\s+/', ' ', $string));
@@ -143,7 +151,7 @@ class TranslationDataCollector extends DataCollector implements LateDataCollecto
if (mb_strlen($string, $encoding) > $length) {
return mb_substr($string, 0, $length - 3, $encoding).'...';
}
} elseif (strlen($string) > $length) {
} elseif (\strlen($string) > $length) {
return substr($string, 0, $length - 3).'...';
}

View File

@@ -11,34 +11,30 @@
namespace Symfony\Component\Translation;
use Symfony\Component\HttpKernel\CacheWarmer\WarmableInterface;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInterface
class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInterface, LocaleAwareInterface, WarmableInterface
{
const MESSAGE_DEFINED = 0;
const MESSAGE_MISSING = 1;
const MESSAGE_EQUALS_FALLBACK = 2;
public const MESSAGE_DEFINED = 0;
public const MESSAGE_MISSING = 1;
public const MESSAGE_EQUALS_FALLBACK = 2;
/**
* @var TranslatorInterface|TranslatorBagInterface
*/
private $translator;
private array $messages = [];
/**
* @var array
*/
private $messages = array();
/**
* @param TranslatorInterface $translator The translator must implement TranslatorBagInterface
* @param TranslatorInterface&TranslatorBagInterface&LocaleAwareInterface $translator
*/
public function __construct(TranslatorInterface $translator)
{
if (!$translator instanceof TranslatorBagInterface) {
throw new InvalidArgumentException(sprintf('The Translator "%s" must implement TranslatorInterface and TranslatorBagInterface.', get_class($translator)));
if (!$translator instanceof TranslatorBagInterface || !$translator instanceof LocaleAwareInterface) {
throw new InvalidArgumentException(sprintf('The Translator "%s" must implement TranslatorInterface, TranslatorBagInterface and LocaleAwareInterface.', get_debug_type($translator)));
}
$this->translator = $translator;
@@ -47,9 +43,9 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter
/**
* {@inheritdoc}
*/
public function trans($id, array $parameters = array(), $domain = null, $locale = null)
public function trans(?string $id, array $parameters = [], string $domain = null, string $locale = null): string
{
$trans = $this->translator->trans($id, $parameters, $domain, $locale);
$trans = $this->translator->trans($id = (string) $id, $parameters, $domain, $locale);
$this->collectMessage($locale, $domain, $id, $trans, $parameters);
return $trans;
@@ -58,18 +54,7 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter
/**
* {@inheritdoc}
*/
public function transChoice($id, $number, array $parameters = array(), $domain = null, $locale = null)
{
$trans = $this->translator->transChoice($id, $number, $parameters, $domain, $locale);
$this->collectMessage($locale, $domain, $id, $trans, $parameters, $number);
return $trans;
}
/**
* {@inheritdoc}
*/
public function setLocale($locale)
public function setLocale(string $locale)
{
$this->translator->setLocale($locale);
}
@@ -77,7 +62,7 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter
/**
* {@inheritdoc}
*/
public function getLocale()
public function getLocale(): string
{
return $this->translator->getLocale();
}
@@ -85,58 +70,67 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter
/**
* {@inheritdoc}
*/
public function getCatalogue($locale = null)
public function getCatalogue(string $locale = null): MessageCatalogueInterface
{
return $this->translator->getCatalogue($locale);
}
/**
* Gets the fallback locales.
*
* @return array $locales The fallback locales
* {@inheritdoc}
*/
public function getFallbackLocales()
public function getCatalogues(): array
{
return $this->translator->getCatalogues();
}
/**
* {@inheritdoc}
*
* @return string[]
*/
public function warmUp(string $cacheDir): array
{
if ($this->translator instanceof WarmableInterface) {
return (array) $this->translator->warmUp($cacheDir);
}
return [];
}
/**
* Gets the fallback locales.
*/
public function getFallbackLocales(): array
{
if ($this->translator instanceof Translator || method_exists($this->translator, 'getFallbackLocales')) {
return $this->translator->getFallbackLocales();
}
return array();
return [];
}
/**
* Passes through all unknown calls onto the translator object.
*/
public function __call($method, $args)
public function __call(string $method, array $args)
{
return call_user_func_array(array($this->translator, $method), $args);
return $this->translator->{$method}(...$args);
}
/**
* @return array
*/
public function getCollectedMessages()
public function getCollectedMessages(): array
{
return $this->messages;
}
/**
* @param string|null $locale
* @param string|null $domain
* @param string $id
* @param string $translation
* @param array|null $parameters
* @param int|null $number
*/
private function collectMessage($locale, $domain, $id, $translation, $parameters = array(), $number = null)
private function collectMessage(?string $locale, ?string $domain, string $id, string $translation, ?array $parameters = [])
{
if (null === $domain) {
$domain = 'messages';
}
$id = (string) $id;
$catalogue = $this->translator->getCatalogue($locale);
$locale = $catalogue->getLocale();
$fallbackLocale = null;
if ($catalogue->defines($id, $domain)) {
$state = self::MESSAGE_DEFINED;
} elseif ($catalogue->has($id, $domain)) {
@@ -145,24 +139,24 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter
$fallbackCatalogue = $catalogue->getFallbackCatalogue();
while ($fallbackCatalogue) {
if ($fallbackCatalogue->defines($id, $domain)) {
$locale = $fallbackCatalogue->getLocale();
$fallbackLocale = $fallbackCatalogue->getLocale();
break;
}
$fallbackCatalogue = $fallbackCatalogue->getFallbackCatalogue();
}
} else {
$state = self::MESSAGE_MISSING;
}
$this->messages[] = array(
$this->messages[] = [
'locale' => $locale,
'fallbackLocale' => $fallbackLocale,
'domain' => $domain,
'id' => $id,
'translation' => $translation,
'parameters' => $parameters,
'transChoiceNumber' => $number,
'state' => $state,
);
'transChoiceNumber' => isset($parameters['%count%']) && is_numeric($parameters['%count%']) ? $parameters['%count%'] : null,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Adds tagged translation.formatter services to translation writer.
*/
class TranslationDumperPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (!$container->hasDefinition('translation.writer')) {
return;
}
$definition = $container->getDefinition('translation.writer');
foreach ($container->findTaggedServiceIds('translation.dumper', true) as $id => $attributes) {
$definition->addMethodCall('addDumper', [$attributes[0]['alias'], new Reference($id)]);
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\RuntimeException;
use Symfony\Component\DependencyInjection\Reference;
/**
* Adds tagged translation.extractor services to translation extractor.
*/
class TranslationExtractorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (!$container->hasDefinition('translation.extractor')) {
return;
}
$definition = $container->getDefinition('translation.extractor');
foreach ($container->findTaggedServiceIds('translation.extractor', true) as $id => $attributes) {
if (!isset($attributes[0]['alias'])) {
throw new RuntimeException(sprintf('The alias for the tag "translation.extractor" of service "%s" must be set.', $id));
}
$definition->addMethodCall('addExtractor', [$attributes[0]['alias'], new Reference($id)]);
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class TranslatorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (!$container->hasDefinition('translator.default')) {
return;
}
$loaders = [];
$loaderRefs = [];
foreach ($container->findTaggedServiceIds('translation.loader', true) as $id => $attributes) {
$loaderRefs[$id] = new Reference($id);
$loaders[$id][] = $attributes[0]['alias'];
if (isset($attributes[0]['legacy-alias'])) {
$loaders[$id][] = $attributes[0]['legacy-alias'];
}
}
if ($container->hasDefinition('translation.reader')) {
$definition = $container->getDefinition('translation.reader');
foreach ($loaders as $id => $formats) {
foreach ($formats as $format) {
$definition->addMethodCall('addLoader', [$format, $loaderRefs[$id]]);
}
}
}
$container
->findDefinition('translator.default')
->replaceArgument(0, ServiceLocatorTagPass::register($container, $loaderRefs))
->replaceArgument(3, $loaders)
;
if (!$container->hasParameter('twig.default_path')) {
return;
}
$paths = array_keys($container->getDefinition('twig.template_iterator')->getArgument(1));
if ($container->hasDefinition('console.command.translation_debug')) {
$definition = $container->getDefinition('console.command.translation_debug');
$definition->replaceArgument(4, $container->getParameter('twig.default_path'));
if (\count($definition->getArguments()) > 6) {
$definition->replaceArgument(6, $paths);
}
}
if ($container->hasDefinition('console.command.translation_extract')) {
$definition = $container->getDefinition('console.command.translation_extract');
$definition->replaceArgument(5, $container->getParameter('twig.default_path'));
if (\count($definition->getArguments()) > 7) {
$definition->replaceArgument(7, $paths);
}
}
}
}

View File

@@ -0,0 +1,147 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\AbstractRecursivePass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
/**
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class TranslatorPathsPass extends AbstractRecursivePass
{
private int $level = 0;
/**
* @var array<string, bool>
*/
private array $paths = [];
/**
* @var array<int, Definition>
*/
private array $definitions = [];
/**
* @var array<string, array<string, bool>>
*/
private array $controllers = [];
public function process(ContainerBuilder $container)
{
if (!$container->hasDefinition('translator')) {
return;
}
foreach ($this->findControllerArguments($container) as $controller => $argument) {
$id = substr($controller, 0, strpos($controller, ':') ?: \strlen($controller));
if ($container->hasDefinition($id)) {
[$locatorRef] = $argument->getValues();
$this->controllers[(string) $locatorRef][$container->getDefinition($id)->getClass()] = true;
}
}
try {
parent::process($container);
$paths = [];
foreach ($this->paths as $class => $_) {
if (($r = $container->getReflectionClass($class)) && !$r->isInterface()) {
$paths[] = $r->getFileName();
foreach ($r->getTraits() as $trait) {
$paths[] = $trait->getFileName();
}
}
}
if ($paths) {
if ($container->hasDefinition('console.command.translation_debug')) {
$definition = $container->getDefinition('console.command.translation_debug');
$definition->replaceArgument(6, array_merge($definition->getArgument(6), $paths));
}
if ($container->hasDefinition('console.command.translation_extract')) {
$definition = $container->getDefinition('console.command.translation_extract');
$definition->replaceArgument(7, array_merge($definition->getArgument(7), $paths));
}
}
} finally {
$this->level = 0;
$this->paths = [];
$this->definitions = [];
}
}
protected function processValue(mixed $value, bool $isRoot = false): mixed
{
if ($value instanceof Reference) {
if ('translator' === (string) $value) {
for ($i = $this->level - 1; $i >= 0; --$i) {
$class = $this->definitions[$i]->getClass();
if (ServiceLocator::class === $class) {
if (!isset($this->controllers[$this->currentId])) {
continue;
}
foreach ($this->controllers[$this->currentId] as $class => $_) {
$this->paths[$class] = true;
}
} else {
$this->paths[$class] = true;
}
break;
}
}
return $value;
}
if ($value instanceof Definition) {
$this->definitions[$this->level++] = $value;
$value = parent::processValue($value, $isRoot);
unset($this->definitions[--$this->level]);
return $value;
}
return parent::processValue($value, $isRoot);
}
private function findControllerArguments(ContainerBuilder $container): array
{
if ($container->hasDefinition('argument_resolver.service')) {
$argument = $container->getDefinition('argument_resolver.service')->getArgument(0);
if ($argument instanceof Reference) {
$argument = $container->getDefinition($argument);
}
return $argument->getArgument(0);
}
if ($container->hasDefinition('debug.'.'argument_resolver.service')) {
$argument = $container->getDefinition('debug.'.'argument_resolver.service')->getArgument(0);
if ($argument instanceof Reference) {
$argument = $container->getDefinition($argument);
}
$argument = $argument->getArgument(0);
if ($argument instanceof Reference) {
$argument = $container->getDefinition($argument);
}
return $argument->getArgument(0);
}
return [];
}
}

View File

@@ -20,18 +20,18 @@ use Symfony\Component\Translation\MessageCatalogue;
*/
class CsvFileDumper extends FileDumper
{
private $delimiter = ';';
private $enclosure = '"';
private string $delimiter = ';';
private string $enclosure = '"';
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$handle = fopen('php://memory', 'rb+');
$handle = fopen('php://memory', 'r+');
foreach ($messages->all($domain) as $source => $target) {
fputcsv($handle, array($source, $target), $this->delimiter, $this->enclosure);
fputcsv($handle, [$source, $target], $this->delimiter, $this->enclosure);
}
rewind($handle);
@@ -43,11 +43,8 @@ class CsvFileDumper extends FileDumper
/**
* Sets the delimiter and escape character for CSV.
*
* @param string $delimiter delimiter character
* @param string $enclosure enclosure character
*/
public function setCsvControl($delimiter = ';', $enclosure = '"')
public function setCsvControl(string $delimiter = ';', string $enclosure = '"')
{
$this->delimiter = $delimiter;
$this->enclosure = $enclosure;
@@ -56,7 +53,7 @@ class CsvFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'csv';
}

View File

@@ -24,8 +24,7 @@ interface DumperInterface
/**
* Dumps the message catalogue.
*
* @param MessageCatalogue $messages The message catalogue
* @param array $options Options that are used by the dumper
* @param array $options Options that are used by the dumper
*/
public function dump(MessageCatalogue $messages, $options = array());
public function dump(MessageCatalogue $messages, array $options = []);
}

View File

@@ -11,13 +11,12 @@
namespace Symfony\Component\Translation\Dumper;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Translation\Exception\RuntimeException;
use Symfony\Component\Translation\MessageCatalogue;
/**
* FileDumper is an implementation of DumperInterface that dump a message catalogue to file(s).
* Performs backup of already existing files.
*
* Options:
* - path (mandatory): the directory where the files should be saved
@@ -33,94 +32,77 @@ abstract class FileDumper implements DumperInterface
*/
protected $relativePathTemplate = '%domain%.%locale%.%extension%';
/**
* Make file backup before the dump.
*
* @var bool
*/
private $backup = true;
/**
* Sets the template for the relative paths to files.
*
* @param string $relativePathTemplate A template for the relative paths to files
*/
public function setRelativePathTemplate($relativePathTemplate)
public function setRelativePathTemplate(string $relativePathTemplate)
{
$this->relativePathTemplate = $relativePathTemplate;
}
/**
* Sets backup flag.
*
* @param bool
*/
public function setBackup($backup)
{
$this->backup = $backup;
}
/**
* {@inheritdoc}
*/
public function dump(MessageCatalogue $messages, $options = array())
public function dump(MessageCatalogue $messages, array $options = [])
{
if (!array_key_exists('path', $options)) {
if (!\array_key_exists('path', $options)) {
throw new InvalidArgumentException('The file dumper needs a path option.');
}
// save a file for each domain
foreach ($messages->getDomains() as $domain) {
// backup
$fullpath = $options['path'].'/'.$this->getRelativePath($domain, $messages->getLocale());
if (file_exists($fullpath)) {
if ($this->backup) {
@trigger_error('Creating a backup while dumping a message catalogue is deprecated since version 3.1 and will be removed in 4.0. Use TranslationWriter::disableBackup() to disable the backup.', E_USER_DEPRECATED);
copy($fullpath, $fullpath.'~');
}
} else {
$directory = dirname($fullpath);
if (!file_exists($fullpath)) {
$directory = \dirname($fullpath);
if (!file_exists($directory) && !@mkdir($directory, 0777, true)) {
throw new RuntimeException(sprintf('Unable to create directory "%s".', $directory));
}
}
// save file
$intlDomain = $domain.MessageCatalogue::INTL_DOMAIN_SUFFIX;
$intlMessages = $messages->all($intlDomain);
if ($intlMessages) {
$intlPath = $options['path'].'/'.$this->getRelativePath($intlDomain, $messages->getLocale());
file_put_contents($intlPath, $this->formatCatalogue($messages, $intlDomain, $options));
$messages->replace([], $intlDomain);
try {
if ($messages->all($domain)) {
file_put_contents($fullpath, $this->formatCatalogue($messages, $domain, $options));
}
continue;
} finally {
$messages->replace($intlMessages, $intlDomain);
}
}
file_put_contents($fullpath, $this->formatCatalogue($messages, $domain, $options));
}
}
/**
* Transforms a domain of a message catalogue to its string representation.
*
* @param MessageCatalogue $messages
* @param string $domain
* @param array $options
*
* @return string representation
*/
abstract public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array());
abstract public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string;
/**
* Gets the file extension of the dumper.
*
* @return string file extension
*/
abstract protected function getExtension();
abstract protected function getExtension(): string;
/**
* Gets the relative file path using the template.
*
* @param string $domain The domain
* @param string $locale The locale
*
* @return string The relative file path
*/
private function getRelativePath($domain, $locale)
private function getRelativePath(string $domain, string $locale): string
{
return strtr($this->relativePathTemplate, array(
return strtr($this->relativePathTemplate, [
'%domain%' => $domain,
'%locale%' => $locale,
'%extension%' => $this->getExtension(),
));
]);
}
}

View File

@@ -28,12 +28,12 @@ class IcuResFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$data = $indexes = $resources = '';
foreach ($messages->all($domain) as $source => $target) {
$indexes .= pack('v', strlen($data) + 28);
$indexes .= pack('v', \strlen($data) + 28);
$data .= $source."\0";
}
@@ -44,19 +44,19 @@ class IcuResFileDumper extends FileDumper
foreach ($messages->all($domain) as $source => $target) {
$resources .= pack('V', $this->getPosition($data));
$data .= pack('V', strlen($target))
$data .= pack('V', \strlen($target))
.mb_convert_encoding($target."\0", 'UTF-16LE', 'UTF-8')
.$this->writePadding($data)
;
;
}
$resOffset = $this->getPosition($data);
$data .= pack('v', count($messages->all($domain)))
$data .= pack('v', \count($messages->all($domain)))
.$indexes
.$this->writePadding($data)
.$resources
;
;
$bundleTop = $this->getPosition($data);
@@ -66,7 +66,7 @@ class IcuResFileDumper extends FileDumper
$keyTop, // Index keys top
$bundleTop, // Index resources top
$bundleTop, // Index bundle top
count($messages->all($domain)), // Index max table length
\count($messages->all($domain)), // Index max table length
0 // Index attributes
);
@@ -82,24 +82,22 @@ class IcuResFileDumper extends FileDumper
return $header.$root.$data;
}
private function writePadding($data)
private function writePadding(string $data): ?string
{
$padding = strlen($data) % 4;
$padding = \strlen($data) % 4;
if ($padding) {
return str_repeat("\xAA", 4 - $padding);
}
return $padding ? str_repeat("\xAA", 4 - $padding) : null;
}
private function getPosition($data)
private function getPosition(string $data)
{
return (strlen($data) + 28) / 4;
return (\strlen($data) + 28) / 4;
}
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'res';
}

View File

@@ -23,7 +23,7 @@ class IniFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$output = '';
@@ -38,7 +38,7 @@ class IniFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'ini';
}

View File

@@ -23,13 +23,9 @@ class JsonFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
if (isset($options['json_encoding'])) {
$flags = $options['json_encoding'];
} else {
$flags = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
}
$flags = $options['json_encoding'] ?? \JSON_PRETTY_PRINT;
return json_encode($messages->all($domain), $flags);
}
@@ -37,7 +33,7 @@ class JsonFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'json';
}

View File

@@ -11,8 +11,8 @@
namespace Symfony\Component\Translation\Dumper;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Loader\MoFileLoader;
use Symfony\Component\Translation\MessageCatalogue;
/**
* MoFileDumper generates a gettext formatted string representation of a message catalogue.
@@ -24,20 +24,20 @@ class MoFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$sources = $targets = $sourceOffsets = $targetOffsets = '';
$offsets = array();
$offsets = [];
$size = 0;
foreach ($messages->all($domain) as $source => $target) {
$offsets[] = array_map('strlen', array($sources, $source, $targets, $target));
$offsets[] = array_map('strlen', [$sources, $source, $targets, $target]);
$sources .= "\0".$source;
$targets .= "\0".$target;
++$size;
}
$header = array(
$header = [
'magicNumber' => MoFileLoader::MO_LITTLE_ENDIAN_MAGIC,
'formatRevision' => 0,
'count' => $size,
@@ -45,9 +45,9 @@ class MoFileDumper extends FileDumper
'offsetTranslated' => MoFileLoader::MO_HEADER_SIZE + (8 * $size),
'sizeHashes' => 0,
'offsetHashes' => MoFileLoader::MO_HEADER_SIZE + (16 * $size),
);
];
$sourcesSize = strlen($sources);
$sourcesSize = \strlen($sources);
$sourcesStart = $header['offsetHashes'] + 1;
foreach ($offsets as $offset) {
@@ -57,12 +57,12 @@ class MoFileDumper extends FileDumper
.$this->writeLong($offset[2] + $sourcesStart + $sourcesSize);
}
$output = implode(array_map(array($this, 'writeLong'), $header))
$output = implode('', array_map([$this, 'writeLong'], $header))
.$sourceOffsets
.$targetOffsets
.$sources
.$targets
;
;
return $output;
}
@@ -70,12 +70,12 @@ class MoFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'mo';
}
private function writeLong($str)
private function writeLong(mixed $str): string
{
return pack('V*', $str);
}

View File

@@ -23,7 +23,7 @@ class PhpFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
return "<?php\n\nreturn ".var_export($messages->all($domain), true).";\n";
}
@@ -31,7 +31,7 @@ class PhpFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'php';
}

View File

@@ -23,7 +23,7 @@ class PoFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$output = 'msgid ""'."\n";
$output .= 'msgstr ""'."\n";
@@ -39,23 +39,99 @@ class PoFileDumper extends FileDumper
} else {
$newLine = true;
}
$output .= sprintf('msgid "%s"'."\n", $this->escape($source));
$output .= sprintf('msgstr "%s"', $this->escape($target));
$metadata = $messages->getMetadata($source, $domain);
if (isset($metadata['comments'])) {
$output .= $this->formatComments($metadata['comments']);
}
if (isset($metadata['flags'])) {
$output .= $this->formatComments(implode(',', (array) $metadata['flags']), ',');
}
if (isset($metadata['sources'])) {
$output .= $this->formatComments(implode(' ', (array) $metadata['sources']), ':');
}
$sourceRules = $this->getStandardRules($source);
$targetRules = $this->getStandardRules($target);
if (2 == \count($sourceRules) && [] !== $targetRules) {
$output .= sprintf('msgid "%s"'."\n", $this->escape($sourceRules[0]));
$output .= sprintf('msgid_plural "%s"'."\n", $this->escape($sourceRules[1]));
foreach ($targetRules as $i => $targetRule) {
$output .= sprintf('msgstr[%d] "%s"'."\n", $i, $this->escape($targetRule));
}
} else {
$output .= sprintf('msgid "%s"'."\n", $this->escape($source));
$output .= sprintf('msgstr "%s"'."\n", $this->escape($target));
}
}
return $output;
}
private function getStandardRules(string $id)
{
// Partly copied from TranslatorTrait::trans.
$parts = [];
if (preg_match('/^\|++$/', $id)) {
$parts = explode('|', $id);
} elseif (preg_match_all('/(?:\|\||[^\|])++/', $id, $matches)) {
$parts = $matches[0];
}
$intervalRegexp = <<<'EOF'
/^(?P<interval>
({\s*
(\-?\d+(\.\d+)?[\s*,\s*\-?\d+(\.\d+)?]*)
\s*})
|
(?P<left_delimiter>[\[\]])
\s*
(?P<left>-Inf|\-?\d+(\.\d+)?)
\s*,\s*
(?P<right>\+?Inf|\-?\d+(\.\d+)?)
\s*
(?P<right_delimiter>[\[\]])
)\s*(?P<message>.*?)$/xs
EOF;
$standardRules = [];
foreach ($parts as $part) {
$part = trim(str_replace('||', '|', $part));
if (preg_match($intervalRegexp, $part)) {
// Explicit rule is not a standard rule.
return [];
} else {
$standardRules[] = $part;
}
}
return $standardRules;
}
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'po';
}
private function escape($str)
private function escape(string $str): string
{
return addcslashes($str, "\0..\37\42\134");
}
private function formatComments(string|array $comments, string $prefix = ''): ?string
{
$output = null;
foreach ((array) $comments as $comment) {
$output .= sprintf('#%s %s'."\n", $prefix, $comment);
}
return $output;
}
}

View File

@@ -23,7 +23,7 @@ class QtFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
@@ -33,6 +33,17 @@ class QtFileDumper extends FileDumper
foreach ($messages->all($domain) as $source => $target) {
$message = $context->appendChild($dom->createElement('message'));
$metadata = $messages->getMetadata($source, $domain);
if (isset($metadata['sources'])) {
foreach ((array) $metadata['sources'] as $location) {
$loc = explode(':', $location, 2);
$location = $message->appendChild($dom->createElement('location'));
$location->setAttribute('filename', $loc[0]);
if (isset($loc[1])) {
$location->setAttribute('line', $loc[1]);
}
}
}
$message->appendChild($dom->createElement('source', $source));
$message->appendChild($dom->createElement('translation', $target));
}
@@ -43,7 +54,7 @@ class QtFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'ts';
}

View File

@@ -11,8 +11,8 @@
namespace Symfony\Component\Translation\Dumper;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Translation\MessageCatalogue;
/**
* XliffFileDumper generates xliff files from a message catalogue.
@@ -24,14 +24,14 @@ class XliffFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$xliffVersion = '1.2';
if (array_key_exists('xliff_version', $options)) {
if (\array_key_exists('xliff_version', $options)) {
$xliffVersion = $options['xliff_version'];
}
if (array_key_exists('default_locale', $options)) {
if (\array_key_exists('default_locale', $options)) {
$defaultLocale = $options['default_locale'];
} else {
$defaultLocale = \Locale::getDefault();
@@ -41,7 +41,7 @@ class XliffFileDumper extends FileDumper
return $this->dumpXliff1($defaultLocale, $messages, $domain, $options);
}
if ('2.0' === $xliffVersion) {
return $this->dumpXliff2($defaultLocale, $messages, $domain, $options);
return $this->dumpXliff2($defaultLocale, $messages, $domain);
}
throw new InvalidArgumentException(sprintf('No support implemented for dumping XLIFF version "%s".', $xliffVersion));
@@ -50,15 +50,15 @@ class XliffFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'xlf';
}
private function dumpXliff1($defaultLocale, MessageCatalogue $messages, $domain, array $options = array())
private function dumpXliff1(string $defaultLocale, MessageCatalogue $messages, ?string $domain, array $options = [])
{
$toolInfo = array('tool-id' => 'symfony', 'tool-name' => 'Symfony');
if (array_key_exists('tool_info', $options)) {
$toolInfo = ['tool-id' => 'symfony', 'tool-name' => 'Symfony'];
if (\array_key_exists('tool_info', $options)) {
$toolInfo = array_merge($toolInfo, $options['tool_info']);
}
@@ -85,7 +85,7 @@ class XliffFileDumper extends FileDumper
foreach ($messages->all($domain) as $source => $target) {
$translation = $dom->createElement('trans-unit');
$translation->setAttribute('id', md5($source));
$translation->setAttribute('id', strtr(substr(base64_encode(hash('sha256', $source, true)), 0, 7), '/+', '._'));
$translation->setAttribute('resname', $source);
$s = $translation->appendChild($dom->createElement('source'));
@@ -129,7 +129,7 @@ class XliffFileDumper extends FileDumper
return $dom->saveXML();
}
private function dumpXliff2($defaultLocale, MessageCatalogue $messages, $domain, array $options = array())
private function dumpXliff2(string $defaultLocale, MessageCatalogue $messages, ?string $domain)
{
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
@@ -141,11 +141,37 @@ class XliffFileDumper extends FileDumper
$xliff->setAttribute('trgLang', str_replace('_', '-', $messages->getLocale()));
$xliffFile = $xliff->appendChild($dom->createElement('file'));
$xliffFile->setAttribute('id', $domain.'.'.$messages->getLocale());
if (str_ends_with($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
$xliffFile->setAttribute('id', substr($domain, 0, -\strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX)).'.'.$messages->getLocale());
} else {
$xliffFile->setAttribute('id', $domain.'.'.$messages->getLocale());
}
foreach ($messages->all($domain) as $source => $target) {
$translation = $dom->createElement('unit');
$translation->setAttribute('id', md5($source));
$translation->setAttribute('id', strtr(substr(base64_encode(hash('sha256', $source, true)), 0, 7), '/+', '._'));
if (\strlen($source) <= 80) {
$translation->setAttribute('name', $source);
}
$metadata = $messages->getMetadata($source, $domain);
// Add notes section
if ($this->hasMetadataArrayInfo('notes', $metadata)) {
$notesElement = $dom->createElement('notes');
foreach ($metadata['notes'] as $note) {
$n = $dom->createElement('note');
$n->appendChild($dom->createTextNode($note['content'] ?? ''));
unset($note['content']);
foreach ($note as $name => $value) {
$n->setAttribute($name, $value);
}
$notesElement->appendChild($n);
}
$translation->appendChild($notesElement);
}
$segment = $translation->appendChild($dom->createElement('segment'));
@@ -156,7 +182,6 @@ class XliffFileDumper extends FileDumper
$text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
$targetElement = $dom->createElement('target');
$metadata = $messages->getMetadata($source, $domain);
if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
foreach ($metadata['target-attributes'] as $name => $value) {
$targetElement->setAttribute($name, $value);
@@ -171,14 +196,8 @@ class XliffFileDumper extends FileDumper
return $dom->saveXML();
}
/**
* @param string $key
* @param array|null $metadata
*
* @return bool
*/
private function hasMetadataArrayInfo($key, $metadata = null)
private function hasMetadataArrayInfo(string $key, array $metadata = null): bool
{
return null !== $metadata && array_key_exists($key, $metadata) && ($metadata[$key] instanceof \Traversable || is_array($metadata[$key]));
return is_iterable($metadata[$key] ?? null);
}
}

View File

@@ -11,10 +11,10 @@
namespace Symfony\Component\Translation\Dumper;
use Symfony\Component\Translation\Exception\LogicException;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Util\ArrayConverter;
use Symfony\Component\Yaml\Yaml;
use Symfony\Component\Translation\Exception\LogicException;
/**
* YamlFileDumper generates yaml files from a message catalogue.
@@ -23,12 +23,19 @@ use Symfony\Component\Translation\Exception\LogicException;
*/
class YamlFileDumper extends FileDumper
{
private string $extension;
public function __construct(string $extension = 'yml')
{
$this->extension = $extension;
}
/**
* {@inheritdoc}
*/
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
if (!class_exists('Symfony\Component\Yaml\Yaml')) {
if (!class_exists(Yaml::class)) {
throw new LogicException('Dumping translations in the YAML format requires the Symfony Yaml component.');
}
@@ -48,8 +55,8 @@ class YamlFileDumper extends FileDumper
/**
* {@inheritdoc}
*/
protected function getExtension()
protected function getExtension(): string
{
return 'yml';
return $this->extension;
}
}

View File

@@ -16,6 +16,6 @@ namespace Symfony\Component\Translation\Exception;
*
* @author Fabien Potencier <fabien@symfony.com>
*/
interface ExceptionInterface
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Exception;
class IncompleteDsnException extends InvalidArgumentException
{
public function __construct(string $message, string $dsn = null, \Throwable $previous = null)
{
if ($dsn) {
$message = sprintf('Invalid "%s" provider DSN: ', $dsn).$message;
}
parent::__construct($message, 0, $previous);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
class MissingRequiredOptionException extends IncompleteDsnException
{
public function __construct(string $option, string $dsn = null, \Throwable $previous = null)
{
$message = sprintf('The option "%s" is required but missing.', $option);
parent::__construct($message, $dsn, $previous);
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Exception;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class ProviderException extends RuntimeException implements ProviderExceptionInterface
{
private $response;
private string $debug;
public function __construct(string $message, ResponseInterface $response, int $code = 0, \Exception $previous = null)
{
$this->response = $response;
$this->debug = $response->getInfo('debug') ?? '';
parent::__construct($message, $code, $previous);
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
public function getDebug(): string
{
return $this->debug;
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Exception;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
interface ProviderExceptionInterface extends ExceptionInterface
{
/*
* Returns debug info coming from the Symfony\Contracts\HttpClient\ResponseInterface
*/
public function getDebug(): string;
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Exception;
use Symfony\Component\Translation\Bridge;
use Symfony\Component\Translation\Provider\Dsn;
class UnsupportedSchemeException extends LogicException
{
private const SCHEME_TO_PACKAGE_MAP = [
'crowdin' => [
'class' => Bridge\Crowdin\CrowdinProviderFactory::class,
'package' => 'symfony/crowdin-translation-provider',
],
'loco' => [
'class' => Bridge\Loco\LocoProviderFactory::class,
'package' => 'symfony/loco-translation-provider',
],
'lokalise' => [
'class' => Bridge\Lokalise\LokaliseProviderFactory::class,
'package' => 'symfony/lokalise-translation-provider',
],
];
public function __construct(Dsn $dsn, string $name = null, array $supported = [])
{
$provider = $dsn->getScheme();
if (false !== $pos = strpos($provider, '+')) {
$provider = substr($provider, 0, $pos);
}
$package = self::SCHEME_TO_PACKAGE_MAP[$provider] ?? null;
if ($package && !class_exists($package['class'])) {
parent::__construct(sprintf('Unable to synchronize translations via "%s" as the provider is not installed; try running "composer require %s".', $provider, $package['package']));
return;
}
$message = sprintf('The "%s" scheme is not supported', $dsn->getScheme());
if ($name && $supported) {
$message .= sprintf('; supported schemes for translation provider "%s" are: "%s"', $name, implode('", "', $supported));
}
parent::__construct($message.'.');
}
}

View File

@@ -20,22 +20,17 @@ use Symfony\Component\Translation\Exception\InvalidArgumentException;
*/
abstract class AbstractFileExtractor
{
/**
* @param string|array $resource files, a file or a directory
*
* @return array
*/
protected function extractFiles($resource)
protected function extractFiles(string|iterable $resource): iterable
{
if (is_array($resource) || $resource instanceof \Traversable) {
$files = array();
if (is_iterable($resource)) {
$files = [];
foreach ($resource as $file) {
if ($this->canBeExtracted($file)) {
$files[] = $this->toSplFileInfo($file);
}
}
} elseif (is_file($resource)) {
$files = $this->canBeExtracted($resource) ? array($this->toSplFileInfo($resource)) : array();
$files = $this->canBeExtracted($resource) ? [$this->toSplFileInfo($resource)] : [];
} else {
$files = $this->extractFromDirectory($resource);
}
@@ -43,24 +38,15 @@ abstract class AbstractFileExtractor
return $files;
}
/**
* @param string $file
*
* @return \SplFileInfo
*/
private function toSplFileInfo($file)
private function toSplFileInfo(string $file): \SplFileInfo
{
return ($file instanceof \SplFileInfo) ? $file : new \SplFileInfo($file);
return new \SplFileInfo($file);
}
/**
* @param string $file
*
* @return bool
*
* @throws InvalidArgumentException
*/
protected function isFile($file)
protected function isFile(string $file): bool
{
if (!is_file($file)) {
throw new InvalidArgumentException(sprintf('The "%s" file does not exist.', $file));
@@ -70,16 +56,12 @@ abstract class AbstractFileExtractor
}
/**
* @param string $file
*
* @return bool
*/
abstract protected function canBeExtracted($file);
abstract protected function canBeExtracted(string $file);
/**
* @param string|array $resource files, a file or a directory
*
* @return array files to be extracted
* @return iterable
*/
abstract protected function extractFromDirectory($resource);
abstract protected function extractFromDirectory(string|array $resource);
}

View File

@@ -25,15 +25,12 @@ class ChainExtractor implements ExtractorInterface
*
* @var ExtractorInterface[]
*/
private $extractors = array();
private array $extractors = [];
/**
* Adds a loader to the translation extractor.
*
* @param string $format The format of the loader
* @param ExtractorInterface $extractor The loader
*/
public function addExtractor($format, ExtractorInterface $extractor)
public function addExtractor(string $format, ExtractorInterface $extractor)
{
$this->extractors[$format] = $extractor;
}
@@ -41,7 +38,7 @@ class ChainExtractor implements ExtractorInterface
/**
* {@inheritdoc}
*/
public function setPrefix($prefix)
public function setPrefix(string $prefix)
{
foreach ($this->extractors as $extractor) {
$extractor->setPrefix($prefix);
@@ -51,7 +48,7 @@ class ChainExtractor implements ExtractorInterface
/**
* {@inheritdoc}
*/
public function extract($directory, MessageCatalogue $catalogue)
public function extract(string|iterable $directory, MessageCatalogue $catalogue)
{
foreach ($this->extractors as $extractor) {
$extractor->extract($directory, $catalogue);

View File

@@ -24,15 +24,12 @@ interface ExtractorInterface
/**
* Extracts translation messages from files, a file or a directory to the catalogue.
*
* @param string|array $resource files, a file or a directory
* @param MessageCatalogue $catalogue The catalogue
* @param string|iterable<string> $resource Files, a file or a directory
*/
public function extract($resource, MessageCatalogue $catalogue);
public function extract(string|iterable $resource, MessageCatalogue $catalogue);
/**
* Sets the prefix that should be used for new found messages.
*
* @param string $prefix The prefix
*/
public function setPrefix($prefix);
public function setPrefix(string $prefix);
}

View File

@@ -0,0 +1,330 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Extractor;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Translation\MessageCatalogue;
/**
* PhpExtractor extracts translation messages from a PHP template.
*
* @author Michel Salib <michelsalib@hotmail.com>
*/
class PhpExtractor extends AbstractFileExtractor implements ExtractorInterface
{
public const MESSAGE_TOKEN = 300;
public const METHOD_ARGUMENTS_TOKEN = 1000;
public const DOMAIN_TOKEN = 1001;
/**
* Prefix for new found message.
*/
private string $prefix = '';
/**
* The sequence that captures translation messages.
*/
protected $sequences = [
[
'->',
'trans',
'(',
self::MESSAGE_TOKEN,
',',
self::METHOD_ARGUMENTS_TOKEN,
',',
self::DOMAIN_TOKEN,
],
[
'->',
'trans',
'(',
self::MESSAGE_TOKEN,
],
[
'new',
'TranslatableMessage',
'(',
self::MESSAGE_TOKEN,
',',
self::METHOD_ARGUMENTS_TOKEN,
',',
self::DOMAIN_TOKEN,
],
[
'new',
'TranslatableMessage',
'(',
self::MESSAGE_TOKEN,
],
[
'new',
'\\',
'Symfony',
'\\',
'Component',
'\\',
'Translation',
'\\',
'TranslatableMessage',
'(',
self::MESSAGE_TOKEN,
',',
self::METHOD_ARGUMENTS_TOKEN,
',',
self::DOMAIN_TOKEN,
],
[
'new',
'\Symfony\Component\Translation\TranslatableMessage',
'(',
self::MESSAGE_TOKEN,
',',
self::METHOD_ARGUMENTS_TOKEN,
',',
self::DOMAIN_TOKEN,
],
[
'new',
'\\',
'Symfony',
'\\',
'Component',
'\\',
'Translation',
'\\',
'TranslatableMessage',
'(',
self::MESSAGE_TOKEN,
],
[
'new',
'\Symfony\Component\Translation\TranslatableMessage',
'(',
self::MESSAGE_TOKEN,
],
[
't',
'(',
self::MESSAGE_TOKEN,
',',
self::METHOD_ARGUMENTS_TOKEN,
',',
self::DOMAIN_TOKEN,
],
[
't',
'(',
self::MESSAGE_TOKEN,
],
];
/**
* {@inheritdoc}
*/
public function extract(string|iterable $resource, MessageCatalogue $catalog)
{
$files = $this->extractFiles($resource);
foreach ($files as $file) {
$this->parseTokens(token_get_all(file_get_contents($file)), $catalog, $file);
gc_mem_caches();
}
}
/**
* {@inheritdoc}
*/
public function setPrefix(string $prefix)
{
$this->prefix = $prefix;
}
/**
* Normalizes a token.
*/
protected function normalizeToken(mixed $token): ?string
{
if (isset($token[1]) && 'b"' !== $token) {
return $token[1];
}
return $token;
}
/**
* Seeks to a non-whitespace token.
*/
private function seekToNextRelevantToken(\Iterator $tokenIterator)
{
for (; $tokenIterator->valid(); $tokenIterator->next()) {
$t = $tokenIterator->current();
if (\T_WHITESPACE !== $t[0]) {
break;
}
}
}
private function skipMethodArgument(\Iterator $tokenIterator)
{
$openBraces = 0;
for (; $tokenIterator->valid(); $tokenIterator->next()) {
$t = $tokenIterator->current();
if ('[' === $t[0] || '(' === $t[0]) {
++$openBraces;
}
if (']' === $t[0] || ')' === $t[0]) {
--$openBraces;
}
if ((0 === $openBraces && ',' === $t[0]) || (-1 === $openBraces && ')' === $t[0])) {
break;
}
}
}
/**
* Extracts the message from the iterator while the tokens
* match allowed message tokens.
*/
private function getValue(\Iterator $tokenIterator)
{
$message = '';
$docToken = '';
$docPart = '';
for (; $tokenIterator->valid(); $tokenIterator->next()) {
$t = $tokenIterator->current();
if ('.' === $t) {
// Concatenate with next token
continue;
}
if (!isset($t[1])) {
break;
}
switch ($t[0]) {
case \T_START_HEREDOC:
$docToken = $t[1];
break;
case \T_ENCAPSED_AND_WHITESPACE:
case \T_CONSTANT_ENCAPSED_STRING:
if ('' === $docToken) {
$message .= PhpStringTokenParser::parse($t[1]);
} else {
$docPart = $t[1];
}
break;
case \T_END_HEREDOC:
if ($indentation = strspn($t[1], ' ')) {
$docPartWithLineBreaks = $docPart;
$docPart = '';
foreach (preg_split('~(\r\n|\n|\r)~', $docPartWithLineBreaks, -1, \PREG_SPLIT_DELIM_CAPTURE) as $str) {
if (\in_array($str, ["\r\n", "\n", "\r"], true)) {
$docPart .= $str;
} else {
$docPart .= substr($str, $indentation);
}
}
}
$message .= PhpStringTokenParser::parseDocString($docToken, $docPart);
$docToken = '';
$docPart = '';
break;
case \T_WHITESPACE:
break;
default:
break 2;
}
}
return $message;
}
/**
* Extracts trans message from PHP tokens.
*/
protected function parseTokens(array $tokens, MessageCatalogue $catalog, string $filename)
{
$tokenIterator = new \ArrayIterator($tokens);
for ($key = 0; $key < $tokenIterator->count(); ++$key) {
foreach ($this->sequences as $sequence) {
$message = '';
$domain = 'messages';
$tokenIterator->seek($key);
foreach ($sequence as $sequenceKey => $item) {
$this->seekToNextRelevantToken($tokenIterator);
if ($this->normalizeToken($tokenIterator->current()) === $item) {
$tokenIterator->next();
continue;
} elseif (self::MESSAGE_TOKEN === $item) {
$message = $this->getValue($tokenIterator);
if (\count($sequence) === ($sequenceKey + 1)) {
break;
}
} elseif (self::METHOD_ARGUMENTS_TOKEN === $item) {
$this->skipMethodArgument($tokenIterator);
} elseif (self::DOMAIN_TOKEN === $item) {
$domainToken = $this->getValue($tokenIterator);
if ('' !== $domainToken) {
$domain = $domainToken;
}
break;
} else {
break;
}
}
if ($message) {
$catalog->set($message, $this->prefix.$message, $domain);
$metadata = $catalog->getMetadata($message, $domain) ?? [];
$normalizedFilename = preg_replace('{[\\\\/]+}', '/', $filename);
$metadata['sources'][] = $normalizedFilename.':'.$tokens[$key][2];
$catalog->setMetadata($message, $metadata, $domain);
break;
}
}
}
}
/**
* @throws \InvalidArgumentException
*/
protected function canBeExtracted(string $file): bool
{
return $this->isFile($file) && 'php' === pathinfo($file, \PATHINFO_EXTENSION);
}
/**
* {@inheritdoc}
*/
protected function extractFromDirectory(string|array $directory): iterable
{
if (!class_exists(Finder::class)) {
throw new \LogicException(sprintf('You cannot use "%s" as the "symfony/finder" package is not installed. Try running "composer require symfony/finder".', static::class));
}
$finder = new Finder();
return $finder->files()->name('*.php')->in($directory);
}
}

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Extractor;
/*
* The following is derived from code at http://github.com/nikic/PHP-Parser
*
* Copyright (c) 2011 by Nikita Popov
*
* Some rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* * The names of the contributors may not be used to endorse or
* promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
class PhpStringTokenParser
{
protected static $replacements = [
'\\' => '\\',
'$' => '$',
'n' => "\n",
'r' => "\r",
't' => "\t",
'f' => "\f",
'v' => "\v",
'e' => "\x1B",
];
/**
* Parses a string token.
*
* @param string $str String token content
*/
public static function parse(string $str): string
{
$bLength = 0;
if ('b' === $str[0]) {
$bLength = 1;
}
if ('\'' === $str[$bLength]) {
return str_replace(
['\\\\', '\\\''],
['\\', '\''],
substr($str, $bLength + 1, -1)
);
} else {
return self::parseEscapeSequences(substr($str, $bLength + 1, -1), '"');
}
}
/**
* Parses escape sequences in strings (all string types apart from single quoted).
*
* @param string $str String without quotes
* @param string|null $quote Quote type
*/
public static function parseEscapeSequences(string $str, string $quote = null): string
{
if (null !== $quote) {
$str = str_replace('\\'.$quote, $quote, $str);
}
return preg_replace_callback(
'~\\\\([\\\\$nrtfve]|[xX][0-9a-fA-F]{1,2}|[0-7]{1,3})~',
[__CLASS__, 'parseCallback'],
$str
);
}
private static function parseCallback(array $matches): string
{
$str = $matches[1];
if (isset(self::$replacements[$str])) {
return self::$replacements[$str];
} elseif ('x' === $str[0] || 'X' === $str[0]) {
return \chr(hexdec($str));
} else {
return \chr(octdec($str));
}
}
/**
* Parses a constant doc string.
*
* @param string $startToken Doc string start token content (<<<SMTHG)
* @param string $str String token content
*/
public static function parseDocString(string $startToken, string $str): string
{
// strip last newline (thanks tokenizer for sticking it into the string!)
$str = preg_replace('~(\r\n|\n|\r)$~', '', $str);
// nowdoc string
if (str_contains($startToken, '\'')) {
return $str;
}
return self::parseEscapeSequences($str, null);
}
}

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Formatter;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Translation\Exception\LogicException;
/**
* @author Guilherme Blanco <guilhermeblanco@hotmail.com>
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
class IntlFormatter implements IntlFormatterInterface
{
private $hasMessageFormatter;
private $cache = [];
/**
* {@inheritdoc}
*/
public function formatIntl(string $message, string $locale, array $parameters = []): string
{
// MessageFormatter constructor throws an exception if the message is empty
if ('' === $message) {
return '';
}
if (!$formatter = $this->cache[$locale][$message] ?? null) {
if (!($this->hasMessageFormatter ?? $this->hasMessageFormatter = class_exists(\MessageFormatter::class))) {
throw new LogicException('Cannot parse message translation: please install the "intl" PHP extension or the "symfony/polyfill-intl-messageformatter" package.');
}
try {
$this->cache[$locale][$message] = $formatter = new \MessageFormatter($locale, $message);
} catch (\IntlException $e) {
throw new InvalidArgumentException(sprintf('Invalid message format (error #%d): ', intl_get_error_code()).intl_get_error_message(), 0, $e);
}
}
foreach ($parameters as $key => $value) {
if (\in_array($key[0] ?? null, ['%', '{'], true)) {
unset($parameters[$key]);
$parameters[trim($key, '%{ }')] = $value;
}
}
if (false === $message = $formatter->format($parameters)) {
throw new InvalidArgumentException(sprintf('Unable to format message (error #%s): ', $formatter->getErrorCode()).$formatter->getErrorMessage());
}
return $message;
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Formatter;
/**
* Formats ICU message patterns.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface IntlFormatterInterface
{
/**
* Formats a localized message using rules defined by ICU MessageFormat.
*
* @see http://icu-project.org/apiref/icu4c/classMessageFormat.html#details
*/
public function formatIntl(string $message, string $locale, array $parameters = []): string;
}

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Formatter;
use Symfony\Component\Translation\IdentityTranslator;
use Symfony\Contracts\Translation\TranslatorInterface;
// Help opcache.preload discover always-needed symbols
class_exists(IntlFormatter::class);
/**
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
class MessageFormatter implements MessageFormatterInterface, IntlFormatterInterface
{
private $translator;
private $intlFormatter;
/**
* @param TranslatorInterface|null $translator An identity translator to use as selector for pluralization
*/
public function __construct(TranslatorInterface $translator = null, IntlFormatterInterface $intlFormatter = null)
{
$this->translator = $translator ?? new IdentityTranslator();
$this->intlFormatter = $intlFormatter ?? new IntlFormatter();
}
/**
* {@inheritdoc}
*/
public function format(string $message, string $locale, array $parameters = []): string
{
if ($this->translator instanceof TranslatorInterface) {
return $this->translator->trans($message, $parameters, null, $locale);
}
return strtr($message, $parameters);
}
/**
* {@inheritdoc}
*/
public function formatIntl(string $message, string $locale, array $parameters = []): string
{
return $this->intlFormatter->formatIntl($message, $locale, $parameters);
}
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Formatter;
/**
* @author Guilherme Blanco <guilhermeblanco@hotmail.com>
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
interface MessageFormatterInterface
{
/**
* Formats a localized message pattern with given arguments.
*
* @param string $message The message (may also be an object that can be cast to string)
* @param string $locale The message locale
* @param array $parameters An array of parameters for the message
*/
public function format(string $message, string $locale, array $parameters = []): string;
}

View File

@@ -11,55 +11,16 @@
namespace Symfony\Component\Translation;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Contracts\Translation\TranslatorTrait;
/**
* IdentityTranslator does not translate anything.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class IdentityTranslator implements TranslatorInterface
class IdentityTranslator implements TranslatorInterface, LocaleAwareInterface
{
private $selector;
private $locale;
/**
* Constructor.
*
* @param MessageSelector|null $selector The message selector for pluralization
*/
public function __construct(MessageSelector $selector = null)
{
$this->selector = $selector ?: new MessageSelector();
}
/**
* {@inheritdoc}
*/
public function setLocale($locale)
{
$this->locale = $locale;
}
/**
* {@inheritdoc}
*/
public function getLocale()
{
return $this->locale ?: \Locale::getDefault();
}
/**
* {@inheritdoc}
*/
public function trans($id, array $parameters = array(), $domain = null, $locale = null)
{
return strtr((string) $id, $parameters);
}
/**
* {@inheritdoc}
*/
public function transChoice($id, $number, array $parameters = array(), $domain = null, $locale = null)
{
return strtr($this->selector->choose((string) $id, (int) $number, $locale ?: $this->getLocale()), $parameters);
}
use TranslatorTrait;
}

View File

@@ -1,109 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
/**
* Tests if a given number belongs to a given math interval.
*
* An interval can represent a finite set of numbers:
*
* {1,2,3,4}
*
* An interval can represent numbers between two numbers:
*
* [1, +Inf]
* ]-1,2[
*
* The left delimiter can be [ (inclusive) or ] (exclusive).
* The right delimiter can be [ (exclusive) or ] (inclusive).
* Beside numbers, you can use -Inf and +Inf for the infinite.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @see http://en.wikipedia.org/wiki/Interval_%28mathematics%29#The_ISO_notation
*/
class Interval
{
/**
* Tests if the given number is in the math interval.
*
* @param int $number A number
* @param string $interval An interval
*
* @return bool
*
* @throws InvalidArgumentException
*/
public static function test($number, $interval)
{
$interval = trim($interval);
if (!preg_match('/^'.self::getIntervalRegexp().'$/x', $interval, $matches)) {
throw new InvalidArgumentException(sprintf('"%s" is not a valid interval.', $interval));
}
if ($matches[1]) {
foreach (explode(',', $matches[2]) as $n) {
if ($number == $n) {
return true;
}
}
} else {
$leftNumber = self::convertNumber($matches['left']);
$rightNumber = self::convertNumber($matches['right']);
return
('[' === $matches['left_delimiter'] ? $number >= $leftNumber : $number > $leftNumber)
&& (']' === $matches['right_delimiter'] ? $number <= $rightNumber : $number < $rightNumber)
;
}
return false;
}
/**
* Returns a Regexp that matches valid intervals.
*
* @return string A Regexp (without the delimiters)
*/
public static function getIntervalRegexp()
{
return <<<EOF
({\s*
(\-?\d+(\.\d+)?[\s*,\s*\-?\d+(\.\d+)?]*)
\s*})
|
(?P<left_delimiter>[\[\]])
\s*
(?P<left>-Inf|\-?\d+(\.\d+)?)
\s*,\s*
(?P<right>\+?Inf|\-?\d+(\.\d+)?)
\s*
(?P<right_delimiter>[\[\]])
EOF;
}
private static function convertNumber($number)
{
if ('-Inf' === $number) {
return log(0);
} elseif ('+Inf' === $number || 'Inf' === $number) {
return -log(0);
}
return (float) $number;
}
}

View File

@@ -1,4 +1,4 @@
Copyright (c) 2004-2017 Fabien Potencier
Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -23,9 +23,9 @@ class ArrayLoader implements LoaderInterface
/**
* {@inheritdoc}
*/
public function load($resource, $locale, $domain = 'messages')
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
{
$this->flatten($resource);
$resource = $this->flatten($resource);
$catalogue = new MessageCatalogue($locale);
$catalogue->add($resource, $domain);
@@ -36,31 +36,23 @@ class ArrayLoader implements LoaderInterface
* Flattens an nested array of translations.
*
* The scheme used is:
* 'key' => array('key2' => array('key3' => 'value'))
* 'key' => ['key2' => ['key3' => 'value']]
* Becomes:
* 'key.key2.key3' => 'value'
*
* This function takes an array by reference and will modify it
*
* @param array &$messages The array that will be flattened
* @param array $subnode Current subnode being parsed, used internally for recursive calls
* @param string $path Current path being parsed, used internally for recursive calls
*/
private function flatten(array &$messages, array $subnode = null, $path = null)
private function flatten(array $messages): array
{
if (null === $subnode) {
$subnode = &$messages;
}
foreach ($subnode as $key => $value) {
if (is_array($value)) {
$nodePath = $path ? $path.'.'.$key : $key;
$this->flatten($messages, $value, $nodePath);
if (null === $path) {
unset($messages[$key]);
$result = [];
foreach ($messages as $key => $value) {
if (\is_array($value)) {
foreach ($this->flatten($value) as $k => $v) {
$result[$key.'.'.$k] = $v;
}
} elseif (null !== $path) {
$messages[$path.'.'.$key] = $value;
} else {
$result[$key] = $value;
}
}
return $result;
}
}

View File

@@ -20,16 +20,16 @@ use Symfony\Component\Translation\Exception\NotFoundResourceException;
*/
class CsvFileLoader extends FileLoader
{
private $delimiter = ';';
private $enclosure = '"';
private $escape = '\\';
private string $delimiter = ';';
private string $enclosure = '"';
private string $escape = '\\';
/**
* {@inheritdoc}
*/
protected function loadResource($resource)
protected function loadResource(string $resource): array
{
$messages = array();
$messages = [];
try {
$file = new \SplFileObject($resource, 'rb');
@@ -41,7 +41,11 @@ class CsvFileLoader extends FileLoader
$file->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
foreach ($file as $data) {
if ('#' !== substr($data[0], 0, 1) && isset($data[1]) && 2 === count($data)) {
if (false === $data) {
continue;
}
if ('#' !== substr($data[0], 0, 1) && isset($data[1]) && 2 === \count($data)) {
$messages[$data[0]] = $data[1];
}
}
@@ -51,12 +55,8 @@ class CsvFileLoader extends FileLoader
/**
* Sets the delimiter, enclosure, and escape character for CSV.
*
* @param string $delimiter delimiter character
* @param string $enclosure enclosure character
* @param string $escape escape character
*/
public function setCsvControl($delimiter = ';', $enclosure = '"', $escape = '\\')
public function setCsvControl(string $delimiter = ';', string $enclosure = '"', string $escape = '\\')
{
$this->delimiter = $delimiter;
$this->enclosure = $enclosure;

View File

@@ -11,9 +11,10 @@
namespace Symfony\Component\Translation\Loader;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Translation\MessageCatalogue;
/**
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
@@ -23,7 +24,7 @@ abstract class FileLoader extends ArrayLoader
/**
* {@inheritdoc}
*/
public function load($resource, $locale, $domain = 'messages')
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
{
if (!stream_is_local($resource)) {
throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource));
@@ -37,17 +38,17 @@ abstract class FileLoader extends ArrayLoader
// empty resource
if (null === $messages) {
$messages = array();
$messages = [];
}
// not an array
if (!is_array($messages)) {
if (!\is_array($messages)) {
throw new InvalidResourceException(sprintf('Unable to load file "%s".', $resource));
}
$catalogue = parent::load($messages, $locale, $domain);
if (class_exists('Symfony\Component\Config\Resource\FileResource')) {
if (class_exists(FileResource::class)) {
$catalogue->addResource(new FileResource($resource));
}
@@ -55,11 +56,7 @@ abstract class FileLoader extends ArrayLoader
}
/**
* @param string $resource
*
* @return array
*
* @throws InvalidResourceException If stream content has an invalid format.
* @throws InvalidResourceException if stream content has an invalid format
*/
abstract protected function loadResource($resource);
abstract protected function loadResource(string $resource): array;
}

View File

@@ -11,10 +11,10 @@
namespace Symfony\Component\Translation\Loader;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Translation\MessageCatalogue;
/**
* IcuResFileLoader loads translations from a resource bundle.
@@ -26,7 +26,7 @@ class IcuDatFileLoader extends IcuResFileLoader
/**
* {@inheritdoc}
*/
public function load($resource, $locale, $domain = 'messages')
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
{
if (!stream_is_local($resource.'.dat')) {
throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource));
@@ -39,12 +39,11 @@ class IcuDatFileLoader extends IcuResFileLoader
try {
$rb = new \ResourceBundle($locale, $resource);
} catch (\Exception $e) {
// HHVM compatibility: constructor throws on invalid resource
$rb = null;
}
if (!$rb) {
throw new InvalidResourceException(sprintf('Cannot load resource "%s"', $resource));
throw new InvalidResourceException(sprintf('Cannot load resource "%s".', $resource));
} elseif (intl_is_failure($rb->getErrorCode())) {
throw new InvalidResourceException($rb->getErrorMessage(), $rb->getErrorCode());
}
@@ -53,7 +52,7 @@ class IcuDatFileLoader extends IcuResFileLoader
$catalogue = new MessageCatalogue($locale);
$catalogue->add($messages, $domain);
if (class_exists('Symfony\Component\Config\Resource\FileResource')) {
if (class_exists(FileResource::class)) {
$catalogue->addResource(new FileResource($resource.'.dat'));
}

View File

@@ -11,10 +11,10 @@
namespace Symfony\Component\Translation\Loader;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Config\Resource\DirectoryResource;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Symfony\Component\Config\Resource\DirectoryResource;
use Symfony\Component\Translation\MessageCatalogue;
/**
* IcuResFileLoader loads translations from a resource bundle.
@@ -26,7 +26,7 @@ class IcuResFileLoader implements LoaderInterface
/**
* {@inheritdoc}
*/
public function load($resource, $locale, $domain = 'messages')
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
{
if (!stream_is_local($resource)) {
throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource));
@@ -39,12 +39,11 @@ class IcuResFileLoader implements LoaderInterface
try {
$rb = new \ResourceBundle($locale, $resource);
} catch (\Exception $e) {
// HHVM compatibility: constructor throws on invalid resource
$rb = null;
}
if (!$rb) {
throw new InvalidResourceException(sprintf('Cannot load resource "%s"', $resource));
throw new InvalidResourceException(sprintf('Cannot load resource "%s".', $resource));
} elseif (intl_is_failure($rb->getErrorCode())) {
throw new InvalidResourceException($rb->getErrorMessage(), $rb->getErrorCode());
}
@@ -53,7 +52,7 @@ class IcuResFileLoader implements LoaderInterface
$catalogue = new MessageCatalogue($locale);
$catalogue->add($messages, $domain);
if (class_exists('Symfony\Component\Config\Resource\DirectoryResource')) {
if (class_exists(DirectoryResource::class)) {
$catalogue->addResource(new DirectoryResource($resource));
}
@@ -70,13 +69,11 @@ class IcuResFileLoader implements LoaderInterface
*
* This function takes an array by reference and will modify it
*
* @param \ResourceBundle $rb the ResourceBundle that will be flattened
* @param array $messages used internally for recursive calls
* @param string $path current path being parsed, used internally for recursive calls
*
* @return array the flattened ResourceBundle
* @param \ResourceBundle $rb The ResourceBundle that will be flattened
* @param array $messages Used internally for recursive calls
* @param string $path Current path being parsed, used internally for recursive calls
*/
protected function flatten(\ResourceBundle $rb, array &$messages = array(), $path = null)
protected function flatten(\ResourceBundle $rb, array &$messages = [], string $path = null): array
{
foreach ($rb as $key => $value) {
$nodePath = $path ? $path.'.'.$key : $key;

View File

@@ -21,7 +21,7 @@ class IniFileLoader extends FileLoader
/**
* {@inheritdoc}
*/
protected function loadResource($resource)
protected function loadResource(string $resource): array
{
return parse_ini_file($resource, true);
}

View File

@@ -23,14 +23,14 @@ class JsonFileLoader extends FileLoader
/**
* {@inheritdoc}
*/
protected function loadResource($resource)
protected function loadResource(string $resource): array
{
$messages = array();
$messages = [];
if ($data = file_get_contents($resource)) {
$messages = json_decode($data, true);
if (0 < $errorCode = json_last_error()) {
throw new InvalidResourceException(sprintf('Error parsing JSON - %s', $this->getJSONErrorMessage($errorCode)));
throw new InvalidResourceException('Error parsing JSON: '.$this->getJSONErrorMessage($errorCode));
}
}
@@ -39,23 +39,19 @@ class JsonFileLoader extends FileLoader
/**
* Translates JSON_ERROR_* constant into meaningful message.
*
* @param int $errorCode Error code returned by json_last_error() call
*
* @return string Message string
*/
private function getJSONErrorMessage($errorCode)
private function getJSONErrorMessage(int $errorCode): string
{
switch ($errorCode) {
case JSON_ERROR_DEPTH:
case \JSON_ERROR_DEPTH:
return 'Maximum stack depth exceeded';
case JSON_ERROR_STATE_MISMATCH:
case \JSON_ERROR_STATE_MISMATCH:
return 'Underflow or the modes mismatch';
case JSON_ERROR_CTRL_CHAR:
case \JSON_ERROR_CTRL_CHAR:
return 'Unexpected control character found';
case JSON_ERROR_SYNTAX:
case \JSON_ERROR_SYNTAX:
return 'Syntax error, malformed JSON';
case JSON_ERROR_UTF8:
case \JSON_ERROR_UTF8:
return 'Malformed UTF-8 characters, possibly incorrectly encoded';
default:
return 'Unknown error';

View File

@@ -11,9 +11,9 @@
namespace Symfony\Component\Translation\Loader;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Symfony\Component\Translation\MessageCatalogue;
/**
* LoaderInterface is the interface implemented by all translation loaders.
@@ -25,14 +25,8 @@ interface LoaderInterface
/**
* Loads a locale.
*
* @param mixed $resource A resource
* @param string $locale A locale
* @param string $domain The domain
*
* @return MessageCatalogue A MessageCatalogue instance
*
* @throws NotFoundResourceException when the resource cannot be found
* @throws InvalidResourceException when the resource cannot be loaded
*/
public function load($resource, $locale, $domain = 'messages');
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue;
}

View File

@@ -19,27 +19,21 @@ use Symfony\Component\Translation\Exception\InvalidResourceException;
class MoFileLoader extends FileLoader
{
/**
* Magic used for validating the format of a MO file as well as
* Magic used for validating the format of an MO file as well as
* detecting if the machine used to create that file was little endian.
*
* @var float
*/
const MO_LITTLE_ENDIAN_MAGIC = 0x950412de;
public const MO_LITTLE_ENDIAN_MAGIC = 0x950412DE;
/**
* Magic used for validating the format of a MO file as well as
* Magic used for validating the format of an MO file as well as
* detecting if the machine used to create that file was big endian.
*
* @var float
*/
const MO_BIG_ENDIAN_MAGIC = 0xde120495;
public const MO_BIG_ENDIAN_MAGIC = 0xDE120495;
/**
* The size of the header of a MO file in bytes.
*
* @var int Number of bytes
* The size of the header of an MO file in bytes.
*/
const MO_HEADER_SIZE = 28;
public const MO_HEADER_SIZE = 28;
/**
* Parses machine object (MO) format, independent of the machine's endian it
@@ -47,7 +41,7 @@ class MoFileLoader extends FileLoader
*
* {@inheritdoc}
*/
protected function loadResource($resource)
protected function loadResource(string $resource): array
{
$stream = fopen($resource, 'r');
@@ -59,9 +53,9 @@ class MoFileLoader extends FileLoader
$magic = unpack('V1', fread($stream, 4));
$magic = hexdec(substr(dechex(current($magic)), -8));
if ($magic == self::MO_LITTLE_ENDIAN_MAGIC) {
if (self::MO_LITTLE_ENDIAN_MAGIC == $magic) {
$isBigEndian = false;
} elseif ($magic == self::MO_BIG_ENDIAN_MAGIC) {
} elseif (self::MO_BIG_ENDIAN_MAGIC == $magic) {
$isBigEndian = true;
} else {
throw new InvalidResourceException('MO stream content has an invalid format.');
@@ -77,7 +71,7 @@ class MoFileLoader extends FileLoader
// offsetHashes
$this->readLong($stream, $isBigEndian);
$messages = array();
$messages = [];
for ($i = 0; $i < $count; ++$i) {
$pluralId = null;
@@ -95,8 +89,8 @@ class MoFileLoader extends FileLoader
fseek($stream, $offset);
$singularId = fread($stream, $length);
if (strpos($singularId, "\000") !== false) {
list($singularId, $pluralId) = explode("\000", $singularId);
if (str_contains($singularId, "\000")) {
[$singularId, $pluralId] = explode("\000", $singularId);
}
fseek($stream, $offsetTranslated + $i * 8);
@@ -110,24 +104,19 @@ class MoFileLoader extends FileLoader
fseek($stream, $offset);
$translated = fread($stream, $length);
if (strpos($translated, "\000") !== false) {
if (str_contains($translated, "\000")) {
$translated = explode("\000", $translated);
}
$ids = array('singular' => $singularId, 'plural' => $pluralId);
$ids = ['singular' => $singularId, 'plural' => $pluralId];
$item = compact('ids', 'translated');
if (is_array($item['translated'])) {
$messages[$item['ids']['singular']] = stripcslashes($item['translated'][0]);
if (!empty($item['ids']['singular'])) {
$id = $item['ids']['singular'];
if (isset($item['ids']['plural'])) {
$plurals = array();
foreach ($item['translated'] as $plural => $translated) {
$plurals[] = sprintf('{%d} %s', $plural, $translated);
}
$messages[$item['ids']['plural']] = stripcslashes(implode('|', $plurals));
$id .= '|'.$item['ids']['plural'];
}
} elseif (!empty($item['ids']['singular'])) {
$messages[$item['ids']['singular']] = stripcslashes($item['translated']);
$messages[$id] = stripcslashes(implode('|', (array) $item['translated']));
}
}
@@ -140,11 +129,8 @@ class MoFileLoader extends FileLoader
* Reads an unsigned long from stream respecting endianness.
*
* @param resource $stream
* @param bool $isBigEndian
*
* @return int
*/
private function readLong($stream, $isBigEndian)
private function readLong($stream, bool $isBigEndian): int
{
$result = unpack($isBigEndian ? 'N1' : 'V1', fread($stream, 4));
$result = current($result);

View File

@@ -18,11 +18,25 @@ namespace Symfony\Component\Translation\Loader;
*/
class PhpFileLoader extends FileLoader
{
private static ?array $cache = [];
/**
* {@inheritdoc}
*/
protected function loadResource($resource)
protected function loadResource(string $resource): array
{
return require $resource;
if ([] === self::$cache && \function_exists('opcache_invalidate') && filter_var(\ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN) && (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) || filter_var(\ini_get('opcache.enable_cli'), \FILTER_VALIDATE_BOOLEAN))) {
self::$cache = null;
}
if (null === self::$cache) {
return require $resource;
}
if (isset(self::$cache[$resource])) {
return self::$cache[$resource];
}
return self::$cache[$resource] = require $resource;
}
}

View File

@@ -12,7 +12,7 @@
namespace Symfony\Component\Translation\Loader;
/**
* @copyright Copyright (c) 2010, Union of RAD http://union-of-rad.org (http://lithify.me/)
* @copyright Copyright (c) 2010, Union of RAD https://github.com/UnionOfRAD/lithium
* @copyright Copyright (c) 2012, Clemens Tolboom
*/
class PoFileLoader extends FileLoader
@@ -20,7 +20,7 @@ class PoFileLoader extends FileLoader
/**
* Parses portable object (PO) format.
*
* From http://www.gnu.org/software/gettext/manual/gettext.html#PO-Files
* From https://www.gnu.org/software/gettext/manual/gettext.html#PO-Files
* we should be able to parse files having:
*
* white-space
@@ -60,57 +60,57 @@ class PoFileLoader extends FileLoader
*
* {@inheritdoc}
*/
protected function loadResource($resource)
protected function loadResource(string $resource): array
{
$stream = fopen($resource, 'r');
$defaults = array(
'ids' => array(),
$defaults = [
'ids' => [],
'translated' => null,
);
];
$messages = array();
$messages = [];
$item = $defaults;
$flags = array();
$flags = [];
while ($line = fgets($stream)) {
$line = trim($line);
if ($line === '') {
if ('' === $line) {
// Whitespace indicated current item is done
if (!in_array('fuzzy', $flags)) {
if (!\in_array('fuzzy', $flags)) {
$this->addMessage($messages, $item);
}
$item = $defaults;
$flags = array();
} elseif (substr($line, 0, 2) === '#,') {
$flags = [];
} elseif ('#,' === substr($line, 0, 2)) {
$flags = array_map('trim', explode(',', substr($line, 2)));
} elseif (substr($line, 0, 7) === 'msgid "') {
} elseif ('msgid "' === substr($line, 0, 7)) {
// We start a new msg so save previous
// TODO: this fails when comments or contexts are added
$this->addMessage($messages, $item);
$item = $defaults;
$item['ids']['singular'] = substr($line, 7, -1);
} elseif (substr($line, 0, 8) === 'msgstr "') {
} elseif ('msgstr "' === substr($line, 0, 8)) {
$item['translated'] = substr($line, 8, -1);
} elseif ($line[0] === '"') {
} elseif ('"' === $line[0]) {
$continues = isset($item['translated']) ? 'translated' : 'ids';
if (is_array($item[$continues])) {
if (\is_array($item[$continues])) {
end($item[$continues]);
$item[$continues][key($item[$continues])] .= substr($line, 1, -1);
} else {
$item[$continues] .= substr($line, 1, -1);
}
} elseif (substr($line, 0, 14) === 'msgid_plural "') {
} elseif ('msgid_plural "' === substr($line, 0, 14)) {
$item['ids']['plural'] = substr($line, 14, -1);
} elseif (substr($line, 0, 7) === 'msgstr[') {
} elseif ('msgstr[' === substr($line, 0, 7)) {
$size = strpos($line, ']');
$item['translated'][(int) substr($line, 7, 1)] = substr($line, $size + 3, -1);
}
}
// save last item
if (!in_array('fuzzy', $flags)) {
if (!\in_array('fuzzy', $flags)) {
$this->addMessage($messages, $item);
}
fclose($stream);
@@ -123,29 +123,27 @@ class PoFileLoader extends FileLoader
*
* A .po file could contain by error missing plural indexes. We need to
* fix these before saving them.
*
* @param array $messages
* @param array $item
*/
private function addMessage(array &$messages, array $item)
{
if (is_array($item['translated'])) {
$messages[stripcslashes($item['ids']['singular'])] = stripcslashes($item['translated'][0]);
if (!empty($item['ids']['singular'])) {
$id = stripcslashes($item['ids']['singular']);
if (isset($item['ids']['plural'])) {
$plurals = $item['translated'];
// PO are by definition indexed so sort by index.
ksort($plurals);
// Make sure every index is filled.
end($plurals);
$count = key($plurals);
// Fill missing spots with '-'.
$empties = array_fill(0, $count + 1, '-');
$plurals += $empties;
ksort($plurals);
$messages[stripcslashes($item['ids']['plural'])] = stripcslashes(implode('|', $plurals));
$id .= '|'.stripcslashes($item['ids']['plural']);
}
} elseif (!empty($item['ids']['singular'])) {
$messages[stripcslashes($item['ids']['singular'])] = stripcslashes($item['translated']);
$translated = (array) $item['translated'];
// PO are by definition indexed so sort by index.
ksort($translated);
// Make sure every index is filled.
end($translated);
$count = key($translated);
// Fill missing spots with '-'.
$empties = array_fill(0, $count + 1, '-');
$translated += $empties;
ksort($translated);
$messages[$id] = stripcslashes(implode('|', $translated));
}
}
}

View File

@@ -11,11 +11,12 @@
namespace Symfony\Component\Translation\Loader;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Config\Util\XmlUtils;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Translation\Exception\RuntimeException;
use Symfony\Component\Translation\MessageCatalogue;
/**
* QtFileLoader loads translations from QT Translations XML files.
@@ -27,8 +28,12 @@ class QtFileLoader implements LoaderInterface
/**
* {@inheritdoc}
*/
public function load($resource, $locale, $domain = 'messages')
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
{
if (!class_exists(XmlUtils::class)) {
throw new RuntimeException('Loading translations from the QT format requires the Symfony Config component.');
}
if (!stream_is_local($resource)) {
throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource));
}
@@ -50,7 +55,7 @@ class QtFileLoader implements LoaderInterface
$nodes = $xpath->evaluate('//TS/context/name[text()="'.$domain.'"]');
$catalogue = new MessageCatalogue($locale);
if ($nodes->length == 1) {
if (1 == $nodes->length) {
$translations = $nodes->item(0)->nextSibling->parentNode->parentNode->getElementsByTagName('message');
foreach ($translations as $translation) {
$translationValue = (string) $translation->getElementsByTagName('translation')->item(0)->nodeValue;
@@ -65,7 +70,7 @@ class QtFileLoader implements LoaderInterface
$translation = $translation->nextSibling;
}
if (class_exists('Symfony\Component\Config\Resource\FileResource')) {
if (class_exists(FileResource::class)) {
$catalogue->addResource(new FileResource($resource));
}
}

View File

@@ -11,12 +11,15 @@
namespace Symfony\Component\Translation\Loader;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Config\Util\Exception\InvalidXmlException;
use Symfony\Component\Config\Util\Exception\XmlParsingException;
use Symfony\Component\Config\Util\XmlUtils;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Translation\Exception\RuntimeException;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Util\XliffUtils;
/**
* XliffFileLoader loads translations from XLIFF files.
@@ -28,36 +31,53 @@ class XliffFileLoader implements LoaderInterface
/**
* {@inheritdoc}
*/
public function load($resource, $locale, $domain = 'messages')
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
{
if (!stream_is_local($resource)) {
throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource));
if (!class_exists(XmlUtils::class)) {
throw new RuntimeException('Loading translations from the Xliff format requires the Symfony Config component.');
}
if (!file_exists($resource)) {
throw new NotFoundResourceException(sprintf('File "%s" not found.', $resource));
if (!$this->isXmlString($resource)) {
if (!stream_is_local($resource)) {
throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource));
}
if (!file_exists($resource)) {
throw new NotFoundResourceException(sprintf('File "%s" not found.', $resource));
}
if (!is_file($resource)) {
throw new InvalidResourceException(sprintf('This is neither a file nor an XLIFF string "%s".', $resource));
}
}
try {
if ($this->isXmlString($resource)) {
$dom = XmlUtils::parse($resource);
} else {
$dom = XmlUtils::loadFile($resource);
}
} catch (\InvalidArgumentException|XmlParsingException|InvalidXmlException $e) {
throw new InvalidResourceException(sprintf('Unable to load "%s": ', $resource).$e->getMessage(), $e->getCode(), $e);
}
if ($errors = XliffUtils::validateSchema($dom)) {
throw new InvalidResourceException(sprintf('Invalid resource provided: "%s"; Errors: ', $resource).XliffUtils::getErrorsAsString($errors));
}
$catalogue = new MessageCatalogue($locale);
$this->extract($resource, $catalogue, $domain);
$this->extract($dom, $catalogue, $domain);
if (class_exists('Symfony\Component\Config\Resource\FileResource')) {
if (is_file($resource) && class_exists(FileResource::class)) {
$catalogue->addResource(new FileResource($resource));
}
return $catalogue;
}
private function extract($resource, MessageCatalogue $catalogue, $domain)
private function extract(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain)
{
try {
$dom = XmlUtils::loadFile($resource);
} catch (\InvalidArgumentException $e) {
throw new InvalidResourceException(sprintf('Unable to load "%s": %s', $resource, $e->getMessage()), $e->getCode(), $e);
}
$xliffVersion = $this->getVersionNumber($dom);
$this->validateSchema($xliffVersion, $dom, $this->getSchema($xliffVersion));
$xliffVersion = XliffUtils::getVersionNumber($dom);
if ('1.2' === $xliffVersion) {
$this->extractXliff1($dom, $catalogue, $domain);
@@ -70,93 +90,107 @@ class XliffFileLoader implements LoaderInterface
/**
* Extract messages and metadata from DOMDocument into a MessageCatalogue.
*
* @param \DOMDocument $dom Source to extract messages and metadata
* @param MessageCatalogue $catalogue Catalogue where we'll collect messages and metadata
* @param string $domain The domain
*/
private function extractXliff1(\DOMDocument $dom, MessageCatalogue $catalogue, $domain)
private function extractXliff1(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain)
{
$xml = simplexml_import_dom($dom);
$encoding = strtoupper($dom->encoding);
$encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
$xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:1.2');
foreach ($xml->xpath('//xliff:trans-unit') as $translation) {
$attributes = $translation->attributes();
$namespace = 'urn:oasis:names:tc:xliff:document:1.2';
$xml->registerXPathNamespace('xliff', $namespace);
if (!(isset($attributes['resname']) || isset($translation->source))) {
continue;
}
foreach ($xml->xpath('//xliff:file') as $file) {
$fileAttributes = $file->attributes();
$source = isset($attributes['resname']) && $attributes['resname'] ? $attributes['resname'] : $translation->source;
// If the xlf file has another encoding specified, try to convert it because
// simple_xml will always return utf-8 encoded values
$target = $this->utf8ToCharset((string) (isset($translation->target) ? $translation->target : $source), $encoding);
$file->registerXPathNamespace('xliff', $namespace);
$catalogue->set((string) $source, $target, $domain);
foreach ($file->xpath('.//xliff:trans-unit') as $translation) {
$attributes = $translation->attributes();
$metadata = array();
if ($notes = $this->parseNotesMetadata($translation->note, $encoding)) {
$metadata['notes'] = $notes;
}
if (isset($translation->target) && $translation->target->attributes()) {
$metadata['target-attributes'] = array();
foreach ($translation->target->attributes() as $key => $value) {
$metadata['target-attributes'][$key] = (string) $value;
if (!(isset($attributes['resname']) || isset($translation->source))) {
continue;
}
}
if (isset($attributes['id'])) {
$metadata['id'] = (string) $attributes['id'];
}
$source = isset($attributes['resname']) && $attributes['resname'] ? $attributes['resname'] : $translation->source;
// If the xlf file has another encoding specified, try to convert it because
// simple_xml will always return utf-8 encoded values
$target = $this->utf8ToCharset((string) ($translation->target ?? $translation->source), $encoding);
$catalogue->setMetadata((string) $source, $metadata, $domain);
$catalogue->set((string) $source, $target, $domain);
$metadata = [
'source' => (string) $translation->source,
'file' => [
'original' => (string) $fileAttributes['original'],
],
];
if ($notes = $this->parseNotesMetadata($translation->note, $encoding)) {
$metadata['notes'] = $notes;
}
if (isset($translation->target) && $translation->target->attributes()) {
$metadata['target-attributes'] = [];
foreach ($translation->target->attributes() as $key => $value) {
$metadata['target-attributes'][$key] = (string) $value;
}
}
if (isset($attributes['id'])) {
$metadata['id'] = (string) $attributes['id'];
}
$catalogue->setMetadata((string) $source, $metadata, $domain);
}
}
}
/**
* @param \DOMDocument $dom
* @param MessageCatalogue $catalogue
* @param string $domain
*/
private function extractXliff2(\DOMDocument $dom, MessageCatalogue $catalogue, $domain)
private function extractXliff2(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain)
{
$xml = simplexml_import_dom($dom);
$encoding = strtoupper($dom->encoding);
$encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
$xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:2.0');
foreach ($xml->xpath('//xliff:unit/xliff:segment') as $segment) {
$source = $segment->source;
foreach ($xml->xpath('//xliff:unit') as $unit) {
foreach ($unit->segment as $segment) {
$attributes = $unit->attributes();
$source = $attributes['name'] ?? $segment->source;
// If the xlf file has another encoding specified, try to convert it because
// simple_xml will always return utf-8 encoded values
$target = $this->utf8ToCharset((string) (isset($segment->target) ? $segment->target : $source), $encoding);
// If the xlf file has another encoding specified, try to convert it because
// simple_xml will always return utf-8 encoded values
$target = $this->utf8ToCharset((string) ($segment->target ?? $segment->source), $encoding);
$catalogue->set((string) $source, $target, $domain);
$catalogue->set((string) $source, $target, $domain);
$metadata = array();
if (isset($segment->target) && $segment->target->attributes()) {
$metadata['target-attributes'] = array();
foreach ($segment->target->attributes() as $key => $value) {
$metadata['target-attributes'][$key] = (string) $value;
$metadata = [];
if (isset($segment->target) && $segment->target->attributes()) {
$metadata['target-attributes'] = [];
foreach ($segment->target->attributes() as $key => $value) {
$metadata['target-attributes'][$key] = (string) $value;
}
}
}
$catalogue->setMetadata((string) $source, $metadata, $domain);
if (isset($unit->notes)) {
$metadata['notes'] = [];
foreach ($unit->notes->note as $noteNode) {
$note = [];
foreach ($noteNode->attributes() as $key => $value) {
$note[$key] = (string) $value;
}
$note['content'] = (string) $noteNode;
$metadata['notes'][] = $note;
}
}
$catalogue->setMetadata((string) $source, $metadata, $domain);
}
}
}
/**
* Convert a UTF8 string to the specified encoding.
*
* @param string $content String to decode
* @param string $encoding Target encoding
*
* @return string
*/
private function utf8ToCharset($content, $encoding = null)
private function utf8ToCharset(string $content, string $encoding = null): string
{
if ('UTF-8' !== $encoding && !empty($encoding)) {
return mb_convert_encoding($content, $encoding, 'UTF-8');
@@ -165,145 +199,9 @@ class XliffFileLoader implements LoaderInterface
return $content;
}
/**
* Validates and parses the given file into a DOMDocument.
*
* @param string $file
* @param \DOMDocument $dom
* @param string $schema source of the schema
*
* @throws InvalidResourceException
*/
private function validateSchema($file, \DOMDocument $dom, $schema)
private function parseNotesMetadata(\SimpleXMLElement $noteElement = null, string $encoding = null): array
{
$internalErrors = libxml_use_internal_errors(true);
$disableEntities = libxml_disable_entity_loader(false);
if (!@$dom->schemaValidateSource($schema)) {
libxml_disable_entity_loader($disableEntities);
throw new InvalidResourceException(sprintf('Invalid resource provided: "%s"; Errors: %s', $file, implode("\n", $this->getXmlErrors($internalErrors))));
}
libxml_disable_entity_loader($disableEntities);
$dom->normalizeDocument();
libxml_clear_errors();
libxml_use_internal_errors($internalErrors);
}
private function getSchema($xliffVersion)
{
if ('1.2' === $xliffVersion) {
$schemaSource = file_get_contents(__DIR__.'/schema/dic/xliff-core/xliff-core-1.2-strict.xsd');
$xmlUri = 'http://www.w3.org/2001/xml.xsd';
} elseif ('2.0' === $xliffVersion) {
$schemaSource = file_get_contents(__DIR__.'/schema/dic/xliff-core/xliff-core-2.0.xsd');
$xmlUri = 'informativeCopiesOf3rdPartySchemas/w3c/xml.xsd';
} else {
throw new InvalidArgumentException(sprintf('No support implemented for loading XLIFF version "%s".', $xliffVersion));
}
return $this->fixXmlLocation($schemaSource, $xmlUri);
}
/**
* Internally changes the URI of a dependent xsd to be loaded locally.
*
* @param string $schemaSource Current content of schema file
* @param string $xmlUri External URI of XML to convert to local
*
* @return string
*/
private function fixXmlLocation($schemaSource, $xmlUri)
{
$newPath = str_replace('\\', '/', __DIR__).'/schema/dic/xliff-core/xml.xsd';
$parts = explode('/', $newPath);
if (0 === stripos($newPath, 'phar://')) {
$tmpfile = tempnam(sys_get_temp_dir(), 'sf2');
if ($tmpfile) {
copy($newPath, $tmpfile);
$parts = explode('/', str_replace('\\', '/', $tmpfile));
}
}
$drive = '\\' === DIRECTORY_SEPARATOR ? array_shift($parts).'/' : '';
$newPath = 'file:///'.$drive.implode('/', array_map('rawurlencode', $parts));
return str_replace($xmlUri, $newPath, $schemaSource);
}
/**
* Returns the XML errors of the internal XML parser.
*
* @param bool $internalErrors
*
* @return array An array of errors
*/
private function getXmlErrors($internalErrors)
{
$errors = array();
foreach (libxml_get_errors() as $error) {
$errors[] = sprintf('[%s %s] %s (in %s - line %d, column %d)',
LIBXML_ERR_WARNING == $error->level ? 'WARNING' : 'ERROR',
$error->code,
trim($error->message),
$error->file ?: 'n/a',
$error->line,
$error->column
);
}
libxml_clear_errors();
libxml_use_internal_errors($internalErrors);
return $errors;
}
/**
* Gets xliff file version based on the root "version" attribute.
* Defaults to 1.2 for backwards compatibility.
*
* @param \DOMDocument $dom
*
* @throws InvalidArgumentException
*
* @return string
*/
private function getVersionNumber(\DOMDocument $dom)
{
/** @var \DOMNode $xliff */
foreach ($dom->getElementsByTagName('xliff') as $xliff) {
$version = $xliff->attributes->getNamedItem('version');
if ($version) {
return $version->nodeValue;
}
$namespace = $xliff->attributes->getNamedItem('xmlns');
if ($namespace) {
if (substr_compare('urn:oasis:names:tc:xliff:document:', $namespace->nodeValue, 0, 34) !== 0) {
throw new InvalidArgumentException(sprintf('Not a valid XLIFF namespace "%s"', $namespace));
}
return substr($namespace, 34);
}
}
// Falls back to v1.2
return '1.2';
}
/**
* @param \SimpleXMLElement|null $noteElement
* @param string|null $encoding
*
* @return array
*/
private function parseNotesMetadata(\SimpleXMLElement $noteElement = null, $encoding = null)
{
$notes = array();
$notes = [];
if (null === $noteElement) {
return $notes;
@@ -312,7 +210,7 @@ class XliffFileLoader implements LoaderInterface
/** @var \SimpleXMLElement $xmlNote */
foreach ($noteElement as $xmlNote) {
$noteAttributes = $xmlNote->attributes();
$note = array('content' => $this->utf8ToCharset((string) $xmlNote, $encoding));
$note = ['content' => $this->utf8ToCharset((string) $xmlNote, $encoding)];
if (isset($noteAttributes['priority'])) {
$note['priority'] = (int) $noteAttributes['priority'];
}
@@ -326,4 +224,9 @@ class XliffFileLoader implements LoaderInterface
return $notes;
}
private function isXmlString(string $resource): bool
{
return 0 === strpos($resource, '<?xml');
}
}

View File

@@ -13,8 +13,8 @@ namespace Symfony\Component\Translation\Loader;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Exception\LogicException;
use Symfony\Component\Yaml\Parser as YamlParser;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Parser as YamlParser;
use Symfony\Component\Yaml\Yaml;
/**
@@ -29,10 +29,10 @@ class YamlFileLoader extends FileLoader
/**
* {@inheritdoc}
*/
protected function loadResource($resource)
protected function loadResource(string $resource): array
{
if (null === $this->yamlParser) {
if (!class_exists('Symfony\Component\Yaml\Parser')) {
if (!class_exists(\Symfony\Component\Yaml\Parser::class)) {
throw new LogicException('Loading translations from the YAML format requires the Symfony Yaml component.');
}
@@ -40,11 +40,15 @@ class YamlFileLoader extends FileLoader
}
try {
$messages = $this->yamlParser->parse(file_get_contents($resource), Yaml::PARSE_KEYS_AS_STRINGS);
$messages = $this->yamlParser->parseFile($resource, Yaml::PARSE_CONSTANT);
} catch (ParseException $e) {
throw new InvalidResourceException(sprintf('Error parsing YAML, invalid file "%s"', $resource), 0, $e);
throw new InvalidResourceException(sprintf('The file "%s" does not contain valid YAML: ', $resource).$e->getMessage(), 0, $e);
}
return $messages;
if (null !== $messages && !\is_array($messages)) {
throw new InvalidResourceException(sprintf('Unable to load file "%s".', $resource));
}
return $messages ?: [];
}
}

View File

@@ -13,30 +13,24 @@ namespace Symfony\Component\Translation;
use Psr\Log\LoggerInterface;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface
class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface, LocaleAwareInterface
{
/**
* @var TranslatorInterface|TranslatorBagInterface
*/
private $translator;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @param TranslatorInterface $translator The translator must implement TranslatorBagInterface
* @param LoggerInterface $logger
* @param TranslatorInterface&TranslatorBagInterface&LocaleAwareInterface $translator The translator must implement TranslatorBagInterface
*/
public function __construct(TranslatorInterface $translator, LoggerInterface $logger)
{
if (!$translator instanceof TranslatorBagInterface) {
throw new InvalidArgumentException(sprintf('The Translator "%s" must implement TranslatorInterface and TranslatorBagInterface.', get_class($translator)));
if (!$translator instanceof TranslatorBagInterface || !$translator instanceof LocaleAwareInterface) {
throw new InvalidArgumentException(sprintf('The Translator "%s" must implement TranslatorInterface, TranslatorBagInterface and LocaleAwareInterface.', get_debug_type($translator)));
}
$this->translator = $translator;
@@ -46,9 +40,9 @@ class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface
/**
* {@inheritdoc}
*/
public function trans($id, array $parameters = array(), $domain = null, $locale = null)
public function trans(?string $id, array $parameters = [], string $domain = null, string $locale = null): string
{
$trans = $this->translator->trans($id, $parameters, $domain, $locale);
$trans = $this->translator->trans($id = (string) $id, $parameters, $domain, $locale);
$this->log($id, $domain, $locale);
return $trans;
@@ -57,26 +51,21 @@ class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface
/**
* {@inheritdoc}
*/
public function transChoice($id, $number, array $parameters = array(), $domain = null, $locale = null)
{
$trans = $this->translator->transChoice($id, $number, $parameters, $domain, $locale);
$this->log($id, $domain, $locale);
return $trans;
}
/**
* {@inheritdoc}
*/
public function setLocale($locale)
public function setLocale(string $locale)
{
$prev = $this->translator->getLocale();
$this->translator->setLocale($locale);
if ($prev === $locale) {
return;
}
$this->logger->debug(sprintf('The locale of the translator has changed from "%s" to "%s".', $prev, $locale));
}
/**
* {@inheritdoc}
*/
public function getLocale()
public function getLocale(): string
{
return $this->translator->getLocale();
}
@@ -84,56 +73,57 @@ class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface
/**
* {@inheritdoc}
*/
public function getCatalogue($locale = null)
public function getCatalogue(string $locale = null): MessageCatalogueInterface
{
return $this->translator->getCatalogue($locale);
}
/**
* Gets the fallback locales.
*
* @return array $locales The fallback locales
* {@inheritdoc}
*/
public function getFallbackLocales()
public function getCatalogues(): array
{
return $this->translator->getCatalogues();
}
/**
* Gets the fallback locales.
*/
public function getFallbackLocales(): array
{
if ($this->translator instanceof Translator || method_exists($this->translator, 'getFallbackLocales')) {
return $this->translator->getFallbackLocales();
}
return array();
return [];
}
/**
* Passes through all unknown calls onto the translator object.
*/
public function __call($method, $args)
public function __call(string $method, array $args)
{
return call_user_func_array(array($this->translator, $method), $args);
return $this->translator->{$method}(...$args);
}
/**
* Logs for missing translations.
*
* @param string $id
* @param string|null $domain
* @param string|null $locale
*/
private function log($id, $domain, $locale)
private function log(string $id, ?string $domain, ?string $locale)
{
if (null === $domain) {
$domain = 'messages';
}
$id = (string) $id;
$catalogue = $this->translator->getCatalogue($locale);
if ($catalogue->defines($id, $domain)) {
return;
}
if ($catalogue->has($id, $domain)) {
$this->logger->debug('Translation use fallback catalogue.', array('id' => $id, 'domain' => $domain, 'locale' => $catalogue->getLocale()));
$this->logger->debug('Translation use fallback catalogue.', ['id' => $id, 'domain' => $domain, 'locale' => $catalogue->getLocale()]);
} else {
$this->logger->warning('Translation not found.', array('id' => $id, 'domain' => $domain, 'locale' => $catalogue->getLocale()));
$this->logger->warning('Translation not found.', ['id' => $id, 'domain' => $domain, 'locale' => $catalogue->getLocale()]);
}
}
}

View File

@@ -15,26 +15,21 @@ use Symfony\Component\Config\Resource\ResourceInterface;
use Symfony\Component\Translation\Exception\LogicException;
/**
* MessageCatalogue.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterface
{
private $messages = array();
private $metadata = array();
private $resources = array();
private $locale;
private $fallbackCatalogue;
private $parent;
private array $messages = [];
private array $metadata = [];
private array $resources = [];
private string $locale;
private $fallbackCatalogue = null;
private ?self $parent = null;
/**
* Constructor.
*
* @param string $locale The locale
* @param array $messages An array of messages classified by domain
* @param array $messages An array of messages classified by domain
*/
public function __construct($locale, array $messages = array())
public function __construct(string $locale, array $messages = [])
{
$this->locale = $locale;
$this->messages = $messages;
@@ -43,7 +38,7 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function getLocale()
public function getLocale(): string
{
return $this->locale;
}
@@ -51,37 +46,62 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function getDomains()
public function getDomains(): array
{
return array_keys($this->messages);
}
$domains = [];
/**
* {@inheritdoc}
*/
public function all($domain = null)
{
if (null === $domain) {
return $this->messages;
foreach ($this->messages as $domain => $messages) {
if (str_ends_with($domain, self::INTL_DOMAIN_SUFFIX)) {
$domain = substr($domain, 0, -\strlen(self::INTL_DOMAIN_SUFFIX));
}
$domains[$domain] = $domain;
}
return isset($this->messages[$domain]) ? $this->messages[$domain] : array();
return array_values($domains);
}
/**
* {@inheritdoc}
*/
public function set($id, $translation, $domain = 'messages')
public function all(string $domain = null): array
{
$this->add(array($id => $translation), $domain);
if (null !== $domain) {
// skip messages merge if intl-icu requested explicitly
if (str_ends_with($domain, self::INTL_DOMAIN_SUFFIX)) {
return $this->messages[$domain] ?? [];
}
return ($this->messages[$domain.self::INTL_DOMAIN_SUFFIX] ?? []) + ($this->messages[$domain] ?? []);
}
$allMessages = [];
foreach ($this->messages as $domain => $messages) {
if (str_ends_with($domain, self::INTL_DOMAIN_SUFFIX)) {
$domain = substr($domain, 0, -\strlen(self::INTL_DOMAIN_SUFFIX));
$allMessages[$domain] = $messages + ($allMessages[$domain] ?? []);
} else {
$allMessages[$domain] = ($allMessages[$domain] ?? []) + $messages;
}
}
return $allMessages;
}
/**
* {@inheritdoc}
*/
public function has($id, $domain = 'messages')
public function set(string $id, string $translation, string $domain = 'messages')
{
if (isset($this->messages[$domain][$id])) {
$this->add([$id => $translation], $domain);
}
/**
* {@inheritdoc}
*/
public function has(string $id, string $domain = 'messages'): bool
{
if (isset($this->messages[$domain][$id]) || isset($this->messages[$domain.self::INTL_DOMAIN_SUFFIX][$id])) {
return true;
}
@@ -95,16 +115,20 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function defines($id, $domain = 'messages')
public function defines(string $id, string $domain = 'messages'): bool
{
return isset($this->messages[$domain][$id]);
return isset($this->messages[$domain][$id]) || isset($this->messages[$domain.self::INTL_DOMAIN_SUFFIX][$id]);
}
/**
* {@inheritdoc}
*/
public function get($id, $domain = 'messages')
public function get(string $id, string $domain = 'messages'): string
{
if (isset($this->messages[$domain.self::INTL_DOMAIN_SUFFIX][$id])) {
return $this->messages[$domain.self::INTL_DOMAIN_SUFFIX][$id];
}
if (isset($this->messages[$domain][$id])) {
return $this->messages[$domain][$id];
}
@@ -119,9 +143,9 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function replace($messages, $domain = 'messages')
public function replace(array $messages, string $domain = 'messages')
{
$this->messages[$domain] = array();
unset($this->messages[$domain], $this->messages[$domain.self::INTL_DOMAIN_SUFFIX]);
$this->add($messages, $domain);
}
@@ -129,12 +153,16 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function add($messages, $domain = 'messages')
public function add(array $messages, string $domain = 'messages')
{
if (!isset($this->messages[$domain])) {
$this->messages[$domain] = $messages;
} else {
$this->messages[$domain] = array_replace($this->messages[$domain], $messages);
$altDomain = str_ends_with($domain, self::INTL_DOMAIN_SUFFIX) ? substr($domain, 0, -\strlen(self::INTL_DOMAIN_SUFFIX)) : $domain.self::INTL_DOMAIN_SUFFIX;
foreach ($messages as $id => $message) {
unset($this->messages[$altDomain][$id]);
$this->messages[$domain][$id] = $message;
}
if ([] === ($this->messages[$altDomain] ?? null)) {
unset($this->messages[$altDomain]);
}
}
@@ -144,10 +172,14 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
public function addCatalogue(MessageCatalogueInterface $catalogue)
{
if ($catalogue->getLocale() !== $this->locale) {
throw new LogicException(sprintf('Cannot add a catalogue for locale "%s" as the current locale for this catalogue is "%s"', $catalogue->getLocale(), $this->locale));
throw new LogicException(sprintf('Cannot add a catalogue for locale "%s" as the current locale for this catalogue is "%s".', $catalogue->getLocale(), $this->locale));
}
foreach ($catalogue->all() as $domain => $messages) {
if ($intlMessages = $catalogue->all($domain.self::INTL_DOMAIN_SUFFIX)) {
$this->add($intlMessages, $domain.self::INTL_DOMAIN_SUFFIX);
$messages = array_diff_key($messages, $intlMessages);
}
$this->add($messages, $domain);
}
@@ -196,7 +228,7 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function getFallbackCatalogue()
public function getFallbackCatalogue(): ?MessageCatalogueInterface
{
return $this->fallbackCatalogue;
}
@@ -204,7 +236,7 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function getResources()
public function getResources(): array
{
return array_values($this->resources);
}
@@ -220,7 +252,7 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function getMetadata($key = '', $domain = 'messages')
public function getMetadata(string $key = '', string $domain = 'messages'): mixed
{
if ('' == $domain) {
return $this->metadata;
@@ -235,12 +267,14 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
return $this->metadata[$domain][$key];
}
}
return null;
}
/**
* {@inheritdoc}
*/
public function setMetadata($key, $value, $domain = 'messages')
public function setMetadata(string $key, mixed $value, string $domain = 'messages')
{
$this->metadata[$domain][$key] = $value;
}
@@ -248,10 +282,10 @@ class MessageCatalogue implements MessageCatalogueInterface, MetadataAwareInterf
/**
* {@inheritdoc}
*/
public function deleteMetadata($key = '', $domain = 'messages')
public function deleteMetadata(string $key = '', string $domain = 'messages')
{
if ('' == $domain) {
$this->metadata = array();
$this->metadata = [];
} elseif ('' == $key) {
unset($this->metadata[$domain]);
} else {

View File

@@ -20,19 +20,17 @@ use Symfony\Component\Config\Resource\ResourceInterface;
*/
interface MessageCatalogueInterface
{
public const INTL_DOMAIN_SUFFIX = '+intl-icu';
/**
* Gets the catalogue locale.
*
* @return string The locale
*/
public function getLocale();
public function getLocale(): string;
/**
* Gets the domains.
*
* @return array An array of domains
*/
public function getDomains();
public function getDomains(): array;
/**
* Gets the messages within a given domain.
@@ -40,10 +38,8 @@ interface MessageCatalogueInterface
* If $domain is null, it returns all messages.
*
* @param string $domain The domain name
*
* @return array An array of messages
*/
public function all($domain = null);
public function all(string $domain = null): array;
/**
* Sets a message translation.
@@ -52,37 +48,31 @@ interface MessageCatalogueInterface
* @param string $translation The messages translation
* @param string $domain The domain name
*/
public function set($id, $translation, $domain = 'messages');
public function set(string $id, string $translation, string $domain = 'messages');
/**
* Checks if a message has a translation.
*
* @param string $id The message id
* @param string $domain The domain name
*
* @return bool true if the message has a translation, false otherwise
*/
public function has($id, $domain = 'messages');
public function has(string $id, string $domain = 'messages'): bool;
/**
* Checks if a message has a translation (it does not take into account the fallback mechanism).
*
* @param string $id The message id
* @param string $domain The domain name
*
* @return bool true if the message has a translation, false otherwise
*/
public function defines($id, $domain = 'messages');
public function defines(string $id, string $domain = 'messages'): bool;
/**
* Gets a message translation.
*
* @param string $id The message id
* @param string $domain The domain name
*
* @return string The message translation
*/
public function get($id, $domain = 'messages');
public function get(string $id, string $domain = 'messages'): string;
/**
* Sets translations for a given domain.
@@ -90,7 +80,7 @@ interface MessageCatalogueInterface
* @param array $messages An array of translations
* @param string $domain The domain name
*/
public function replace($messages, $domain = 'messages');
public function replace(array $messages, string $domain = 'messages');
/**
* Adds translations for a given domain.
@@ -98,45 +88,37 @@ interface MessageCatalogueInterface
* @param array $messages An array of translations
* @param string $domain The domain name
*/
public function add($messages, $domain = 'messages');
public function add(array $messages, string $domain = 'messages');
/**
* Merges translations from the given Catalogue into the current one.
*
* The two catalogues must have the same locale.
*
* @param self $catalogue
*/
public function addCatalogue(MessageCatalogueInterface $catalogue);
public function addCatalogue(self $catalogue);
/**
* Merges translations from the given Catalogue into the current one
* only when the translation does not exist.
*
* This is used to provide default translations when they do not exist for the current locale.
*
* @param self $catalogue
*/
public function addFallbackCatalogue(MessageCatalogueInterface $catalogue);
public function addFallbackCatalogue(self $catalogue);
/**
* Gets the fallback catalogue.
*
* @return self|null A MessageCatalogueInterface instance or null when no fallback has been set
*/
public function getFallbackCatalogue();
public function getFallbackCatalogue(): ?self;
/**
* Returns an array of resources loaded to build this collection.
*
* @return ResourceInterface[] An array of resources
* @return ResourceInterface[]
*/
public function getResources();
public function getResources(): array;
/**
* Adds a resource for this collection.
*
* @param ResourceInterface $resource A resource instance
*/
public function addResource(ResourceInterface $resource);
}

View File

@@ -1,88 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
/**
* MessageSelector.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class MessageSelector
{
/**
* Given a message with different plural translations separated by a
* pipe (|), this method returns the correct portion of the message based
* on the given number, locale and the pluralization rules in the message
* itself.
*
* The message supports two different types of pluralization rules:
*
* interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples
* indexed: There is one apple|There are %count% apples
*
* The indexed solution can also contain labels (e.g. one: There is one apple).
* This is purely for making the translations more clear - it does not
* affect the functionality.
*
* The two methods can also be mixed:
* {0} There are no apples|one: There is one apple|more: There are %count% apples
*
* @param string $message The message being translated
* @param int $number The number of items represented for the message
* @param string $locale The locale to use for choosing
*
* @return string
*
* @throws InvalidArgumentException
*/
public function choose($message, $number, $locale)
{
preg_match_all('/(?:\|\||[^\|])++/', $message, $parts);
$explicitRules = array();
$standardRules = array();
foreach ($parts[0] as $part) {
$part = trim(str_replace('||', '|', $part));
if (preg_match('/^(?P<interval>'.Interval::getIntervalRegexp().')\s*(?P<message>.*?)$/xs', $part, $matches)) {
$explicitRules[$matches['interval']] = $matches['message'];
} elseif (preg_match('/^\w+\:\s*(.*?)$/', $part, $matches)) {
$standardRules[] = $matches[1];
} else {
$standardRules[] = $part;
}
}
// try to match an explicit rule, then fallback to the standard ones
foreach ($explicitRules as $interval => $m) {
if (Interval::test($number, $interval)) {
return $m;
}
}
$position = PluralizationRules::get($number, $locale);
if (!isset($standardRules[$position])) {
// when there's exactly one rule given, and that rule is a standard
// rule, use this rule
if (1 === count($parts[0]) && isset($standardRules[0])) {
return $standardRules[0];
}
throw new InvalidArgumentException(sprintf('Unable to choose a translation for "%s" with locale "%s" for value "%d". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %%count%% apples").', $message, $locale, $number));
}
return $standardRules[$position];
}
}

View File

@@ -25,30 +25,20 @@ interface MetadataAwareInterface
* domain and then by key. Passing an empty key will return an array with all
* metadata for the given domain.
*
* @param string $key The key
* @param string $domain The domain name
*
* @return mixed The value that was set or an array with the domains/keys or null
*/
public function getMetadata($key = '', $domain = 'messages');
public function getMetadata(string $key = '', string $domain = 'messages'): mixed;
/**
* Adds metadata to a message domain.
*
* @param string $key The key
* @param mixed $value The value
* @param string $domain The domain name
*/
public function setMetadata($key, $value, $domain = 'messages');
public function setMetadata(string $key, mixed $value, string $domain = 'messages');
/**
* Deletes metadata for the given key and domain.
*
* Passing an empty domain will delete all metadata. Passing an empty key will
* delete all metadata for the given domain.
*
* @param string $key The key
* @param string $domain The domain name
*/
public function deleteMetadata($key = '', $domain = 'messages');
public function deleteMetadata(string $key = '', string $domain = 'messages');
}

View File

@@ -1,209 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation;
/**
* Returns the plural rules for a given locale.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class PluralizationRules
{
private static $rules = array();
/**
* Returns the plural position to use for the given locale and number.
*
* @param int $number The number
* @param string $locale The locale
*
* @return int The plural position
*/
public static function get($number, $locale)
{
if ('pt_BR' === $locale) {
// temporary set a locale for brazilian
$locale = 'xbr';
}
if (strlen($locale) > 3) {
$locale = substr($locale, 0, -strlen(strrchr($locale, '_')));
}
if (isset(self::$rules[$locale])) {
$return = call_user_func(self::$rules[$locale], $number);
if (!is_int($return) || $return < 0) {
return 0;
}
return $return;
}
/*
* The plural rules are derived from code of the Zend Framework (2010-09-25),
* which is subject to the new BSD license (http://framework.zend.com/license/new-bsd).
* Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
*/
switch ($locale) {
case 'az':
case 'bo':
case 'dz':
case 'id':
case 'ja':
case 'jv':
case 'ka':
case 'km':
case 'kn':
case 'ko':
case 'ms':
case 'th':
case 'tr':
case 'vi':
case 'zh':
return 0;
break;
case 'af':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'ur':
case 'zu':
return ($number == 1) ? 0 : 1;
case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'hy':
case 'ln':
case 'mg':
case 'nso':
case 'xbr':
case 'ti':
case 'wa':
return (($number == 0) || ($number == 1)) ? 0 : 1;
case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sr':
case 'uk':
return (($number % 10 == 1) && ($number % 100 != 11)) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2);
case 'cs':
case 'sk':
return ($number == 1) ? 0 : ((($number >= 2) && ($number <= 4)) ? 1 : 2);
case 'ga':
return ($number == 1) ? 0 : (($number == 2) ? 1 : 2);
case 'lt':
return (($number % 10 == 1) && ($number % 100 != 11)) ? 0 : ((($number % 10 >= 2) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2);
case 'sl':
return ($number % 100 == 1) ? 0 : (($number % 100 == 2) ? 1 : ((($number % 100 == 3) || ($number % 100 == 4)) ? 2 : 3));
case 'mk':
return ($number % 10 == 1) ? 0 : 1;
case 'mt':
return ($number == 1) ? 0 : ((($number == 0) || (($number % 100 > 1) && ($number % 100 < 11))) ? 1 : ((($number % 100 > 10) && ($number % 100 < 20)) ? 2 : 3));
case 'lv':
return ($number == 0) ? 0 : ((($number % 10 == 1) && ($number % 100 != 11)) ? 1 : 2);
case 'pl':
return ($number == 1) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 12) || ($number % 100 > 14))) ? 1 : 2);
case 'cy':
return ($number == 1) ? 0 : (($number == 2) ? 1 : ((($number == 8) || ($number == 11)) ? 2 : 3));
case 'ro':
return ($number == 1) ? 0 : ((($number == 0) || (($number % 100 > 0) && ($number % 100 < 20))) ? 1 : 2);
case 'ar':
return ($number == 0) ? 0 : (($number == 1) ? 1 : (($number == 2) ? 2 : ((($number % 100 >= 3) && ($number % 100 <= 10)) ? 3 : ((($number % 100 >= 11) && ($number % 100 <= 99)) ? 4 : 5))));
default:
return 0;
}
}
/**
* Overrides the default plural rule for a given locale.
*
* @param callable $rule A PHP callable
* @param string $locale The locale
*/
public static function set(callable $rule, $locale)
{
if ('pt_BR' === $locale) {
// temporary set a locale for brazilian
$locale = 'xbr';
}
if (strlen($locale) > 3) {
$locale = substr($locale, 0, -strlen(strrchr($locale, '_')));
}
self::$rules[$locale] = $rule;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\Exception\IncompleteDsnException;
abstract class AbstractProviderFactory implements ProviderFactoryInterface
{
public function supports(Dsn $dsn): bool
{
return \in_array($dsn->getScheme(), $this->getSupportedSchemes(), true);
}
/**
* @return string[]
*/
abstract protected function getSupportedSchemes(): array;
protected function getUser(Dsn $dsn): string
{
if (null === $user = $dsn->getUser()) {
throw new IncompleteDsnException('User is not set.', $dsn->getOriginalDsn());
}
return $user;
}
protected function getPassword(Dsn $dsn): string
{
if (null === $password = $dsn->getPassword()) {
throw new IncompleteDsnException('Password is not set.', $dsn->getOriginalDsn());
}
return $password;
}
}

View File

@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
use Symfony\Component\Translation\Exception\MissingRequiredOptionException;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Oskar Stark <oskarstark@googlemail.com>
*/
final class Dsn
{
private ?string $scheme;
private ?string $host;
private ?string $user;
private ?string $password;
private ?int $port;
private ?string $path;
private array $options = [];
private string $originalDsn;
public function __construct(string $dsn)
{
$this->originalDsn = $dsn;
if (false === $parsedDsn = parse_url($dsn)) {
throw new InvalidArgumentException(sprintf('The "%s" translation provider DSN is invalid.', $dsn));
}
if (!isset($parsedDsn['scheme'])) {
throw new InvalidArgumentException(sprintf('The "%s" translation provider DSN must contain a scheme.', $dsn));
}
$this->scheme = $parsedDsn['scheme'];
if (!isset($parsedDsn['host'])) {
throw new InvalidArgumentException(sprintf('The "%s" translation provider DSN must contain a host (use "default" by default).', $dsn));
}
$this->host = $parsedDsn['host'];
$this->user = '' !== ($parsedDsn['user'] ?? '') ? urldecode($parsedDsn['user']) : null;
$this->password = '' !== ($parsedDsn['pass'] ?? '') ? urldecode($parsedDsn['pass']) : null;
$this->port = $parsedDsn['port'] ?? null;
$this->path = $parsedDsn['path'] ?? null;
parse_str($parsedDsn['query'] ?? '', $this->options);
}
public function getScheme(): string
{
return $this->scheme;
}
public function getHost(): string
{
return $this->host;
}
public function getUser(): ?string
{
return $this->user;
}
public function getPassword(): ?string
{
return $this->password;
}
public function getPort(int $default = null): ?int
{
return $this->port ?? $default;
}
public function getOption(string $key, mixed $default = null)
{
return $this->options[$key] ?? $default;
}
public function getRequiredOption(string $key)
{
if (!\array_key_exists($key, $this->options) || '' === trim($this->options[$key])) {
throw new MissingRequiredOptionException($key);
}
return $this->options[$key];
}
public function getOptions(): array
{
return $this->options;
}
public function getPath(): ?string
{
return $this->path;
}
public function getOriginalDsn(): string
{
return $this->originalDsn;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Component\Translation\TranslatorBagInterface;
/**
* Filters domains and locales between the Translator config values and those specific to each provider.
*
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*/
class FilteringProvider implements ProviderInterface
{
private $provider;
private array $locales;
private array $domains;
public function __construct(ProviderInterface $provider, array $locales, array $domains = [])
{
$this->provider = $provider;
$this->locales = $locales;
$this->domains = $domains;
}
public function __toString(): string
{
return (string) $this->provider;
}
/**
* {@inheritdoc}
*/
public function write(TranslatorBagInterface $translatorBag): void
{
$this->provider->write($translatorBag);
}
public function read(array $domains, array $locales): TranslatorBag
{
$domains = !$this->domains ? $domains : array_intersect($this->domains, $domains);
$locales = array_intersect($this->locales, $locales);
return $this->provider->read($domains, $locales);
}
public function delete(TranslatorBagInterface $translatorBag): void
{
$this->provider->delete($translatorBag);
}
public function getDomains(): array
{
return $this->domains;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Component\Translation\TranslatorBagInterface;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*/
class NullProvider implements ProviderInterface
{
public function __toString(): string
{
return 'null';
}
public function write(TranslatorBagInterface $translatorBag, bool $override = false): void
{
}
public function read(array $domains, array $locales): TranslatorBag
{
return new TranslatorBag();
}
public function delete(TranslatorBagInterface $translatorBag): void
{
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\Exception\UnsupportedSchemeException;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*/
final class NullProviderFactory extends AbstractProviderFactory
{
public function create(Dsn $dsn): ProviderInterface
{
if ('null' === $dsn->getScheme()) {
return new NullProvider();
}
throw new UnsupportedSchemeException($dsn, 'null', $this->getSupportedSchemes());
}
protected function getSupportedSchemes(): array
{
return ['null'];
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\Exception\IncompleteDsnException;
use Symfony\Component\Translation\Exception\UnsupportedSchemeException;
interface ProviderFactoryInterface
{
/**
* @throws UnsupportedSchemeException
* @throws IncompleteDsnException
*/
public function create(Dsn $dsn): ProviderInterface;
public function supports(Dsn $dsn): bool;
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Component\Translation\TranslatorBagInterface;
interface ProviderInterface
{
public function __toString(): string;
/**
* Translations available in the TranslatorBag only must be created.
* Translations available in both the TranslatorBag and on the provider
* must be overwritten.
* Translations available on the provider only must be kept.
*/
public function write(TranslatorBagInterface $translatorBag): void;
public function read(array $domains, array $locales): TranslatorBag;
public function delete(TranslatorBagInterface $translatorBag): void;
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*/
final class TranslationProviderCollection
{
/**
* @var array<string, ProviderInterface>
*/
private $providers;
/**
* @param array<string, ProviderInterface> $providers
*/
public function __construct(iterable $providers)
{
$this->providers = \is_array($providers) ? $providers : iterator_to_array($providers);
}
public function __toString(): string
{
return '['.implode(',', array_keys($this->providers)).']';
}
public function has(string $name): bool
{
return isset($this->providers[$name]);
}
public function get(string $name): ProviderInterface
{
if (!$this->has($name)) {
throw new InvalidArgumentException(sprintf('Provider "%s" not found. Available: "%s".', $name, (string) $this));
}
return $this->providers[$name];
}
public function keys(): array
{
return array_keys($this->providers);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Provider;
use Symfony\Component\Translation\Exception\UnsupportedSchemeException;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*/
class TranslationProviderCollectionFactory
{
private iterable $factories;
private array $enabledLocales;
/**
* @param iterable<mixed, ProviderFactoryInterface> $factories
*/
public function __construct(iterable $factories, array $enabledLocales)
{
$this->factories = $factories;
$this->enabledLocales = $enabledLocales;
}
public function fromConfig(array $config): TranslationProviderCollection
{
$providers = [];
foreach ($config as $name => $currentConfig) {
$providers[$name] = $this->fromDsnObject(
new Dsn($currentConfig['dsn']),
!$currentConfig['locales'] ? $this->enabledLocales : $currentConfig['locales'],
!$currentConfig['domains'] ? [] : $currentConfig['domains']
);
}
return new TranslationProviderCollection($providers);
}
public function fromDsnObject(Dsn $dsn, array $locales, array $domains = []): ProviderInterface
{
foreach ($this->factories as $factory) {
if ($factory->supports($dsn)) {
return new FilteringProvider($factory->create($dsn), $locales, $domains);
}
}
throw new UnsupportedSchemeException($dsn);
}
}

View File

@@ -0,0 +1,368 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* This translator should only be used in a development environment.
*/
final class PseudoLocalizationTranslator implements TranslatorInterface
{
private const EXPANSION_CHARACTER = '~';
private $translator;
private bool $accents;
private float $expansionFactor;
private bool $brackets;
private bool $parseHTML;
/**
* @var string[]
*/
private array $localizableHTMLAttributes;
/**
* Available options:
* * accents:
* type: boolean
* default: true
* description: replace ASCII characters of the translated string with accented versions or similar characters
* example: if true, "foo" => "ƒöö".
*
* * expansion_factor:
* type: float
* default: 1
* validation: it must be greater than or equal to 1
* description: expand the translated string by the given factor with spaces and tildes
* example: if 2, "foo" => "~foo ~"
*
* * brackets:
* type: boolean
* default: true
* description: wrap the translated string with brackets
* example: if true, "foo" => "[foo]"
*
* * parse_html:
* type: boolean
* default: false
* description: parse the translated string as HTML - looking for HTML tags has a performance impact but allows to preserve them from alterations - it also allows to compute the visible translated string length which is useful to correctly expand ot when it contains HTML
* warning: unclosed tags are unsupported, they will be fixed (closed) by the parser - eg, "foo <div>bar" => "foo <div>bar</div>"
*
* * localizable_html_attributes:
* type: string[]
* default: []
* description: the list of HTML attributes whose values can be altered - it is only useful when the "parse_html" option is set to true
* example: if ["title"], and with the "accents" option set to true, "<a href="#" title="Go to your profile">Profile</a>" => "<a href="#" title="Ĝö ţö ýöûŕ þŕöƒîļé">Þŕöƒîļé</a>" - if "title" was not in the "localizable_html_attributes" list, the title attribute data would be left unchanged.
*/
public function __construct(TranslatorInterface $translator, array $options = [])
{
$this->translator = $translator;
$this->accents = $options['accents'] ?? true;
if (1.0 > ($this->expansionFactor = $options['expansion_factor'] ?? 1.0)) {
throw new \InvalidArgumentException('The expansion factor must be greater than or equal to 1.');
}
$this->brackets = $options['brackets'] ?? true;
$this->parseHTML = $options['parse_html'] ?? false;
if ($this->parseHTML && !$this->accents && 1.0 === $this->expansionFactor) {
$this->parseHTML = false;
}
$this->localizableHTMLAttributes = $options['localizable_html_attributes'] ?? [];
}
/**
* {@inheritdoc}
*/
public function trans(string $id, array $parameters = [], string $domain = null, string $locale = null): string
{
$trans = '';
$visibleText = '';
foreach ($this->getParts($this->translator->trans($id, $parameters, $domain, $locale)) as [$visible, $localizable, $text]) {
if ($visible) {
$visibleText .= $text;
}
if (!$localizable) {
$trans .= $text;
continue;
}
$this->addAccents($trans, $text);
}
$this->expand($trans, $visibleText);
$this->addBrackets($trans);
return $trans;
}
public function getLocale(): string
{
return $this->translator->getLocale();
}
private function getParts(string $originalTrans): array
{
if (!$this->parseHTML) {
return [[true, true, $originalTrans]];
}
$html = mb_encode_numericentity($originalTrans, [0x80, 0xFFFF, 0, 0xFFFF], mb_detect_encoding($originalTrans, null, true) ?: 'UTF-8');
$useInternalErrors = libxml_use_internal_errors(true);
$dom = new \DOMDocument();
$dom->loadHTML('<trans>'.$html.'</trans>');
libxml_clear_errors();
libxml_use_internal_errors($useInternalErrors);
return $this->parseNode($dom->childNodes->item(1)->childNodes->item(0)->childNodes->item(0));
}
private function parseNode(\DOMNode $node): array
{
$parts = [];
foreach ($node->childNodes as $childNode) {
if (!$childNode instanceof \DOMElement) {
$parts[] = [true, true, $childNode->nodeValue];
continue;
}
$parts[] = [false, false, '<'.$childNode->tagName];
/** @var \DOMAttr $attribute */
foreach ($childNode->attributes as $attribute) {
$parts[] = [false, false, ' '.$attribute->nodeName.'="'];
$localizableAttribute = \in_array($attribute->nodeName, $this->localizableHTMLAttributes, true);
foreach (preg_split('/(&(?:amp|quot|#039|lt|gt);+)/', htmlspecialchars($attribute->nodeValue, \ENT_QUOTES, 'UTF-8'), -1, \PREG_SPLIT_DELIM_CAPTURE) as $i => $match) {
if ('' === $match) {
continue;
}
$parts[] = [false, $localizableAttribute && 0 === $i % 2, $match];
}
$parts[] = [false, false, '"'];
}
$parts[] = [false, false, '>'];
$parts = array_merge($parts, $this->parseNode($childNode, $parts));
$parts[] = [false, false, '</'.$childNode->tagName.'>'];
}
return $parts;
}
private function addAccents(string &$trans, string $text): void
{
$trans .= $this->accents ? strtr($text, [
' ' => '',
'!' => '¡',
'"' => '″',
'#' => '♯',
'$' => '€',
'%' => '‰',
'&' => '⅋',
'\'' => '´',
'(' => '{',
')' => '}',
'*' => '',
'+' => '⁺',
',' => '،',
'-' => '',
'.' => '·',
'/' => '',
'0' => '⓪',
'1' => '①',
'2' => '②',
'3' => '③',
'4' => '④',
'5' => '⑤',
'6' => '⑥',
'7' => '⑦',
'8' => '⑧',
'9' => '⑨',
':' => '',
';' => '⁏',
'<' => '≤',
'=' => '≂',
'>' => '≥',
'?' => '¿',
'@' => '՞',
'A' => 'Å',
'B' => 'Ɓ',
'C' => 'Ç',
'D' => 'Ð',
'E' => 'É',
'F' => 'Ƒ',
'G' => 'Ĝ',
'H' => 'Ĥ',
'I' => 'Î',
'J' => 'Ĵ',
'K' => 'Ķ',
'L' => 'Ļ',
'M' => 'Ṁ',
'N' => 'Ñ',
'O' => 'Ö',
'P' => 'Þ',
'Q' => 'Ǫ',
'R' => 'Ŕ',
'S' => 'Š',
'T' => 'Ţ',
'U' => 'Û',
'V' => 'Ṽ',
'W' => 'Ŵ',
'X' => 'Ẋ',
'Y' => 'Ý',
'Z' => 'Ž',
'[' => '⁅',
'\\' => '',
']' => '⁆',
'^' => '˄',
'_' => '‿',
'`' => '',
'a' => 'å',
'b' => 'ƀ',
'c' => 'ç',
'd' => 'ð',
'e' => 'é',
'f' => 'ƒ',
'g' => 'ĝ',
'h' => 'ĥ',
'i' => 'î',
'j' => 'ĵ',
'k' => 'ķ',
'l' => 'ļ',
'm' => 'ɱ',
'n' => 'ñ',
'o' => 'ö',
'p' => 'þ',
'q' => 'ǫ',
'r' => 'ŕ',
's' => 'š',
't' => 'ţ',
'u' => 'û',
'v' => 'ṽ',
'w' => 'ŵ',
'x' => 'ẋ',
'y' => 'ý',
'z' => 'ž',
'{' => '(',
'|' => '¦',
'}' => ')',
'~' => '˞',
]) : $text;
}
private function expand(string &$trans, string $visibleText): void
{
if (1.0 >= $this->expansionFactor) {
return;
}
$visibleLength = $this->strlen($visibleText);
$missingLength = (int) ceil($visibleLength * $this->expansionFactor) - $visibleLength;
if ($this->brackets) {
$missingLength -= 2;
}
if (0 >= $missingLength) {
return;
}
$words = [];
$wordsCount = 0;
foreach (preg_split('/ +/', $visibleText, -1, \PREG_SPLIT_NO_EMPTY) as $word) {
$wordLength = $this->strlen($word);
if ($wordLength >= $missingLength) {
continue;
}
if (!isset($words[$wordLength])) {
$words[$wordLength] = 0;
}
++$words[$wordLength];
++$wordsCount;
}
if (!$words) {
$trans .= 1 === $missingLength ? self::EXPANSION_CHARACTER : ' '.str_repeat(self::EXPANSION_CHARACTER, $missingLength - 1);
return;
}
arsort($words, \SORT_NUMERIC);
$longestWordLength = max(array_keys($words));
while (true) {
$r = mt_rand(1, $wordsCount);
foreach ($words as $length => $count) {
$r -= $count;
if ($r <= 0) {
break;
}
}
$trans .= ' '.str_repeat(self::EXPANSION_CHARACTER, $length);
$missingLength -= $length + 1;
if (0 === $missingLength) {
return;
}
while ($longestWordLength >= $missingLength) {
$wordsCount -= $words[$longestWordLength];
unset($words[$longestWordLength]);
if (!$words) {
$trans .= 1 === $missingLength ? self::EXPANSION_CHARACTER : ' '.str_repeat(self::EXPANSION_CHARACTER, $missingLength - 1);
return;
}
$longestWordLength = max(array_keys($words));
}
}
}
private function addBrackets(string &$trans): void
{
if (!$this->brackets) {
return;
}
$trans = '['.$trans.']';
}
private function strlen(string $s): int
{
return false === ($encoding = mb_detect_encoding($s, null, true)) ? \strlen($s) : mb_strlen($s, $encoding);
}
}

View File

@@ -3,11 +3,46 @@ Translation Component
The Translation component provides tools to internationalize your application.
Getting Started
---------------
```
$ composer require symfony/translation
```
```php
use Symfony\Component\Translation\Translator;
use Symfony\Component\Translation\Loader\ArrayLoader;
$translator = new Translator('fr_FR');
$translator->addLoader('array', new ArrayLoader());
$translator->addResource('array', [
'Hello World!' => 'Bonjour !',
], 'fr_FR');
echo $translator->trans('Hello World!'); // outputs « Bonjour ! »
```
Sponsor
-------
The Translation component for Symfony 5.4/6.0 is [backed][1] by:
* [Crowdin][2], a cloud-based localization management software helping teams to go global and stay agile.
* [Lokalise][3], a continuous localization and translation management platform that integrates into your development workflow so you can ship localized products, faster.
Help Symfony by [sponsoring][4] its development!
Resources
---------
* [Documentation](https://symfony.com/doc/current/components/translation/index.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)
* [Documentation](https://symfony.com/doc/current/translation.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)
[1]: https://symfony.com/backers
[2]: https://crowdin.com
[3]: https://lokalise.com
[4]: https://symfony.com/sponsor

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Reader;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\MessageCatalogue;
/**
* TranslationReader reads translation messages from translation files.
*
* @author Michel Salib <michelsalib@hotmail.com>
*/
class TranslationReader implements TranslationReaderInterface
{
/**
* Loaders used for import.
*
* @var array<string, LoaderInterface>
*/
private array $loaders = [];
/**
* Adds a loader to the translation extractor.
*
* @param string $format The format of the loader
*/
public function addLoader(string $format, LoaderInterface $loader)
{
$this->loaders[$format] = $loader;
}
/**
* {@inheritdoc}
*/
public function read(string $directory, MessageCatalogue $catalogue)
{
if (!is_dir($directory)) {
return;
}
foreach ($this->loaders as $format => $loader) {
// load any existing translation files
$finder = new Finder();
$extension = $catalogue->getLocale().'.'.$format;
$files = $finder->files()->name('*.'.$extension)->in($directory);
foreach ($files as $file) {
$domain = substr($file->getFilename(), 0, -1 * \strlen($extension) - 1);
$catalogue->addCatalogue($loader->load($file->getPathname(), $catalogue->getLocale(), $domain));
}
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Reader;
use Symfony\Component\Translation\MessageCatalogue;
/**
* TranslationReader reads translation messages from translation files.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface TranslationReaderInterface
{
/**
* Reads translation messages from a directory to the catalogue.
*/
public function read(string $directory, MessageCatalogue $catalogue);
}

View File

@@ -0,0 +1,278 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
if ('cli' !== \PHP_SAPI) {
throw new Exception('This script must be run from the command line.');
}
$usageInstructions = <<<END
Usage instructions
-------------------------------------------------------------------------------
$ cd symfony-code-root-directory/
# show the translation status of all locales
$ php translation-status.php
# only show the translation status of incomplete or erroneous locales
$ php translation-status.php --incomplete
# show the translation status of all locales, all their missing translations and mismatches between trans-unit id and source
$ php translation-status.php -v
# show the status of a single locale
$ php translation-status.php fr
# show the status of a single locale, missing translations and mismatches between trans-unit id and source
$ php translation-status.php fr -v
END;
$config = [
// if TRUE, the full list of missing translations is displayed
'verbose_output' => false,
// NULL = analyze all locales
'locale_to_analyze' => null,
// append --incomplete to only show incomplete languages
'include_completed_languages' => true,
// the reference files all the other translations are compared to
'original_files' => [
'src/Symfony/Component/Form/Resources/translations/validators.en.xlf',
'src/Symfony/Component/Security/Core/Resources/translations/security.en.xlf',
'src/Symfony/Component/Validator/Resources/translations/validators.en.xlf',
],
];
$argc = $_SERVER['argc'];
$argv = $_SERVER['argv'];
if ($argc > 4) {
echo str_replace('translation-status.php', $argv[0], $usageInstructions);
exit(1);
}
foreach (array_slice($argv, 1) as $argumentOrOption) {
if ('--incomplete' === $argumentOrOption) {
$config['include_completed_languages'] = false;
continue;
}
if (str_starts_with($argumentOrOption, '-')) {
$config['verbose_output'] = true;
} else {
$config['locale_to_analyze'] = $argumentOrOption;
}
}
foreach ($config['original_files'] as $originalFilePath) {
if (!file_exists($originalFilePath)) {
echo sprintf('The following file does not exist. Make sure that you execute this command at the root dir of the Symfony code repository.%s %s', \PHP_EOL, $originalFilePath);
exit(1);
}
}
$totalMissingTranslations = 0;
$totalTranslationMismatches = 0;
foreach ($config['original_files'] as $originalFilePath) {
$translationFilePaths = findTranslationFiles($originalFilePath, $config['locale_to_analyze']);
$translationStatus = calculateTranslationStatus($originalFilePath, $translationFilePaths);
$totalMissingTranslations += array_sum(array_map(function ($translation) {
return count($translation['missingKeys']);
}, array_values($translationStatus)));
$totalTranslationMismatches += array_sum(array_map(function ($translation) {
return count($translation['mismatches']);
}, array_values($translationStatus)));
printTranslationStatus($originalFilePath, $translationStatus, $config['verbose_output'], $config['include_completed_languages']);
}
exit($totalTranslationMismatches > 0 ? 1 : 0);
function findTranslationFiles($originalFilePath, $localeToAnalyze)
{
$translations = [];
$translationsDir = dirname($originalFilePath);
$originalFileName = basename($originalFilePath);
$translationFileNamePattern = str_replace('.en.', '.*.', $originalFileName);
$translationFiles = glob($translationsDir.'/'.$translationFileNamePattern, \GLOB_NOSORT);
sort($translationFiles);
foreach ($translationFiles as $filePath) {
$locale = extractLocaleFromFilePath($filePath);
if (null !== $localeToAnalyze && $locale !== $localeToAnalyze) {
continue;
}
$translations[$locale] = $filePath;
}
return $translations;
}
function calculateTranslationStatus($originalFilePath, $translationFilePaths)
{
$translationStatus = [];
$allTranslationKeys = extractTranslationKeys($originalFilePath);
foreach ($translationFilePaths as $locale => $translationPath) {
$translatedKeys = extractTranslationKeys($translationPath);
$missingKeys = array_diff_key($allTranslationKeys, $translatedKeys);
$mismatches = findTransUnitMismatches($allTranslationKeys, $translatedKeys);
$translationStatus[$locale] = [
'total' => count($allTranslationKeys),
'translated' => count($translatedKeys),
'missingKeys' => $missingKeys,
'mismatches' => $mismatches,
];
$translationStatus[$locale]['is_completed'] = isTranslationCompleted($translationStatus[$locale]);
}
return $translationStatus;
}
function isTranslationCompleted(array $translationStatus): bool
{
return $translationStatus['total'] === $translationStatus['translated'] && 0 === count($translationStatus['mismatches']);
}
function printTranslationStatus($originalFilePath, $translationStatus, $verboseOutput, $includeCompletedLanguages)
{
printTitle($originalFilePath);
printTable($translationStatus, $verboseOutput, $includeCompletedLanguages);
echo \PHP_EOL.\PHP_EOL;
}
function extractLocaleFromFilePath($filePath)
{
$parts = explode('.', $filePath);
return $parts[count($parts) - 2];
}
function extractTranslationKeys($filePath)
{
$translationKeys = [];
$contents = new \SimpleXMLElement(file_get_contents($filePath));
foreach ($contents->file->body->{'trans-unit'} as $translationKey) {
$translationId = (string) $translationKey['id'];
$translationKey = (string) $translationKey->source;
$translationKeys[$translationId] = $translationKey;
}
return $translationKeys;
}
/**
* Check whether the trans-unit id and source match with the base translation.
*/
function findTransUnitMismatches(array $baseTranslationKeys, array $translatedKeys): array
{
$mismatches = [];
foreach ($baseTranslationKeys as $translationId => $translationKey) {
if (!isset($translatedKeys[$translationId])) {
continue;
}
if ($translatedKeys[$translationId] !== $translationKey) {
$mismatches[$translationId] = [
'found' => $translatedKeys[$translationId],
'expected' => $translationKey,
];
}
}
return $mismatches;
}
function printTitle($title)
{
echo $title.\PHP_EOL;
echo str_repeat('=', strlen($title)).\PHP_EOL.\PHP_EOL;
}
function printTable($translations, $verboseOutput, bool $includeCompletedLanguages)
{
if (0 === count($translations)) {
echo 'No translations found';
return;
}
$longestLocaleNameLength = max(array_map('strlen', array_keys($translations)));
foreach ($translations as $locale => $translation) {
if (!$includeCompletedLanguages && $translation['is_completed']) {
continue;
}
if ($translation['translated'] > $translation['total']) {
textColorRed();
} elseif (count($translation['mismatches']) > 0) {
textColorRed();
} elseif ($translation['is_completed']) {
textColorGreen();
}
echo sprintf(
'| Locale: %-'.$longestLocaleNameLength.'s | Translated: %2d/%2d | Mismatches: %d |',
$locale,
$translation['translated'],
$translation['total'],
count($translation['mismatches'])
).\PHP_EOL;
textColorNormal();
$shouldBeClosed = false;
if (true === $verboseOutput && count($translation['missingKeys']) > 0) {
echo '| Missing Translations:'.\PHP_EOL;
foreach ($translation['missingKeys'] as $id => $content) {
echo sprintf('| (id=%s) %s', $id, $content).\PHP_EOL;
}
$shouldBeClosed = true;
}
if (true === $verboseOutput && count($translation['mismatches']) > 0) {
echo '| Mismatches between trans-unit id and source:'.\PHP_EOL;
foreach ($translation['mismatches'] as $id => $content) {
echo sprintf('| (id=%s) Expected: %s', $id, $content['expected']).\PHP_EOL;
echo sprintf('| Found: %s', $content['found']).\PHP_EOL;
}
$shouldBeClosed = true;
}
if ($shouldBeClosed) {
echo str_repeat('-', 80).\PHP_EOL;
}
}
}
function textColorGreen()
{
echo "\033[32m";
}
function textColorRed()
{
echo "\033[31m";
}
function textColorNormal()
{
echo "\033[0m";
}

View File

@@ -0,0 +1,141 @@
{
"az_Cyrl": "root",
"bs_Cyrl": "root",
"en_150": "en_001",
"en_AG": "en_001",
"en_AI": "en_001",
"en_AT": "en_150",
"en_AU": "en_001",
"en_BB": "en_001",
"en_BE": "en_150",
"en_BM": "en_001",
"en_BS": "en_001",
"en_BW": "en_001",
"en_BZ": "en_001",
"en_CC": "en_001",
"en_CH": "en_150",
"en_CK": "en_001",
"en_CM": "en_001",
"en_CX": "en_001",
"en_CY": "en_001",
"en_DE": "en_150",
"en_DG": "en_001",
"en_DK": "en_150",
"en_DM": "en_001",
"en_ER": "en_001",
"en_FI": "en_150",
"en_FJ": "en_001",
"en_FK": "en_001",
"en_FM": "en_001",
"en_GB": "en_001",
"en_GD": "en_001",
"en_GG": "en_001",
"en_GH": "en_001",
"en_GI": "en_001",
"en_GM": "en_001",
"en_GY": "en_001",
"en_HK": "en_001",
"en_IE": "en_001",
"en_IL": "en_001",
"en_IM": "en_001",
"en_IN": "en_001",
"en_IO": "en_001",
"en_JE": "en_001",
"en_JM": "en_001",
"en_KE": "en_001",
"en_KI": "en_001",
"en_KN": "en_001",
"en_KY": "en_001",
"en_LC": "en_001",
"en_LR": "en_001",
"en_LS": "en_001",
"en_MG": "en_001",
"en_MO": "en_001",
"en_MS": "en_001",
"en_MT": "en_001",
"en_MU": "en_001",
"en_MV": "en_001",
"en_MW": "en_001",
"en_MY": "en_001",
"en_NA": "en_001",
"en_NF": "en_001",
"en_NG": "en_001",
"en_NL": "en_150",
"en_NR": "en_001",
"en_NU": "en_001",
"en_NZ": "en_001",
"en_PG": "en_001",
"en_PK": "en_001",
"en_PN": "en_001",
"en_PW": "en_001",
"en_RW": "en_001",
"en_SB": "en_001",
"en_SC": "en_001",
"en_SD": "en_001",
"en_SE": "en_150",
"en_SG": "en_001",
"en_SH": "en_001",
"en_SI": "en_150",
"en_SL": "en_001",
"en_SS": "en_001",
"en_SX": "en_001",
"en_SZ": "en_001",
"en_TC": "en_001",
"en_TK": "en_001",
"en_TO": "en_001",
"en_TT": "en_001",
"en_TV": "en_001",
"en_TZ": "en_001",
"en_UG": "en_001",
"en_VC": "en_001",
"en_VG": "en_001",
"en_VU": "en_001",
"en_WS": "en_001",
"en_ZA": "en_001",
"en_ZM": "en_001",
"en_ZW": "en_001",
"es_AR": "es_419",
"es_BO": "es_419",
"es_BR": "es_419",
"es_BZ": "es_419",
"es_CL": "es_419",
"es_CO": "es_419",
"es_CR": "es_419",
"es_CU": "es_419",
"es_DO": "es_419",
"es_EC": "es_419",
"es_GT": "es_419",
"es_HN": "es_419",
"es_MX": "es_419",
"es_NI": "es_419",
"es_PA": "es_419",
"es_PE": "es_419",
"es_PR": "es_419",
"es_PY": "es_419",
"es_SV": "es_419",
"es_US": "es_419",
"es_UY": "es_419",
"es_VE": "es_419",
"ff_Adlm": "root",
"hi_Latn": "en_IN",
"ks_Deva": "root",
"nb": "no",
"nn": "no",
"pa_Arab": "root",
"pt_AO": "pt_PT",
"pt_CH": "pt_PT",
"pt_CV": "pt_PT",
"pt_GQ": "pt_PT",
"pt_GW": "pt_PT",
"pt_LU": "pt_PT",
"pt_MO": "pt_PT",
"pt_MZ": "pt_PT",
"pt_ST": "pt_PT",
"pt_TL": "pt_PT",
"sd_Deva": "root",
"sr_Latn": "root",
"uz_Arab": "root",
"uz_Cyrl": "root",
"zh_Hant": "root",
"zh_Hant_MO": "zh_Hant_HK"
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation;
if (!\function_exists(t::class)) {
/**
* @author Nate Wiebe <nate@northern.co>
*/
function t(string $message, array $parameters = [], string $domain = null): TranslatableMessage
{
return new TranslatableMessage($message, $parameters, $domain);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\Translation\Dumper\XliffFileDumper;
use Symfony\Component\Translation\Exception\IncompleteDsnException;
use Symfony\Component\Translation\Exception\UnsupportedSchemeException;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\Provider\Dsn;
use Symfony\Component\Translation\Provider\ProviderFactoryInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* A test case to ease testing a translation provider factory.
*
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*
* @internal
*/
abstract class ProviderFactoryTestCase extends TestCase
{
protected $client;
protected $logger;
protected string $defaultLocale;
protected $loader;
protected $xliffFileDumper;
abstract public function createFactory(): ProviderFactoryInterface;
/**
* @return iterable<array{0: bool, 1: string}>
*/
abstract public function supportsProvider(): iterable;
/**
* @return iterable<array{0: string, 1: string, 2: TransportInterface}>
*/
abstract public function createProvider(): iterable;
/**
* @return iterable<array{0: string, 1: string|null}>
*/
public function unsupportedSchemeProvider(): iterable
{
return [];
}
/**
* @return iterable<array{0: string, 1: string|null}>
*/
public function incompleteDsnProvider(): iterable
{
return [];
}
/**
* @dataProvider supportsProvider
*/
public function testSupports(bool $expected, string $dsn)
{
$factory = $this->createFactory();
$this->assertSame($expected, $factory->supports(new Dsn($dsn)));
}
/**
* @dataProvider createProvider
*/
public function testCreate(string $expected, string $dsn)
{
$factory = $this->createFactory();
$provider = $factory->create(new Dsn($dsn));
$this->assertSame($expected, (string) $provider);
}
/**
* @dataProvider unsupportedSchemeProvider
*/
public function testUnsupportedSchemeException(string $dsn, string $message = null)
{
$factory = $this->createFactory();
$dsn = new Dsn($dsn);
$this->expectException(UnsupportedSchemeException::class);
if (null !== $message) {
$this->expectExceptionMessage($message);
}
$factory->create($dsn);
}
/**
* @dataProvider incompleteDsnProvider
*/
public function testIncompleteDsnException(string $dsn, string $message = null)
{
$factory = $this->createFactory();
$dsn = new Dsn($dsn);
$this->expectException(IncompleteDsnException::class);
if (null !== $message) {
$this->expectExceptionMessage($message);
}
$factory->create($dsn);
}
protected function getClient(): HttpClientInterface
{
return $this->client ??= new MockHttpClient();
}
protected function getLogger(): LoggerInterface
{
return $this->logger ??= $this->createMock(LoggerInterface::class);
}
protected function getDefaultLocale(): string
{
return $this->defaultLocale ??= 'en';
}
protected function getLoader(): LoaderInterface
{
return $this->loader ??= $this->createMock(LoaderInterface::class);
}
protected function getXliffFileDumper(): XliffFileDumper
{
return $this->xliffFileDumper ??= $this->createMock(XliffFileDumper::class);
}
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\Translation\Dumper\XliffFileDumper;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* A test case to ease testing a translation provider.
*
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*
* @internal
*/
abstract class ProviderTestCase extends TestCase
{
protected $client;
protected $logger;
protected string $defaultLocale;
protected $loader;
protected $xliffFileDumper;
abstract public function createProvider(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint): ProviderInterface;
/**
* @return iterable<array{0: string, 1: ProviderInterface}>
*/
abstract public function toStringProvider(): iterable;
/**
* @dataProvider toStringProvider
*/
public function testToString(ProviderInterface $provider, string $expected)
{
$this->assertSame($expected, (string) $provider);
}
protected function getClient(): MockHttpClient
{
return $this->client ??= new MockHttpClient();
}
protected function getLoader(): LoaderInterface
{
return $this->loader ??= $this->createMock(LoaderInterface::class);
}
protected function getLogger(): LoggerInterface
{
return $this->logger ??= $this->createMock(LoggerInterface::class);
}
protected function getDefaultLocale(): string
{
return $this->defaultLocale ??= 'en';
}
protected function getXliffFileDumper(): XliffFileDumper
{
return $this->xliffFileDumper ??= $this->createMock(XliffFileDumper::class);
}
}

View File

@@ -1,74 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Catalogue;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
abstract class AbstractOperationTest extends TestCase
{
public function testGetEmptyDomains()
{
$this->assertEquals(
array(),
$this->createOperation(
new MessageCatalogue('en'),
new MessageCatalogue('en')
)->getDomains()
);
}
public function testGetMergedDomains()
{
$this->assertEquals(
array('a', 'b', 'c'),
$this->createOperation(
new MessageCatalogue('en', array('a' => array(), 'b' => array())),
new MessageCatalogue('en', array('b' => array(), 'c' => array()))
)->getDomains()
);
}
public function testGetMessagesFromUnknownDomain()
{
$this->{method_exists($this, $_ = 'expectException') ? $_ : 'setExpectedException'}('InvalidArgumentException');
$this->createOperation(
new MessageCatalogue('en'),
new MessageCatalogue('en')
)->getMessages('domain');
}
public function testGetEmptyMessages()
{
$this->assertEquals(
array(),
$this->createOperation(
new MessageCatalogue('en', array('a' => array())),
new MessageCatalogue('en')
)->getMessages('a')
);
}
public function testGetEmptyResult()
{
$this->assertEquals(
new MessageCatalogue('en'),
$this->createOperation(
new MessageCatalogue('en'),
new MessageCatalogue('en')
)->getResult()
);
}
abstract protected function createOperation(MessageCatalogueInterface $source, MessageCatalogueInterface $target);
}

View File

@@ -1,83 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Catalogue;
use Symfony\Component\Translation\Catalogue\MergeOperation;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
class MergeOperationTest extends AbstractOperationTest
{
public function testGetMessagesFromSingleDomain()
{
$operation = $this->createOperation(
new MessageCatalogue('en', array('messages' => array('a' => 'old_a', 'b' => 'old_b'))),
new MessageCatalogue('en', array('messages' => array('a' => 'new_a', 'c' => 'new_c')))
);
$this->assertEquals(
array('a' => 'old_a', 'b' => 'old_b', 'c' => 'new_c'),
$operation->getMessages('messages')
);
$this->assertEquals(
array('c' => 'new_c'),
$operation->getNewMessages('messages')
);
$this->assertEquals(
array(),
$operation->getObsoleteMessages('messages')
);
}
public function testGetResultFromSingleDomain()
{
$this->assertEquals(
new MessageCatalogue('en', array(
'messages' => array('a' => 'old_a', 'b' => 'old_b', 'c' => 'new_c'),
)),
$this->createOperation(
new MessageCatalogue('en', array('messages' => array('a' => 'old_a', 'b' => 'old_b'))),
new MessageCatalogue('en', array('messages' => array('a' => 'new_a', 'c' => 'new_c')))
)->getResult()
);
}
public function testGetResultWithMetadata()
{
$leftCatalogue = new MessageCatalogue('en', array('messages' => array('a' => 'old_a', 'b' => 'old_b')));
$leftCatalogue->setMetadata('a', 'foo', 'messages');
$leftCatalogue->setMetadata('b', 'bar', 'messages');
$rightCatalogue = new MessageCatalogue('en', array('messages' => array('b' => 'new_b', 'c' => 'new_c')));
$rightCatalogue->setMetadata('b', 'baz', 'messages');
$rightCatalogue->setMetadata('c', 'qux', 'messages');
$mergedCatalogue = new MessageCatalogue('en', array('messages' => array('a' => 'old_a', 'b' => 'old_b', 'c' => 'new_c')));
$mergedCatalogue->setMetadata('a', 'foo', 'messages');
$mergedCatalogue->setMetadata('b', 'bar', 'messages');
$mergedCatalogue->setMetadata('c', 'qux', 'messages');
$this->assertEquals(
$mergedCatalogue,
$this->createOperation(
$leftCatalogue,
$rightCatalogue
)->getResult()
);
}
protected function createOperation(MessageCatalogueInterface $source, MessageCatalogueInterface $target)
{
return new MergeOperation($source, $target);
}
}

View File

@@ -1,82 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Catalogue;
use Symfony\Component\Translation\Catalogue\TargetOperation;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
class TargetOperationTest extends AbstractOperationTest
{
public function testGetMessagesFromSingleDomain()
{
$operation = $this->createOperation(
new MessageCatalogue('en', array('messages' => array('a' => 'old_a', 'b' => 'old_b'))),
new MessageCatalogue('en', array('messages' => array('a' => 'new_a', 'c' => 'new_c')))
);
$this->assertEquals(
array('a' => 'old_a', 'c' => 'new_c'),
$operation->getMessages('messages')
);
$this->assertEquals(
array('c' => 'new_c'),
$operation->getNewMessages('messages')
);
$this->assertEquals(
array('b' => 'old_b'),
$operation->getObsoleteMessages('messages')
);
}
public function testGetResultFromSingleDomain()
{
$this->assertEquals(
new MessageCatalogue('en', array(
'messages' => array('a' => 'old_a', 'c' => 'new_c'),
)),
$this->createOperation(
new MessageCatalogue('en', array('messages' => array('a' => 'old_a', 'b' => 'old_b'))),
new MessageCatalogue('en', array('messages' => array('a' => 'new_a', 'c' => 'new_c')))
)->getResult()
);
}
public function testGetResultWithMetadata()
{
$leftCatalogue = new MessageCatalogue('en', array('messages' => array('a' => 'old_a', 'b' => 'old_b')));
$leftCatalogue->setMetadata('a', 'foo', 'messages');
$leftCatalogue->setMetadata('b', 'bar', 'messages');
$rightCatalogue = new MessageCatalogue('en', array('messages' => array('b' => 'new_b', 'c' => 'new_c')));
$rightCatalogue->setMetadata('b', 'baz', 'messages');
$rightCatalogue->setMetadata('c', 'qux', 'messages');
$diffCatalogue = new MessageCatalogue('en', array('messages' => array('b' => 'old_b', 'c' => 'new_c')));
$diffCatalogue->setMetadata('b', 'bar', 'messages');
$diffCatalogue->setMetadata('c', 'qux', 'messages');
$this->assertEquals(
$diffCatalogue,
$this->createOperation(
$leftCatalogue,
$rightCatalogue
)->getResult()
);
}
protected function createOperation(MessageCatalogueInterface $source, MessageCatalogueInterface $target)
{
return new TargetOperation($source, $target);
}
}

View File

@@ -1,150 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\DataCollector;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Component\Translation\DataCollector\TranslationDataCollector;
class TranslationDataCollectorTest extends TestCase
{
protected function setUp()
{
if (!class_exists('Symfony\Component\HttpKernel\DataCollector\DataCollector')) {
$this->markTestSkipped('The "DataCollector" is not available');
}
}
public function testCollectEmptyMessages()
{
$translator = $this->getTranslator();
$translator->expects($this->any())->method('getCollectedMessages')->will($this->returnValue(array()));
$dataCollector = new TranslationDataCollector($translator);
$dataCollector->lateCollect();
$this->assertEquals(0, $dataCollector->getCountMissings());
$this->assertEquals(0, $dataCollector->getCountFallbacks());
$this->assertEquals(0, $dataCollector->getCountDefines());
$this->assertEquals(array(), $dataCollector->getMessages()->getValue());
}
public function testCollect()
{
$collectedMessages = array(
array(
'id' => 'foo',
'translation' => 'foo (en)',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_DEFINED,
'parameters' => array(),
'transChoiceNumber' => null,
),
array(
'id' => 'bar',
'translation' => 'bar (fr)',
'locale' => 'fr',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK,
'parameters' => array(),
'transChoiceNumber' => null,
),
array(
'id' => 'choice',
'translation' => 'choice',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_MISSING,
'parameters' => array('%count%' => 3),
'transChoiceNumber' => 3,
),
array(
'id' => 'choice',
'translation' => 'choice',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_MISSING,
'parameters' => array('%count%' => 3),
'transChoiceNumber' => 3,
),
array(
'id' => 'choice',
'translation' => 'choice',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_MISSING,
'parameters' => array('%count%' => 4, '%foo%' => 'bar'),
'transChoiceNumber' => 4,
),
);
$expectedMessages = array(
array(
'id' => 'foo',
'translation' => 'foo (en)',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_DEFINED,
'count' => 1,
'parameters' => array(),
'transChoiceNumber' => null,
),
array(
'id' => 'bar',
'translation' => 'bar (fr)',
'locale' => 'fr',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK,
'count' => 1,
'parameters' => array(),
'transChoiceNumber' => null,
),
array(
'id' => 'choice',
'translation' => 'choice',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_MISSING,
'count' => 3,
'parameters' => array(
array('%count%' => 3),
array('%count%' => 3),
array('%count%' => 4, '%foo%' => 'bar'),
),
'transChoiceNumber' => 3,
),
);
$translator = $this->getTranslator();
$translator->expects($this->any())->method('getCollectedMessages')->will($this->returnValue($collectedMessages));
$dataCollector = new TranslationDataCollector($translator);
$dataCollector->lateCollect();
$this->assertEquals(1, $dataCollector->getCountMissings());
$this->assertEquals(1, $dataCollector->getCountFallbacks());
$this->assertEquals(1, $dataCollector->getCountDefines());
$this->assertEquals($expectedMessages, array_values($dataCollector->getMessages()->getValue(true)));
}
private function getTranslator()
{
$translator = $this
->getMockBuilder('Symfony\Component\Translation\DataCollectorTranslator')
->disableOriginalConstructor()
->getMock()
;
return $translator;
}
}

View File

@@ -1,92 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Component\Translation\Loader\ArrayLoader;
class DataCollectorTranslatorTest extends TestCase
{
public function testCollectMessages()
{
$collector = $this->createCollector();
$collector->setFallbackLocales(array('fr', 'ru'));
$collector->trans('foo');
$collector->trans('bar');
$collector->transChoice('choice', 0);
$collector->trans('bar_ru');
$collector->trans('bar_ru', array('foo' => 'bar'));
$expectedMessages = array();
$expectedMessages[] = array(
'id' => 'foo',
'translation' => 'foo (en)',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_DEFINED,
'parameters' => array(),
'transChoiceNumber' => null,
);
$expectedMessages[] = array(
'id' => 'bar',
'translation' => 'bar (fr)',
'locale' => 'fr',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK,
'parameters' => array(),
'transChoiceNumber' => null,
);
$expectedMessages[] = array(
'id' => 'choice',
'translation' => 'choice',
'locale' => 'en',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_MISSING,
'parameters' => array(),
'transChoiceNumber' => 0,
);
$expectedMessages[] = array(
'id' => 'bar_ru',
'translation' => 'bar (ru)',
'locale' => 'ru',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK,
'parameters' => array(),
'transChoiceNumber' => null,
);
$expectedMessages[] = array(
'id' => 'bar_ru',
'translation' => 'bar (ru)',
'locale' => 'ru',
'domain' => 'messages',
'state' => DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK,
'parameters' => array('foo' => 'bar'),
'transChoiceNumber' => null,
);
$this->assertEquals($expectedMessages, $collector->getCollectedMessages());
}
private function createCollector()
{
$translator = new Translator('en');
$translator->addLoader('array', new ArrayLoader());
$translator->addResource('array', array('foo' => 'foo (en)'), 'en');
$translator->addResource('array', array('bar' => 'bar (fr)'), 'fr');
$translator->addResource('array', array('bar_ru' => 'bar (ru)'), 'ru');
return new DataCollectorTranslator($translator);
}
}

View File

@@ -1,30 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Dumper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Dumper\CsvFileDumper;
class CsvFileDumperTest extends TestCase
{
public function testFormatCatalogue()
{
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar', 'bar' => 'foo
foo', 'foo;foo' => 'bar'));
$dumper = new CsvFileDumper();
$this->assertStringEqualsFile(__DIR__.'/../fixtures/valid.csv', $dumper->formatCatalogue($catalogue, 'messages'));
}
}

View File

@@ -1,87 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Dumper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Dumper\FileDumper;
class FileDumperTest extends TestCase
{
public function testDump()
{
$tempDir = sys_get_temp_dir();
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new ConcreteFileDumper();
$dumper->dump($catalogue, array('path' => $tempDir));
$this->assertFileExists($tempDir.'/messages.en.concrete');
}
/**
* @group legacy
*/
public function testDumpBackupsFileIfExisting()
{
$tempDir = sys_get_temp_dir();
$file = $tempDir.'/messages.en.concrete';
$backupFile = $file.'~';
@touch($file);
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new ConcreteFileDumper();
$dumper->dump($catalogue, array('path' => $tempDir));
$this->assertFileExists($backupFile);
@unlink($file);
@unlink($backupFile);
}
public function testDumpCreatesNestedDirectoriesAndFile()
{
$tempDir = sys_get_temp_dir();
$translationsDir = $tempDir.'/test/translations';
$file = $translationsDir.'/messages.en.concrete';
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new ConcreteFileDumper();
$dumper->setRelativePathTemplate('test/translations/%domain%.%locale%.%extension%');
$dumper->dump($catalogue, array('path' => $tempDir));
$this->assertFileExists($file);
@unlink($file);
@rmdir($translationsDir);
}
}
class ConcreteFileDumper extends FileDumper
{
public function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array())
{
return '';
}
protected function getExtension()
{
return 'concrete';
}
}

View File

@@ -1,29 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Dumper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Dumper\IcuResFileDumper;
class IcuResFileDumperTest extends TestCase
{
public function testFormatCatalogue()
{
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new IcuResFileDumper();
$this->assertStringEqualsFile(__DIR__.'/../fixtures/resourcebundle/res/en.res', $dumper->formatCatalogue($catalogue, 'messages'));
}
}

View File

@@ -1,29 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Dumper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Dumper\IniFileDumper;
class IniFileDumperTest extends TestCase
{
public function testFormatCatalogue()
{
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new IniFileDumper();
$this->assertStringEqualsFile(__DIR__.'/../fixtures/resources.ini', $dumper->formatCatalogue($catalogue, 'messages'));
}
}

View File

@@ -1,39 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Dumper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Dumper\JsonFileDumper;
class JsonFileDumperTest extends TestCase
{
public function testFormatCatalogue()
{
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new JsonFileDumper();
$this->assertStringEqualsFile(__DIR__.'/../fixtures/resources.json', $dumper->formatCatalogue($catalogue, 'messages'));
}
public function testDumpWithCustomEncoding()
{
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => '"bar"'));
$dumper = new JsonFileDumper();
$this->assertStringEqualsFile(__DIR__.'/../fixtures/resources.dump.json', $dumper->formatCatalogue($catalogue, 'messages', array('json_encoding' => JSON_HEX_QUOT)));
}
}

View File

@@ -1,29 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Dumper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Dumper\MoFileDumper;
class MoFileDumperTest extends TestCase
{
public function testFormatCatalogue()
{
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new MoFileDumper();
$this->assertStringEqualsFile(__DIR__.'/../fixtures/resources.mo', $dumper->formatCatalogue($catalogue, 'messages'));
}
}

View File

@@ -1,29 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Tests\Dumper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Dumper\PhpFileDumper;
class PhpFileDumperTest extends TestCase
{
public function testFormatCatalogue()
{
$catalogue = new MessageCatalogue('en');
$catalogue->add(array('foo' => 'bar'));
$dumper = new PhpFileDumper();
$this->assertStringEqualsFile(__DIR__.'/../fixtures/resources.php', $dumper->formatCatalogue($catalogue, 'messages'));
}
}

Some files were not shown because too many files have changed in this diff Show More