Skip to content

Loading…

[2.2] [Translation] added support for translations depending on gender and number of variables #4884

Closed
wants to merge 3 commits into from

4 participants

@sroddy

Bug fix: no
Feature addition: yes
Backwards compatibility break: no
Symfony2 tests pass: yes
References: #4797
Fixes the following tickets: -
License of the code: MIT

This improvement adds to the translator component the possibility to pass variables specifying the gender and the number. This enables the translators to handle all the specificities of their own language. With an easy to understand but strict syntax, I've managed not to break the BC, while adding a very small footprint in the trans method (due to a single validation regexp which is anyways improvable).

This the syntax ref of a translated message:

%variable% [gender][plural_position]: String

Where
%variable% is any kind of string (all characters except spaces and |),
[gender] is one of the following: m n f (male, female, neuter)
[plural_position] is an integer that follows the same convetions of the pluralization rules that you can find in Symfony\Component\Translation\PluralizationRules
String is any kind of string (except the | sign)

You can specify (and it's highly reccomended) a generic translation as the first position (without using any specific syntax).
This will be returned in case no other translation matches the given parameters.

Some examples follow:
%user% wrote on %friend%'s wall for his/her birthday | %friend% f: %user% wrote on %friend%'s wall for her birthday | %friend% m: %user% wrote on %friend%'s wall for his birthday

You are a very good boy! | %user% m1: You are very good boys! | %user% f0: You are a very good girl! | %user% 1f: You are very good girls!

I'd like to hear some feedbacks about this implementation so I can adjust what you think is wrong and then write the documentation.

@stof stof commented on an outdated diff
src/Symfony/Component/Translation/MessageSelector.php
((21 lines not shown))
+ * You can specify (and it's highly reccomended) a generic translation as the first position (without using any specific syntax).
+ * This will be returned in case no other translation matches the given parameters.
+ *
+ * Some examples follow:
+ * %user% wrote on %friend%'s wall for his/her birthday | %friend% f: %user% wrote on %friend%'s wall for her birthday | %friend% m: %user% wrote on %friend%'s wall for his birthday
+ * You are a very good boy! | %user% m1: You are very good boys! | %user% f0: You are a very good girl! | %user% 1f: You are very good girls!
+ *
+ * @param string $message The message being translated
+ * @param array $parameters The variables on which the message depends.
+ * @param string $locale The locale to use for choosing
+ *
+ * @return string
+ *
+ * @throws InvalidArgumentException
+ *
+ * @api
@stof Symfony member
stof added a note

you should not tag is as @api as it is not part of the stable api (it is not even part of the codebase yet)

@sroddy
sroddy added a note

sorry, it was a wrong copy-paste :) fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Stefano Rodr... added some commits
@sroddy

@fabpot what do you think about this feature? I'm already using it heavely in my current installation and I personally think it's a far better approach compared to the transchoice method because that method has to be invoked by the programmer and not by the translator, as this one.

@guilhermeblanco

After some discussion with @sroddy, we throught it would be better if we strictly follow the pluralization rules defined by unicode.org: http://unicode.org/repos/cldr-tmp/trunk/charts/supplemental/language_plural_rules.html
That way, it would not be more 0, 1, etc, but actually, zero, one, two, few, many and other.
Considering that we also have genders as: male and female and neutral for example, we could update the translation keys to a standardized solution, like:

variable[gender][pluralization]

That way, we could do something like:

$translation = implode("|", array(
"aboutMyMatch[male][one]: About him",
"aboutMyMatch[male][other]: About them",
"aboutMyMatch[female][one]: About her",
));

We also consider defining only one or none of the elements, like only defining gender, but not pluralization, or defining only the pluralization (which would consider the gender as neutral). For most of the pluralization rules, the plural goes into a male translation. But it would be up to the developer to define how the genders should be named. For example:

thing[cat][one]: Foo

As an ability to make it flexible, developers would be able to define anything except the plural rules names and the | sign. Finally, they give a TranslatableInterface, which would require the methods getGender and getNumber to be implemented. These two methods would be used internally of Translation component to retrieve the correct elements and find the correct/best match translation key option.

@sroddy

@fabpot @stof we would love to hear some feedbacks before starting to implement these changes. If we are totally out of scope, it's better not to start either. The idea is to change this PR according to the unicode plural rules and the a-bit more-verbose-but-much-clearer 'var[gender][pluralization]:' syntax. I'd love to hear a feedback from @schmittjoh too, because his really useful JMSTranslationBundle will be even more useful to help translators with this kind of syntax.

@fabpot
Symfony member

Closing in favor of #6009

@fabpot fabpot closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Sep 6, 2012
  1. [Translation] added support for translations depending on gender and …

    Stefano Rodriguez committed
    …number of multiple variables
  2. [Translation] fixes docblock @api tag and duplicate in the tests

    Stefano Rodriguez committed
  3. added author

    Stefano Rodriguez committed
View
5 src/Symfony/Component/Translation/CHANGELOG.md
@@ -1,6 +1,11 @@
CHANGELOG
=========
+2.2.0
+-----
+
+ * added support for translations depending on gender and number of multiple variables.
+
2.1.0
-----
View
101 src/Symfony/Component/Translation/MessageSelector.php
@@ -15,6 +15,7 @@
* MessageSelector.
*
* @author Fabien Potencier <fabien@symfony.com>
+ * @author Stefano Rodriguez <stefano.rodriguez@fubles.com>
*
* @api
*/
@@ -79,4 +80,104 @@ public function choose($message, $number, $locale)
return $standardRules[$position];
}
+
+ /**
+ * Given a message with different plural translations separated by a
+ * pipe (|), this method returns the correct portion of the message based
+ * on the given variables, locale and the pluralization rules in the message
+ * itself.
+ *
+ * This is the syntax of the messages:
+ * %variable% [gender][plural_position]: String
+ *
+ * Where
+ * %variable% is any kind of string (all characters except spaces and |),
+ * [gender] is one of the following: m n f (male, female, neuter)
+ * [plural_position] is an integer that follows the same convetions of the pluralization rules
+ * that you can find in Symfony\Component\Translation\PluralizationRules
+ * String is any kind of string (except the | sign)
+ *
+ * You can specify (and it's highly reccomended) a generic translation as the first position (without using any specific syntax).
+ * This will be returned in case no other translation matches the given parameters.
+ *
+ * Some examples follow:
+ * %user% wrote on %friend%'s wall for his/her birthday | %friend% f: %user% wrote on %friend%'s wall for her birthday | %friend% m: %user% wrote on %friend%'s wall for his birthday
+ * You are a very good boy! | %user% m1: You are very good boys! | %user% f0: You are a very good girl! | %user% 1f: You are very good girls!
+ *
+ * @param string $message The message being translated
+ * @param array $parameters The variables on which the message depends.
+ * @param string $locale The locale to use for choosing
+ *
+ * @return string
+ *
+ * @throws InvalidArgumentException
+ */
+ public function chooseByParams($message, $parameters, $locale)
+ {
+ $possibleMessages = explode('|', $message);
+ $bestMessage = null;
+ $bestMessageIndex = 0;
+
+ foreach ($possibleMessages as $possibleMessage) {
+ $possibleMessage = trim($possibleMessage);
+ // if this rule is not matched, we have a generic translation
+ if (preg_match('/^(.+)\:\s*(.*?)$/', $possibleMessage, $matches)) {
+ $parts = explode(',', $matches[1]);
+ $possibleMessage = $matches[2];
+ $isValid = true;
+ $priority = 0;
+
+ foreach ($parts as $part) {
+ $part = trim($part);
+ // checks if the message can fit with the variables
+ if (!preg_match('/^(?P<variable>[^\s]+)\s*((?P<number>\d)(\s*(?P<gender>[m|n|f]))?|(?P<gender1>[m|n|f])(\s*(?P<number1>\d))?)$/', $part, $matches)) {
+ throw new \InvalidArgumentException(sprintf('Syntax error on translation of "%s" with locale "%s".', $message, $locale));
+ }
+
+ $variable = $matches['variable'] ?: null;
+ $gender = isset($matches['gender']) && '' !== $matches['gender'] ? $matches['gender'] : (isset($matches['gender1']) && '' !== $matches['gender1'] ? $matches['gender1'] : null);
+ $number = isset($matches['number']) && '' !== $matches['number'] ? $matches['number'] : (isset($matches['number1']) && '' !== $matches['number1'] ? $matches['number1'] : null);
+
+ // if the variable is not passed to the method this string is not usable
+ if (!isset($parameters[$variable])) {
+ $isValid = false;
+ break;
+ }
+
+ // give 1 point if the variable matches gender/number, 2 points if both. Exclude it if doesn't match
+ if (isset($parameters[$variable]['gender'])) {
+ if ($gender == $parameters[$variable]['gender']) {
+ ++$priority;
+ } else {
+ $isValid = false;
+ break;
+ }
+ }
+ if (isset($parameters[$variable]['number'])) {
+ if (PluralizationRules::get($parameters[$variable]['number'], $locale) == $number) {
+ ++$priority;
+ } else {
+ $isValid = false;
+ break;
+ }
+ }
+ }
+
+ // We now have a priority. We set this as the best message if it's more relevant than the previous.
+ if (true === $isValid && $bestMessageIndex < $priority) {
+ $bestMessage = $possibleMessage;
+ $bestMessageIndex = $priority;
+ }
+ } else {
+ if (null === $bestMessage) {
+ $bestMessage = $possibleMessage;
+ }
+ }
+ }
+ if (null === $bestMessage) {
+ throw new \InvalidArgumentException(sprintf('Unable to choose a translation for "%s" with locale "%s". Please specify a default translation.', $message, $locale));
+ }
+
+ return $bestMessage;
+ }
}
View
26 src/Symfony/Component/Translation/Tests/MessageSelectorTest.php
@@ -69,4 +69,30 @@ public function getChooseTests()
array('There are %count% apples', 'There is one apple|There are %count% apples', 2),
);
}
+
+ /**
+ * @dataProvider getChooseByParamsTests
+ */
+ public function testChooseByParams($expected, $id, $parameters)
+ {
+ $selector = new MessageSelector();
+
+ $this->assertEquals($expected, $selector->chooseByParams($id, $parameters, 'en'));
+ }
+
+ public function getChooseByParamsTests()
+ {
+ return array(
+ array('There is %count% apples', '%count% 0:There is one apple|%count% 1: There is %count% apples', array('%count%' => array('number' => 0))),
+ array('There is %count% apples', '%count% 0: There is one apple|%count% 1: There is %count% apples', array('%count%' => array('number' => 0))),
+ array('There is %count% apples', 'There is one apple|%count% 1: There is %count% apples', array('%count%' => array('number' => 0))),
+
+ array('There is one apple', '%count% 0:There is one apple|%count% 1: There is %count% apples', array('%count%' => array('number' => 1))),
+
+ array('%user% wrote on %friend%\'s wall for his/her birthday','%user% wrote on %friend%\'s wall for his/her birthday | %friend% f: %user% wrote on %friend%\'s wall for her birthday | %friend% m: %user% wrote on %friend%\'s wall for his birthday', array()),
+ array('%user% wrote on %friend%\'s wall for her birthday','%user% wrote on %friend%\'s wall for his/her birthday | %friend% f: %user% wrote on %friend%\'s wall for her birthday | %friend% m: %user% wrote on %friend%\'s wall for his birthday', array('%friend%' => array('gender' => 'f', 'number' => 1))),
+ array('%user% wrote on %friend%\'s wall for his birthday','%user% wrote on %friend%\'s wall for his/her birthday|%friend% f:%user% wrote on %friend%\'s wall for her birthday | %friend% m: %user% wrote on %friend%\'s wall for his birthday', array('%friend%' => array('gender' => 'm', 'number' => 1))),
+ array('%user% wrote on %friend%\' walls for their birthdays',' %friend% f: %user% wrote on %friend%\'s wall for her birthday | %friend% m: %user% wrote on %friend%\'s wall for his birthday|%friend% 1: %user% wrote on %friend%\' walls for their birthdays', array('%friend%' => array('number' => 10))),
+ );
+ }
}
View
5 src/Symfony/Component/Translation/Tests/TranslatorTest.php
@@ -150,6 +150,11 @@ public function getTransTests()
array('Symfony2 est super !', 'Symfony2 is great!', 'Symfony2 est super !', array(), 'fr', ''),
array('Symfony2 est awesome !', 'Symfony2 is %what%!', 'Symfony2 est %what% !', array('%what%' => 'awesome'), 'fr', ''),
array('Symfony2 est super !', new String('Symfony2 is great!'), 'Symfony2 est super !', array(), 'fr', ''),
+ array('A James piace la tua attivita', '%name% likes your %activity%', 'A %name% piace la tua %activity%', array('%name%' => array('string' => 'James', 'gender' => 'm', 'number' => 1), '%activity%' => 'attivita'), 'it', ''),
+ array('Default string when nothing to choose from', '%name% likes your %activity%', 'Default string when nothing to choose from|%name% m 1: A %name% piace la tua %activity%', array('%name%' => array('string' => 'James', 'gender' => 'm', 'number' => 1), '%activity%' => 'attivita'), 'it', ''),
+ array('A James piace la tua %activity%', '%name% likes your %activity%', 'Default string when nothing to choose from|%name% m 1: A %name% piace la tua %activity%', array('%name%' => array('string' => 'James', 'gender' => 'm', 'number' => 0)), 'it', ''),
+ array('Trying to confuse regexp|James m m: Whoa!', 'meaninful message', 'Trying to confuse regexp|James m m: Whoa!', array('%user%' => array('string' => 'James')), 'it', ''),
+ array('Trying to confuse regexp|James 55: Whoa!', 'meaninful message', 'Trying to confuse regexp|James 55: Whoa!', array('%user%' => array('string' => 'James')), 'it', ''),
);
}
View
19 src/Symfony/Component/Translation/Translator.php
@@ -17,6 +17,7 @@
* Translator.
*
* @author Fabien Potencier <fabien@symfony.com>
+ * @author Stefano Rodriguez <stefano.rodriguez@fubles.com>
*
* @api
*/
@@ -125,7 +126,23 @@ public function trans($id, array $parameters = array(), $domain = 'messages', $l
$this->loadCatalogue($locale);
}
- return strtr($this->catalogues[$locale]->get((string) $id, $domain), $parameters);
+ $message = $this->catalogues[$locale]->get((string) $id, $domain);
+
+ // if we have to select a message
+ if (preg_match('/^([^\|]+\|)?\s*([^\s\|]+)\s+((\d)(\s*([m|n|f]))?|([m|n|f])(\s*(\d))?)(,\s*([^\s\|]+)\s+((\d)(\s*([m|n|f]))?|([m|n|f])(\s*(\d))?))*\s*:\s*([^\|]*)(\|\s*([^\s\|]+)\s+((\d)(\s*([m|n|f]))?|([m|n|f])(\s*(\d))?)(,\s*([^\s\|]+)\s+((\d)(\s*([m|n|f]))?|([m|n|f])(\s*(\d))?))*\s*:\s*([^\|]*))*$/',$message)) {
+ $message = $this->selector->chooseByParams($message, $parameters, $locale);
+ }
+
+ // if the array is composed of other arrays we need to flatten it out in order to pass it to strtr
+ foreach ($parameters as &$parameter) {
+ if (is_array($parameter)) {
+ if (isset($parameter['string'])) {
+ $parameter = $parameter['string'];
+ }
+ }
+ }
+
+ return strtr($message, $parameters);
}
/**
Something went wrong with that request. Please try again.