diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml index 9553d4d60d7ce..4369a5549f367 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml @@ -113,6 +113,13 @@ + + + + + + + diff --git a/src/Symfony/Component/Translation/Command/IntlConvertCommand.php b/src/Symfony/Component/Translation/Command/IntlConvertCommand.php new file mode 100644 index 0000000000000..745881c634273 --- /dev/null +++ b/src/Symfony/Component/Translation/Command/IntlConvertCommand.php @@ -0,0 +1,96 @@ + + * + * 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\Command\Command; +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\HttpKernel\KernelInterface; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Reader\TranslationReaderInterface; +use Symfony\Component\Translation\Util\IntlMessageConverter; +use Symfony\Component\Translation\Writer\TranslationWriterInterface; + +/** + * Convert to Intl styled message format. + * + * @author Tobias Nyholm + */ +class IntlConvertCommand extends Command +{ + protected static $defaultName = 'translation:convert-to-intl-messages'; + + private $writer; + private $reader; + + public function __construct(TranslationWriterInterface $writer, TranslationReaderInterface $reader) + { + parent::__construct(); + + $this->writer = $writer; + $this->reader = $reader; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setDescription('Convert from Symfony 3 plural format to ICU message format.') + ->addArgument('locale', InputArgument::REQUIRED, 'The locale') + ->addArgument('path', null, 'A file or a directory') + ->addOption('domain', null, InputOption::VALUE_OPTIONAL, 'The messages domain') + ->addOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + $path = $input->getArgument('path'); + $locale = $input->getArgument('locale'); + $domain = $input->getOption('domain'); + /** @var KernelInterface $kernel */ + $kernel = $this->getApplication()->getKernel(); + + // Define Root Paths + $transPaths = $kernel->getProjectDir().\DIRECTORY_SEPARATOR.'translations'; + if (null !== $path) { + $transPaths = $path; + } + + // load any existing messages from the translation files + $currentCatalogue = new MessageCatalogue($locale); + if (!is_dir($transPaths)) { + throw new \LogicException('The "path" must be a directory.'); + } + $this->reader->read($transPaths, $currentCatalogue); + + $allMessages = $currentCatalogue->all($domain); + if (null !== $domain) { + $allMessages = array($domain => $allMessages); + } + + $updated = array(); + foreach ($allMessages as $messageDomain => $messages) { + foreach ($messages as $key => $message) { + $updated[$messageDomain][$key] = IntlMessageConverter::convert($message); + } + } + + $this->writer->write(new MessageCatalogue($locale, $updated), $input->getOption('output-format'), array('path' => $transPaths)); + } +} diff --git a/src/Symfony/Component/Translation/Tests/Util/IntlMessageConverterTest.php b/src/Symfony/Component/Translation/Tests/Util/IntlMessageConverterTest.php new file mode 100644 index 0000000000000..7aef95df822b8 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Util/IntlMessageConverterTest.php @@ -0,0 +1,96 @@ +assertEquals($output, $result); + } + + public function testConvertWithCustomDelimiter() + { + $result = IntlMessageConverter::convert('Foo #var# bar', '#'); + $this->assertEquals('Foo {var} bar', $result); + + $result = IntlMessageConverter::convert('{0} Foo #var# bar | {1} Bar #var# foo', '#'); + $this->assertEquals( + <<expectException(\LogicException::class); + IntlMessageConverter::convert(']-Inf, -2[ Negative|]1,Inf[ Positive'); + } + + public function getTestData() + { + yield array('|', '|'); + yield array( + '{0} There are no apples|{1} There is one apple|]1,Inf[ There %name% are %count% apples', + << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Util; + +/** + * Convert from Symfony 3's plural syntax to Intl message format. + * {@link https://messageformat.github.io/messageformat/page-guide}. + * + * @author Tobias Nyholm + */ +class IntlMessageConverter +{ + public static function convert(string $message, string $variableDelimiter = '%'): string + { + $array = self::getMessageArray($message); + if (empty($array)) { + return $message; + } + + if (1 === \count($array) && isset($array[0])) { + return self::replaceVariables($message, $variableDelimiter); + } + + $icu = self::buildIcuString($array, $variableDelimiter); + + return $icu; + } + + /** + * Get an ICU like array. + */ + private static function getMessageArray(string $message): array + { + if (preg_match('/^\|++$/', $message)) { + // If the message only contains pipes ("|||") + return array(); + } elseif (preg_match_all('/(?:\|\||[^\|])++/', $message, $matches)) { + $parts = $matches[0]; + } else { + throw new \LogicException(sprintf('Input string "%s" is not supported.', $message)); + } + + $intervalRegexp = <<<'EOF' +/^(?P + ({\s* + (\-?\d+(\.\d+)?[\s*,\s*\-?\d+(\.\d+)?]*) + \s*}) + + | + + (?P[\[\]]) + \s* + (?P-Inf|\-?\d+(\.\d+)?) + \s*,\s* + (?P\+?Inf|\-?\d+(\.\d+)?) + \s* + (?P[\[\]]) +)\s*(?P.*?)$/xs +EOF; + + $standardRules = array(); + foreach ($parts as $part) { + $part = trim(str_replace('||', '|', $part)); + + // try to match an explicit rule, then fallback to the standard ones + if (preg_match($intervalRegexp, $part, $matches)) { + if ($matches[2]) { + foreach (explode(',', $matches[3]) as $n) { + $standardRules['='.$n] = $matches['message']; + } + } else { + $leftNumber = '-Inf' === $matches['left'] ? -INF : (float) $matches['left']; + $rightNumber = \is_numeric($matches['right']) ? (float) $matches['right'] : INF; + + $leftNumber = ('[' === $matches['left_delimiter'] ? $leftNumber : 1 + $leftNumber); + $rightNumber = (']' === $matches['right_delimiter'] ? 1 + $rightNumber : $rightNumber); + + if ($leftNumber !== -INF && INF !== $rightNumber) { + for ($i = $leftNumber; $i < $rightNumber; ++$i) { + $standardRules['='.$i] = $matches['message']; + } + } else { + // $rightNumber is INF or $leftNumber is -INF + if (isset($standardRules['other'])) { + throw new \LogicException(sprintf('%s does not support converting messages with both "-Inf" and "Inf". Message: "%s"', __CLASS__, $message)); + } + $standardRules['other'] = $matches['message']; + } + } + } elseif (preg_match('/^\w+\:\s*(.*?)$/', $part, $matches)) { + $standardRules[] = $matches[1]; + } else { + $standardRules[] = $part; + } + } + + return $standardRules; + } + + private static function buildIcuString(array $data, string $variableDelimiter): string + { + $icu = "{ COUNT, plural,\n"; + foreach ($data as $key => $message) { + $message = strtr($message, array('%count%' => '#')); + $message = self::replaceVariables($message, $variableDelimiter); + $icu .= sprintf(" %s {%s}\n", $key, $message); + } + $icu .= '}'; + + return $icu; + } + + private static function replaceVariables(string $message, string $variableDelimiter): string + { + $regex = sprintf('|%s(.*?)%s|s', $variableDelimiter, $variableDelimiter); + + return preg_replace($regex, '{$1}', $message); + } +}