Commit 1d50852a authored by catch's avatar catch

Issue #2570107 by alexpott: Make format_plural() return a...

Issue #2570107 by alexpott: Make format_plural() return a PluralTranslatableString object to remove reliance on a static, unpredictable safe list
parent b9ae4cd1
......@@ -33,6 +33,7 @@
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Routing\GeneratorNotInitializedException;
use Drupal\Core\StringTranslation\PluralTranslatableString;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\Element;
......@@ -142,11 +143,11 @@
/**
* The delimiter used to split plural strings.
*
* This is the ETX (End of text) character and is used as a minimal means to
* separate singular and plural variants in source and translation text. It
* was found to be the most compatible delimiter for the supported databases.
* @deprecated in Drupal 8.0.x-dev, will be removed before Drupal 9.0.0.
* Use \Drupal\Core\StringTranslation\PluralTranslatableString::DELIMITER
* instead.
*/
const LOCALE_PLURAL_DELIMITER = "\03";
const LOCALE_PLURAL_DELIMITER = PluralTranslatableString::DELIMITER;
/**
* Prepares a 'destination' URL query parameter for use with url().
......
<?php
/**
* @file
* Contains \Drupal\Core\StringTranslation\PluralTranslatableString.
*/
namespace Drupal\Core\StringTranslation;
use Drupal\Component\Utility\PlaceholderTrait;
use Drupal\Component\Utility\SafeMarkup;
/**
* A class to hold plural translatable strings.
*/
class PluralTranslatableString extends TranslatableString {
use PlaceholderTrait;
/**
* The delimiter used to split plural strings.
*
* This is the ETX (End of text) character and is used as a minimal means to
* separate singular and plural variants in source and translation text. It
* was found to be the most compatible delimiter for the supported databases.
*/
const DELIMITER = "\03";
/**
* The item count to display.
*
* @var int
*/
protected $count;
/**
* The already translated string.
*
* @var string
*/
protected $translatedString;
/**
* A bool that statically caches whether locale_get_plural() exists.
*
* @var bool
*/
protected static $localeEnabled;
/**
* Constructs a new PluralTranslatableString object.
*
* Parses values passed into this class through the format_plural() function
* in Drupal and handles an optional context for the string.
*
* @param int $count
* The item count to display.
* @param string $singular
* The string for the singular case. Make sure it is clear this is singular,
* to ease translation (e.g. use "1 new comment" instead of "1 new"). Do not
* use @count in the singular string.
* @param string $plural
* The string for the plural case. Make sure it is clear this is plural, to
* ease translation. Use @count in place of the item count, as in
* "@count new comments".
* @param array $args
* (optional) An associative array of replacements to make after
* translation. Instances of any key in this array are replaced with the
* corresponding value. Based on the first character of the key, the value
* is escaped and/or themed. See
* \Drupal\Component\Utility\SafeMarkup::format(). Note that you do not need
* to include @count in this array; this replacement is done automatically
* for the plural cases.
* @param array $options
* (optional) An associative array of additional options. See t() for
* allowed keys.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* (optional) The string translation service.
*/
public function __construct($count, $singular, $plural, array $args = [], array $options = [], TranslationInterface $string_translation = NULL) {
$this->count = $count;
$translatable_string = implode(static::DELIMITER, array($singular, $plural));
parent::__construct($translatable_string, $args, $options, $string_translation);
}
/**
* Constructs a new class instance from an already translated string.
*
* This method ensures that the string is pluralized correctly. As opposed
* to the __construct() method, this method is designed to be invoked with
* a string already translated (such as with configuration translation).
*
* @param int $count
* The item count to display.
* @param string $translated_string
* The already translated string.
* @param array $args
* An associative array of replacements to make after translation. Instances
* of any key in this array are replaced with the corresponding value.
* Based on the first character of the key, the value is escaped and/or
* themed. See \Drupal\Component\Utility\SafeMarkup::format(). Note that you
* do not need to include @count in this array; this replacement is done
* automatically for the plural cases.
* @param array $options
* An associative array of additional options. See t() for allowed keys.
*
* @return \Drupal\Core\StringTranslation\PluralTranslatableString
* A PluralTranslatableString object.
*/
public static function createFromTranslatedString($count, $translated_string, array $args = [], array $options = []) {
$safe = TRUE;
foreach (array_keys($args) as $arg_key) {
// If the string has arguments that start with '!' we consider it unsafe
// and return the translation as a string for backward compatibility
// purposes.
// @todo https://www.drupal.org/node/2570037 remove this temporary
// workaround.
if (0 === strpos($arg_key, '!') && !SafeMarkup::isSafe($args[$arg_key])) {
$safe = FALSE;
break;
}
}
$plural = new static($count, '', '', $args, $options);
$plural->translatedString = $translated_string;
return $safe ? $plural : (string) $plural;
}
/**
* Renders the object as a string.
*
* @return string
* The translated string.
*/
public function render() {
if (!$this->translatedString) {
$this->translatedString = $this->getStringTranslation()->translateString($this);
}
if ($this->translatedString === '') {
return '';
}
$arguments = $this->getArguments();
$arguments['@count'] = $this->count;
$translated_array = explode(static::DELIMITER, $this->translatedString);
if ($this->count == 1) {
return $this->placeholderFormat($translated_array[0], $arguments);
}
$index = $this->getPluralIndex();
if ($index == 0) {
// Singular form.
$return = $translated_array[0];
}
else {
if (isset($translated_array[$index])) {
// N-th plural form.
$return = $translated_array[$index];
}
else {
// If the index cannot be computed or there's no translation, use the
// second plural form as a fallback (which allows for most flexibility
// with the replaceable @count value).
$return = $translated_array[1];
}
}
return $this->placeholderFormat($return, $arguments);
}
/**
* Gets the plural index through the gettext formula.
*
* @return int
*/
protected function getPluralIndex() {
if (!isset(static::$localeEnabled)) {
static::$localeEnabled = function_exists('locale_get_plural');
}
if (function_exists('locale_get_plural')) {
return locale_get_plural($this->count, $this->getOption('langcode'));
}
return -1;
}
}
......@@ -54,9 +54,10 @@ public function translateString(TranslatableString $translated_string);
/**
* Formats a string containing a count of items.
*
* This function ensures that the string is pluralized correctly. Since t() is
* called by this function, make sure not to pass already-localized strings to
* it. See formatPluralTranslated() for that.
* This function ensures that the string is pluralized correctly. Since
* TranslationInterface::translate() is called by this function, make sure not
* to pass already-localized strings to it. See
* PluralTranslatableString::createFromTranslatedString() for that.
*
* For example:
* @code
......@@ -91,50 +92,16 @@ public function translateString(TranslatableString $translated_string);
* @param array $options
* An associative array of additional options. See t() for allowed keys.
*
* @return string
* @return \Drupal\Core\StringTranslation\PluralTranslatableString
* A translated string.
*
* @see self::translate()
* @see \Drupal\Core\StringTranslation\TranslationInterface::translate()
* @see t()
* @see \Drupal\Component\Utility\SafeMarkup::format()
* @see self::formatPluralTranslated
* @see \Drupal\Core\StringTranslation\PluralTranslatableString::createFromTranslatedString()
*/
public function formatPlural($count, $singular, $plural, array $args = array(), array $options = array());
/**
* Formats an already translated string containing a count of items.
*
* This function ensures that the string is pluralized correctly. As opposed
* to the formatPlural() method, this method is designed to be invoked with
* a string already translated (such as with configuration translation).
*
* @param int $count
* The item count to display.
* @param string $translation
* The string containing the translation of a singular/plural pair. It may
* contain any number of possible variants (depending on the language
* translated to) separated by the value of the LOCALE_PLURAL_DELIMITER
* constant.
* @param array $args
* Associative array of replacements to make in the translation. Instances
* of any key in this array are replaced with the corresponding value.
* Based on the first character of the key, the value is escaped and/or
* themed. See \Drupal\Component\Utility\SafeMarkup::format(). Note that you do
* not need to include @count in this array; this replacement is done
* automatically for the plural cases.
* @param array $options
* An associative array of additional options. The 'context' key is not
* supported because the passed string is already translated. Use the
* 'langcode' key to ensure the proper plural logic is used.
*
* @return string
* The correct substring for the given $count with $args replaced.
*
* @see self::formatPlural()
* @see \Drupal\Component\Utility\SafeMarkup::format()
*/
public function formatPluralTranslated($count, $translation, array $args = array(), array $options = array());
/**
* Returns the number of plurals supported by a given language.
*
......
......@@ -191,43 +191,20 @@ protected function doTranslate($string, array $options = array()) {
* {@inheritdoc}
*/
public function formatPlural($count, $singular, $plural, array $args = array(), array $options = array()) {
$translatable_string = implode(LOCALE_PLURAL_DELIMITER, array($singular, $plural));
$translated_strings = $this->doTranslate($translatable_string, $options);
return $this->formatPluralTranslated($count, $translated_strings, $args, $options);
}
/**
* {@inheritdoc}
*/
public function formatPluralTranslated($count, $translation, array $args = array(), array $options = array()) {
$args['@count'] = $count;
$translated_array = explode(LOCALE_PLURAL_DELIMITER, $translation);
if ($count == 1) {
return SafeMarkup::format($translated_array[0], $args);
}
// Get the plural index through the gettext formula.
// @todo implement static variable to minimize function_exists() usage.
$index = (function_exists('locale_get_plural')) ? locale_get_plural($count, isset($options['langcode']) ? $options['langcode'] : NULL) : -1;
if ($index == 0) {
// Singular form.
$return = $translated_array[0];
}
else {
if (isset($translated_array[$index])) {
// N-th plural form.
$return = $translated_array[$index];
}
else {
// If the index cannot be computed or there's no translation, use
// the second plural form as a fallback (which allows for most flexibility
// with the replaceable @count value).
$return = $translated_array[1];
$safe = TRUE;
foreach (array_keys($args) as $arg_key) {
// If the string has arguments that start with '!' we consider it unsafe
// and return the translation as a string for backward compatibility
// purposes.
// @todo https://www.drupal.org/node/2570037 remove this temporary
// workaround.
if (0 === strpos($arg_key, '!') && !SafeMarkup::isSafe($args[$arg_key])) {
$safe = FALSE;
break;
}
}
return SafeMarkup::format($return, $args);
$plural = new PluralTranslatableString($count, $singular, $plural, $args, $options, $this);
return $safe ? $plural : (string) $plural;
}
/**
......
......@@ -7,6 +7,7 @@
namespace Drupal\locale\Tests;
use Drupal\Core\StringTranslation\PluralTranslatableString;
use Drupal\simpletest\WebTestBase;
/**
......@@ -130,12 +131,15 @@ public function testGetPluralFormat() {
// expected index as per the logic for translation lookups.
$expected_plural_index = ($count == 1) ? 0 : $expected_plural_index;
$expected_plural_string = str_replace('@count', $count, $plural_strings[$langcode][$expected_plural_index]);
$this->assertIdentical(\Drupal::translation()->formatPlural($count, '1 hour', '@count hours', array(), array('langcode' => $langcode)), $expected_plural_string, 'Plural translation of 1 hours / @count hours for count ' . $count . ' in ' . $langcode . ' is ' . $expected_plural_string);
// DO NOT use translation to pass into formatPluralTranslated() this
// way. It is designed to be used with *already* translated text like
// settings from configuration. We use PHP translation here just because
// we have the expected result data in that format.
$this->assertIdentical(\Drupal::translation()->formatPluralTranslated($count, \Drupal::translation()->translate('1 hour' . LOCALE_PLURAL_DELIMITER . '@count hours', array(), array('langcode' => $langcode)), array(), array('langcode' => $langcode)), $expected_plural_string, 'Translated plural lookup of 1 hours / @count hours for count ' . $count . ' in ' . $langcode . ' is ' . $expected_plural_string);
$this->assertIdentical(\Drupal::translation()->formatPlural($count, '1 hour', '@count hours', array(), array('langcode' => $langcode))->render(), $expected_plural_string, 'Plural translation of 1 hours / @count hours for count ' . $count . ' in ' . $langcode . ' is ' . $expected_plural_string);
// DO NOT use translation to pass translated strings into
// PluralTranslatableString::createFromTranslatedString() this way. It
// is designed to be used with *already* translated text like settings
// from configuration. We use PHP translation here just because we have
// the expected result data in that format.
$translated_string = \Drupal::translation()->translate('1 hour' . PluralTranslatableString::DELIMITER . '@count hours', array(), array('langcode' => $langcode));
$plural = PluralTranslatableString::createFromTranslatedString($count, $translated_string, array(), array('langcode' => $langcode));
$this->assertIdentical($plural->render(), $expected_plural_string);
}
}
}
......@@ -223,7 +227,7 @@ public function testPluralEditExport() {
// langcode here because the language will be English by default and will
// not save our source string for performance optimization if we do not ask
// specifically for a language.
\Drupal::translation()->formatPlural(1, '1 day', '@count days', array(), array('langcode' => 'fr'));
\Drupal::translation()->formatPlural(1, '1 day', '@count days', array(), array('langcode' => 'fr'))->render();
$lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = ''", array(':source' => "1 day" . LOCALE_PLURAL_DELIMITER . "@count days"))->fetchField();
// Look up editing page for this plural string and check fields.
$search = array(
......
......@@ -8,6 +8,7 @@
namespace Drupal\views\Plugin\views\field;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\PluralTranslatableString;
use Drupal\views\ResultRow;
/**
......@@ -174,7 +175,7 @@ public function render(ResultRow $values) {
// If we should format as plural, take the (possibly) translated plural
// setting and format with the current language.
if (!empty($this->options['format_plural'])) {
$value = $this->formatPluralTranslated($value, $this->options['format_plural_string']);
$value = PluralTranslatableString::createFromTranslatedString($value, $this->options['format_plural_string']);
}
return $this->sanitizeValue($this->options['prefix'], 'xss')
......
......@@ -139,14 +139,14 @@ public function form(array $form, FormStateInterface $form_state) {
'#account' => $this->entityManager->getStorage('user')->load($view->lock->owner),
);
$lock_message_substitutions = array(
'!user' => drupal_render($username),
'!age' => $this->dateFormatter->formatTimeDiffSince($view->lock->updated),
'@user' => drupal_render($username),
'@age' => $this->dateFormatter->formatTimeDiffSince($view->lock->updated),
'@url' => $view->url('break-lock-form'),
);
$form['locked'] = array(
'#type' => 'container',
'#attributes' => array('class' => array('view-locked', 'messages', 'messages--warning')),
'#children' => $this->t('This view is being edited by user !user, and is therefore locked from editing by others. This lock is !age old. Click here to <a href="@url">break this lock</a>.', $lock_message_substitutions),
'#children' => $this->t('This view is being edited by user @user, and is therefore locked from editing by others. This lock is @age old. Click here to <a href="@url">break this lock</a>.', $lock_message_substitutions),
'#weight' => -10,
);
}
......
......@@ -5,7 +5,7 @@
* Contains \Drupal\Tests\Core\StringTranslation\TranslationManagerTest.
*/
namespace Drupal\Tests\Core\StringTranslation {
namespace Drupal\Tests\Core\StringTranslation;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Component\Utility\SafeStringInterface;
......@@ -106,11 +106,3 @@ public function __construct() {
}
}
}
namespace {
if (!defined('LOCALE_PLURAL_DELIMITER')) {
define('LOCALE_PLURAL_DELIMITER', "\03");
}
}
......@@ -14,6 +14,8 @@
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Component\Utility\PlaceholderTrait;
use Drupal\Core\StringTranslation\TranslatableString;
use Drupal\Core\StringTranslation\PluralTranslatableString;
/**
* Provides a base class and helpers for Drupal unit tests.
......@@ -231,8 +233,9 @@ public function getStringTranslationStub() {
});
$translation->expects($this->any())
->method('formatPlural')
->willReturnCallback(function ($count, $singular, $plural, array $args = [], array $options = []) {
return $count === 1 ? SafeMarkup::format($singular, $args) : SafeMarkup::format($plural, $args + ['@count' => $count]);
->willReturnCallback(function ($count, $singular, $plural, array $args = [], array $options = []) use ($translation) {
$wrapper = new PluralTranslatableString($count, $singular, $plural, $args, $options, $translation);
return $wrapper;
});
return $translation;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment