diff --git a/core/CHANGELOG.txt b/core/CHANGELOG.txt index e48669b553064c3a02b641c89375c8d5bd7448e0..c5a8b5da0864c597a3611eac9d0d470d09e0cc75 100644 --- a/core/CHANGELOG.txt +++ b/core/CHANGELOG.txt @@ -31,6 +31,7 @@ Drupal 8.0, xxxx-xx-xx (development version) * Made interface translation directly accessible from language list. * Centralized interface translation import to one directory. * Drupal can now be translated to English and English can be deleted. + * Added support for singular/plural discovery and translation. * Improved content language support: * Freely orderable language selector in forms. * Made it possible to assign language to taxonomy terms, vocabularies diff --git a/core/includes/common.inc b/core/includes/common.inc index 240f644a154ebca2b0ef5bc3e37ac7bfdaa26040..327ffbd27a53c4dff3cfffd775beb50923a57c6e 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -162,6 +162,15 @@ */ const DRUPAL_CACHE_GLOBAL = 0x0008; +/** + * 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 LOCALE_PLURAL_DELIMITER = "\03"; + /** * Adds content to a specified region. * @@ -1719,27 +1728,34 @@ function format_xml_elements($array) { */ function format_plural($count, $singular, $plural, array $args = array(), array $options = array()) { $args['@count'] = $count; + // Join both forms to search a translation. + $tranlatable_string = implode(LOCALE_PLURAL_DELIMITER, array($singular, $plural)); + // Translate as usual. + $translated_strings = t($tranlatable_string, $args, $options); + // Split joined translation strings into array. + $translated_array = explode(LOCALE_PLURAL_DELIMITER, $translated_strings); + if ($count == 1) { - return t($singular, $args, $options); + return $translated_array[0]; } // 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 the index cannot be computed, use the plural as a fallback (which - // allows for most flexiblity with the replaceable @count value). - if ($index < 0) { - return t($plural, $args, $options); + if ($index == 0) { + // Singular form. + return $translated_array[0]; } else { - switch ($index) { - case "0": - return t($singular, $args, $options); - case "1": - return t($plural, $args, $options); - default: - unset($args['@count']); - $args['@count[' . $index . ']'] = $count; - return t(strtr($plural, array('@count' => '@count[' . $index . ']')), $args, $options); + 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 flexiblity + // with the replaceable @count value). + return $translated_array[1]; } } } diff --git a/core/includes/gettext.inc b/core/includes/gettext.inc index 95e84cf15275ba17c268e6f50f66e4b4708b9c94..18c27ea426eb4bfceb85582405ed9b17e9a111c1 100644 --- a/core/includes/gettext.inc +++ b/core/includes/gettext.inc @@ -173,7 +173,7 @@ function _locale_import_read_po($op, $file, $mode = NULL, $lang = NULL) { } // Append the plural form to the current entry. - $current['msgid'] .= "\0" . $quoted; + $current['msgid'] .= LOCALE_PLURAL_DELIMITER . $quoted; $context = 'MSGID_PLURAL'; } @@ -390,8 +390,10 @@ function _locale_import_one_string($op, $value = NULL, $mode = NULL, $lang = NUL // Store the string we got in the database. case 'db-store': - // We got header information. + if ($value['msgid'] == '') { + // If 'msgid' is empty, it means we got values for the header of the + // file as per the structure of the Gettext format. $locale_plurals = variable_get('locale_translation_plurals', array()); if (($mode != LOCALE_IMPORT_KEEP) || empty($locale_plurals[$lang]['plurals'])) { // Since we only need to parse the header if we ought to update the @@ -413,32 +415,25 @@ function _locale_import_one_string($op, $value = NULL, $mode = NULL, $lang = NUL } else { - // Some real string to import. + // Found a string to store, clean up and prepare the data. $comments = _locale_import_shorten_comments(empty($value['#']) ? array() : $value['#']); - if (strpos($value['msgid'], "\0")) { - // This string has plural versions. - $english = explode("\0", $value['msgid'], 2); - $entries = array_keys($value['msgstr']); - for ($i = 3; $i <= count($entries); $i++) { - $english[] = $english[1]; - } - $translation = array_map('_locale_import_append_plural', $value['msgstr'], $entries); - $english = array_map('_locale_import_append_plural', $english, $entries); - foreach ($translation as $key => $trans) { - if ($key == 0) { - $plid = 0; - } - $plid = _locale_import_one_string_db($report, $lang, isset($value['msgctxt']) ? $value['msgctxt'] : '', $english[$key], $trans, $comments, $mode, $plid, $key); - } + if (is_array($value['msgstr'])) { + // Sort plural variants by their form index. + ksort($value['msgstr']); + // Serialize plural variants in one string by LOCALE_PLURAL_DELIMITER. + $value['msgstr'] = implode(LOCALE_PLURAL_DELIMITER, $value['msgstr']); } - else { - // A simple string to import. - $english = $value['msgid']; - $translation = $value['msgstr']; - _locale_import_one_string_db($report, $lang, isset($value['msgctxt']) ? $value['msgctxt'] : '', $english, $translation, $comments, $mode); - } + _locale_import_one_string_db( + $report, + $lang, + isset($value['msgctxt']) ? $value['msgctxt'] : '', + $value['msgid'], + $value['msgstr'], + $comments, + $mode + ); } } // end of db-store operation } @@ -461,15 +456,11 @@ function _locale_import_one_string($op, $value = NULL, $mode = NULL, $lang = NUL * Location value to save with source string. * @param $mode * Import mode to use, LOCALE_IMPORT_KEEP or LOCALE_IMPORT_OVERWRITE. - * @param $plid - * Optional plural ID to use. - * @param $plural - * Optional plural value to use. * * @return * The string ID of the existing string modified or the new string added. */ -function _locale_import_one_string_db(&$report, $langcode, $context, $source, $translation, $location, $mode, $plid = 0, $plural = 0) { +function _locale_import_one_string_db(&$report, $langcode, $context, $source, $translation, $location, $mode) { $lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = :context", array(':source' => $source, ':context' => $context))->fetchField(); if (!empty($translation)) { @@ -497,8 +488,6 @@ function _locale_import_one_string_db(&$report, $langcode, $context, $source, $t 'lid' => $lid, 'language' => $langcode, 'translation' => $translation, - 'plid' => $plid, - 'plural' => $plural, )) ->execute(); @@ -509,8 +498,6 @@ function _locale_import_one_string_db(&$report, $langcode, $context, $source, $t db_update('locales_target') ->fields(array( 'translation' => $translation, - 'plid' => $plid, - 'plural' => $plural, )) ->condition('language', $langcode) ->condition('lid', $lid) @@ -534,8 +521,6 @@ function _locale_import_one_string_db(&$report, $langcode, $context, $source, $t 'lid' => $lid, 'language' => $langcode, 'translation' => $translation, - 'plid' => $plid, - 'plural' => $plural )) ->execute(); @@ -547,8 +532,6 @@ function _locale_import_one_string_db(&$report, $langcode, $context, $source, $t db_delete('locales_target') ->condition('language', $langcode) ->condition('lid', $lid) - ->condition('plid', $plid) - ->condition('plural', $plural) ->execute(); $report['deletes']++; @@ -790,27 +773,6 @@ function _locale_import_tokenize_formula($formula) { return $tokens; } -/** - * Adds count indices to a string. - * - * Callback for array_map() within _locale_import_one_string(). - * - * @param $entry - * An array element. - * @param $key - * Index of the array element. - */ -function _locale_import_append_plural($entry, $key) { - // No modifications for 0, 1 - if ($key == 0 || $key == 1) { - return $entry; - } - - // First remove any possibly false indices, then add new ones - $entry = preg_replace('/(@count)\[[0-9]\]/', '\\1', $entry); - return preg_replace('/(@count)/', "\\1[$key]", $entry); -} - /** * Generates a short, one-string version of the passed comment array. * @@ -872,28 +834,19 @@ function _locale_import_parse_quoted($string) { */ function _locale_export_get_strings($language = NULL) { if (isset($language)) { - $result = db_query("SELECT s.lid, s.source, s.context, s.location, t.translation, t.plid, t.plural FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language ORDER BY t.plid, t.plural", array(':language' => $language->langcode)); + $result = db_query("SELECT s.lid, s.source, s.context, s.location, t.translation FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language", array(':language' => $language->langcode)); } else { - $result = db_query("SELECT s.lid, s.source, s.context, s.location, t.plid, t.plural FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid ORDER BY t.plid, t.plural"); + $result = db_query("SELECT s.lid, s.source, s.context, s.location FROM {locales_source} s"); } $strings = array(); foreach ($result as $child) { - $string = array( + $strings[$child->lid] = array( 'comment' => $child->location, 'source' => $child->source, 'context' => $child->context, 'translation' => isset($child->translation) ? $child->translation : '', ); - if ($child->plid) { - // Has a parent lid. Since we process in the order of plids, - // we already have the parent in the array, so we can add the - // lid to the next plural version to it. This builds a linked - // list of plurals. - $string['child'] = TRUE; - $strings[$child->plid]['plural'] = $child->lid; - } - $strings[$child->lid] = $string; } return $strings; } @@ -933,6 +886,12 @@ function _locale_export_po_generate($language = NULL, $strings = array(), $heade $header .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; if (!empty($locale_plurals[$language->langcode]['formula'])) { $header .= "\"Plural-Forms: nplurals=" . $locale_plurals[$language->langcode]['plurals'] . "; plural=" . strtr($locale_plurals[$language->langcode]['formula'], array('$' => '')) . ";\\n\"\n"; + // Remember number of plural variants to optimize the export. + $nplurals = $locale_plurals[$language->langcode]['plurals']; + } + else { + // Remember we did not have a plural number for the export. + $nplurals = 0; } } else { @@ -956,41 +915,38 @@ function _locale_export_po_generate($language = NULL, $strings = array(), $heade $output = $header . "\n"; foreach ($strings as $lid => $string) { - // Only process non-children, children are output below their parent. - if (!isset($string['child'])) { - if ($string['comment']) { - $output .= '#: ' . $string['comment'] . "\n"; - } - if (!empty($string['context'])) { - $output .= 'msgctxt ' . _locale_export_string($string['context']); - } - $output .= 'msgid ' . _locale_export_string($string['source']); - if (!empty($string['plural'])) { - $plural = $string['plural']; - $output .= 'msgid_plural ' . _locale_export_string($strings[$plural]['source']); - if (isset($language)) { - $translation = $string['translation']; - for ($i = 0; $i < $locale_plurals[$language->langcode]['plurals']; $i++) { - $output .= 'msgstr[' . $i . '] ' . _locale_export_string($translation); - if ($plural) { - $translation = _locale_export_remove_plural($strings[$plural]['translation']); - $plural = isset($strings[$plural]['plural']) ? $strings[$plural]['plural'] : 0; - } - else { - $translation = ''; - } + if ($string['comment']) { + $output .= '#: ' . $string['comment'] . "\n"; + } + if (!empty($string['context'])) { + $output .= 'msgctxt ' . _locale_export_string($string['context']); + } + if (strpos($string['source'], LOCALE_PLURAL_DELIMITER) !== FALSE) { + // Export plural string. + $export_array = explode(LOCALE_PLURAL_DELIMITER, $string['source']); + $output .= 'msgid ' . _locale_export_string($export_array[0]); + $output .= 'msgid_plural ' . _locale_export_string($export_array[1]); + if (isset($language)) { + $export_array = explode(LOCALE_PLURAL_DELIMITER, $string['translation']); + for ($i = 0; $i < $nplurals; $i++) { + if (isset($export_array[$i])) { + $output .= 'msgstr[' . $i . '] ' . _locale_export_string($export_array[$i]); + } + else { + $output .= 'msgstr[' . $i . '] ""' . "\n"; } - } - else { - $output .= 'msgstr[0] ""' . "\n"; - $output .= 'msgstr[1] ""' . "\n"; } } else { - $output .= 'msgstr ' . _locale_export_string($string['translation']); + $output .= 'msgstr[0] ""' . "\n"; + $output .= 'msgstr[1] ""' . "\n"; } - $output .= "\n"; } + else { + $output .= 'msgid ' . _locale_export_string($string['source']); + $output .= 'msgstr ' . _locale_export_string($string['translation']); + } + $output .= "\n"; } return $output; } @@ -1086,13 +1042,6 @@ function _locale_export_wrap($str, $len) { return implode("\n", $return); } -/** - * Removes plural index information from a string. - */ -function _locale_export_remove_plural($entry) { - return preg_replace('/(@count)\[[0-9]\]/', '\\1', $entry); -} - /** * @} End of "locale-api-import-export" */ diff --git a/core/modules/locale/locale.install b/core/modules/locale/locale.install index d05d24ba08568a2036b550c68af9c9f9e65e04b7..11d1873455a0e8e9b6467951f15dcf85ed1fa17f 100644 --- a/core/modules/locale/locale.install +++ b/core/modules/locale/locale.install @@ -176,20 +176,8 @@ function locale_schema() { 'default' => '', 'description' => 'Language code. References {language}.langcode.', ), - 'plid' => array( - 'type' => 'int', - 'not null' => TRUE, // This should be NULL for no referenced string, not zero. - 'default' => 0, - 'description' => 'Parent lid (lid of the previous string in the plural chain) in case of plural strings. References {locales_source}.lid.', - ), - 'plural' => array( - 'type' => 'int', - 'not null' => TRUE, - 'default' => 0, - 'description' => 'Plural index number in case of plural strings.', - ), ), - 'primary key' => array('language', 'lid', 'plural'), + 'primary key' => array('language', 'lid'), 'foreign keys' => array( 'locales_source' => array( 'table' => 'locales_source', @@ -198,8 +186,6 @@ function locale_schema() { ), 'indexes' => array( 'lid' => array('lid'), - 'plid' => array('plid'), - 'plural' => array('plural'), ), ); @@ -379,6 +365,123 @@ function locale_update_8004() { } } +/** + * Update plural interface translations to new format. + * + * See http://drupal.org/node/532512#comment-5679184 for the details of the + * structures handled in this update. + */ +function locale_update_8005() { + // Collect all LIDs that are sources to plural variants. + $results = db_query("SELECT lid, plid FROM {locales_target} WHERE plural <> 0"); + $plural_lids = array(); + foreach ($results as $row) { + // Need to collect both LID and PLID. The LID for the first (singular) + // string can only be retrieved from the first plural's PLID given no + // other indication. The last plural variant is never referenced, so we + // need to store the LID directly for that. We never know whether we are + // on the last plural though, so we always remember LID too. + $plural_lids[] = $row->lid; + $plural_lids[] = $row->plid; + } + $plural_lids = array_unique($plural_lids); + + // Look up all translations for these source strings. Ordering by language + // will group the strings by language, the 'plid' order will get the + // strings in singular/plural order and 'plural' will get them in precise + // sequential order needed. + $results = db_query("SELECT s.lid, s.source, t.translation, t.plid, t.plural, t.language FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid WHERE s.lid IN (:lids) ORDER BY t.language, t.plid, t.plural", array(':lids' => $plural_lids)); + + // Collect the strings into an array and combine values as we go. + $strings = array(); + $parents_to_sources = array(); + $remove_lids = array(); + foreach ($results as $child) { + $strings[$child->language][$child->lid] = array( + 'source' => array($child->source), + 'translation' => array($child->translation), + ); + + if (empty($child->plid)) { + // Non-children strings point to themselves as parents. This makes it + // easy to look up the utmost parents for any plurals. + $parents_to_sources[$child->lid] = $child->lid; + } + else { + // Children strings point to their utmost parents. Because we get data + // in PLID order, we can ensure that all previous parents have data now, + // so we can just copy the parent's data about their parent, etc. + $parents_to_sources[$child->lid] = $parents_to_sources[$child->plid]; + + // Append translation to the utmost parent's translation string. + $utmost_parent = &$strings[$child->language][$parents_to_sources[$child->plid]]; + // Drop the Drupal-specific numbering scheme from the end of plural + // formulas. + $utmost_parent['translation'][] = str_replace('@count[' . $child->plural .']', '@count', $child->translation); + if (count($utmost_parent['source']) < 2) { + // Append source to the utmost parent's source string only if it is the + // plural variant. Further Drupal specific plural variants are not to be + // retained for source strings. + $utmost_parent['source'][] = $child->source; + } + + // All plural variant LIDs are to be removed with their translations. + // Only the singular LIDs will be kept. + $remove_lids[] = $child->lid; + } + } + + // Do updates for all source strings and all translations. + $updated_sources = array(); + foreach ($strings as $langcode => $translations) { + foreach($translations as $lid => $translation) { + if (!in_array($lid, $updated_sources)) { + // Only update source string if not yet updated. We merged these within + // the translation lookups because plural information was only avilable + // with the translation, but we don't need to save it again for every + // language. + db_update('locales_source') + ->fields(array( + 'source' => implode(LOCALE_PLURAL_DELIMITER, $translation['source']), + )) + ->condition('lid', $lid) + ->execute(); + $updated_sources[] = $lid; + } + db_update('locales_target') + ->fields(array( + 'translation' => implode(LOCALE_PLURAL_DELIMITER, $translation['translation']), + )) + ->condition('lid', $lid) + ->condition('language', $langcode) + ->execute(); + } + } + + // Remove all plural LIDs from source and target. only keep those which were + // originally used for the singular strings (now updated to contain the + // serialized version of plurals). + $remove_lids = array_unique($remove_lids); + db_delete('locales_source') + ->condition('lid', $remove_lids, 'IN') + ->execute(); + db_delete('locales_target') + ->condition('lid', $remove_lids, 'IN') + ->execute(); + + // Drop the primary key because it contains 'plural'. + db_drop_primary_key('locales_target'); + + // Remove the 'plid' and 'plural' columns and indexes. + db_drop_index('locales_target', 'plid'); + db_drop_field('locales_target', 'plid'); + db_drop_index('locales_target', 'plural'); + db_drop_field('locales_target', 'plural'); + + // Add back a primary key without 'plural'. + db_add_primary_key('locales_target', array('language', 'lid')); +} + /** * @} End of "addtogroup updates-7.x-to-8.x" * The next series of updates should start at 9000. diff --git a/core/modules/locale/locale.pages.inc b/core/modules/locale/locale.pages.inc index e41bae59d4bdddaf84c9434998ea0601175b175d..0d59f421f09ac68f98f84f46c0b19dbf2636f2bc 100644 --- a/core/modules/locale/locale.pages.inc +++ b/core/modules/locale/locale.pages.inc @@ -92,7 +92,7 @@ function _locale_translate_seek() { $rows = array(); foreach ($strings as $lid => $string) { $rows[] = array( - array('data' => check_plain(truncate_utf8($string['source'], 150, FALSE, TRUE)) . '<br /><small>' . $string['location'] . '</small>'), + array('data' => check_plain(truncate_utf8(str_replace(LOCALE_PLURAL_DELIMITER, ', ', $string['source']), 150, FALSE, TRUE)) . '<br /><small>' . $string['location'] . '</small>'), $string['context'], array('data' => _locale_translate_language_list($string['languages'], $limit_language), 'align' => 'center'), array('data' => l(t('edit'), "admin/config/regional/translate/edit/$lid", array('query' => drupal_get_destination())), 'class' => array('nowrap')), @@ -278,13 +278,37 @@ function locale_translate_edit_form($form, &$form_state, $lid) { drupal_set_message(t('String not found.'), 'error'); drupal_goto('admin/config/regional/translate/translate'); } - - // Add original text to the top and some values for form altering. - $form['original'] = array( - '#type' => 'item', - '#title' => t('Original text'), - '#markup' => check_plain(wordwrap($source->source, 0)), - ); + // Split source to work with plural values. + $source_array = explode(LOCALE_PLURAL_DELIMITER, $source->source); + if (count($source_array) == 1) { + // Add original text value and mark as non-plural. + $form['plural'] = array( + '#type' => 'value', + '#value' => 0 + ); + $form['original'] = array( + '#type' => 'item', + '#title' => t('Original text'), + '#markup' => check_plain($source_array[0]), + ); + } + else { + // Add original text value and mark as plural. + $form['plural'] = array( + '#type' => 'value', + '#value' => 1 + ); + $form['original_singular'] = array( + '#type' => 'item', + '#title' => t('Original singular form'), + '#markup' => check_plain($source_array[0]), + ); + $form['original_plural'] = array( + '#type' => 'item', + '#title' => t('Original plural form'), + '#markup' => check_plain($source_array[1]), + ); + } if (!empty($source->context)) { $form['context'] = array( '#type' => 'item', @@ -307,22 +331,68 @@ function locale_translate_edit_form($form, &$form_state, $lid) { if (!locale_translate_english()) { unset($languages['en']); } - $form['translations'] = array('#tree' => TRUE); + // Store languages to iterate for validation and submission of the form. + $form_state['langcodes'] = array_keys($languages); + $plural_formulas = variable_get('locale_translation_plurals', array()); + + $form['translations'] = array( + '#type' => 'vertical_tabs', + '#tree' => TRUE + ); + // Approximate the number of rows to use in the default textarea. - $rows = min(ceil(str_word_count($source->source) / 12), 10); + $rows = min(ceil(str_word_count($source_array[0]) / 12), 10); foreach ($languages as $langcode => $language) { $form['translations'][$langcode] = array( - '#type' => 'textarea', + '#type' => 'fieldset', '#title' => $language->name, - '#rows' => $rows, - '#default_value' => '', ); + if (empty($form['plural']['#value'])) { + $form['translations'][$langcode][0] = array( + '#type' => 'textarea', + '#title' => $language->name, + '#rows' => $rows, + '#default_value' => '', + ); + } + else { + // Dealing with plural strings. + if (isset($plural_formulas[$langcode]['plurals']) && $plural_formulas[$langcode]['plurals'] > 1) { + // Add a textarea for each plural variant. + for ($i = 0; $i < $plural_formulas[$langcode]['plurals']; $i++) { + $form['translations'][$langcode][$i] = array( + '#type' => 'textarea', + '#title' => ($i == 0 ? t('Singular form') : format_plural($i, 'First plural form', '@count. plural form')), + '#rows' => $rows, + '#default_value' => '', + ); + } + } + else { + // Fallback for unknown number of plurals. + $form['translations'][$langcode][0] = array( + '#type' => 'textarea', + '#title' => t('Sigular form'), + '#rows' => $rows, + '#default_value' => '', + ); + $form['translations'][$langcode][1] = array( + '#type' => 'textarea', + '#title' => t('Plural form'), + '#rows' => $rows, + '#default_value' => '', + ); + } + } } // Fetch translations and fill in default values in the form. $result = db_query("SELECT DISTINCT translation, language FROM {locales_target} WHERE lid = :lid", array(':lid' => $lid)); foreach ($result as $translation) { - $form['translations'][$translation->language]['#default_value'] = $translation->translation; + $translation_array = explode(LOCALE_PLURAL_DELIMITER, $translation->translation); + for ($i = 0; $i < count($translation_array); $i++) { + $form['translations'][$translation->language][$i]['#default_value'] = $translation_array[$i]; + } } $form['actions'] = array('#type' => 'actions'); @@ -334,10 +404,12 @@ function locale_translate_edit_form($form, &$form_state, $lid) { * Validate string editing form submissions. */ function locale_translate_edit_form_validate($form, &$form_state) { - foreach ($form_state['values']['translations'] as $key => $value) { - if (!locale_string_is_safe($value)) { - form_set_error('translations', t('The submitted string contains disallowed HTML: %string', array('%string' => $value))); - watchdog('locale', 'Attempted submission of a translation string with disallowed HTML: %string', array('%string' => $value), WATCHDOG_WARNING); + foreach ($form_state['langcodes'] as $langcode) { + foreach ($form_state['values']['translations'][$langcode] as $key => $value) { + if (!locale_string_is_safe($value)) { + form_set_error("translations][$langcode][$key", t('The submitted string contains disallowed HTML: %string', array('%string' => $value))); + watchdog('locale', 'Attempted submission of a translation string with disallowed HTML: %string', array('%string' => $value), WATCHDOG_WARNING); + } } } } @@ -349,9 +421,19 @@ function locale_translate_edit_form_validate($form, &$form_state) { */ function locale_translate_edit_form_submit($form, &$form_state) { $lid = $form_state['values']['lid']; - foreach ($form_state['values']['translations'] as $key => $value) { - $translation = db_query("SELECT translation FROM {locales_target} WHERE lid = :lid AND language = :language", array(':lid' => $lid, ':language' => $key))->fetchField(); - if (!empty($value)) { + foreach ($form_state['langcodes'] as $langcode) { + // Serialize plural variants in one string by LOCALE_PLURAL_DELIMITER. + $value = implode(LOCALE_PLURAL_DELIMITER, $form_state['values']['translations'][$langcode]); + $translation = db_query("SELECT translation FROM {locales_target} WHERE lid = :lid AND language = :language", array(':lid' => $lid, ':language' => $langcode))->fetchField(); + // No translation when all strings are empty. + $has_translation = FALSE; + foreach ($form_state['values']['translations'][$langcode] as $string) { + if (!empty($string)) { + $has_translation = TRUE; + break; + } + } + if ($has_translation) { // Only update or insert if we have a value to use. if (!empty($translation)) { db_update('locales_target') @@ -359,7 +441,7 @@ function locale_translate_edit_form_submit($form, &$form_state) { 'translation' => $value, )) ->condition('lid', $lid) - ->condition('language', $key) + ->condition('language', $langcode) ->execute(); } else { @@ -367,7 +449,7 @@ function locale_translate_edit_form_submit($form, &$form_state) { ->fields(array( 'lid' => $lid, 'translation' => $value, - 'language' => $key, + 'language' => $langcode, )) ->execute(); } @@ -376,12 +458,12 @@ function locale_translate_edit_form_submit($form, &$form_state) { // Empty translation entered: remove existing entry from database. db_delete('locales_target') ->condition('lid', $lid) - ->condition('language', $key) + ->condition('language', $langcode) ->execute(); } // Force JavaScript translation file recreation for this language. - _locale_invalidate_js($key); + _locale_invalidate_js($langcode); } drupal_set_message(t('The string has been saved.')); diff --git a/core/modules/locale/locale.test b/core/modules/locale/locale.test index 4914593c404c9487b1da21032511e2a76f57de14..c6598a1b40f369c3cee6854dd450006fc7ddf9a5 100644 --- a/core/modules/locale/locale.test +++ b/core/modules/locale/locale.test @@ -267,8 +267,8 @@ class LocaleTranslationFunctionalTest extends DrupalWebTestCase { $this->clickLink(t('edit')); $string_edit_url = $this->getUrl(); $edit = array( - "translations[$langcode]" => $translation, - 'translations[en]' => $translation_to_en, + "translations[$langcode][0]" => $translation, + 'translations[en][0]' => $translation_to_en, ); $this->drupalPost(NULL, $edit, t('Save translations')); $this->assertText(t('The string has been saved.'), t('The string has been saved.')); @@ -367,7 +367,7 @@ class LocaleTranslationFunctionalTest extends DrupalWebTestCase { $query->addExpression('min(l.lid)', 'lid'); $result = $query->condition('l.location', '%.js%', 'LIKE')->execute(); $url = 'admin/config/regional/translate/edit/' . $result->fetchObject()->lid; - $edit = array('translations['. $langcode .']' => $this->randomName()); + $edit = array('translations['. $langcode .'][0]' => $this->randomName()); $this->drupalPost($url, $edit, t('Save translations')); // Trigger JavaScript translation parsing and building. @@ -434,7 +434,7 @@ class LocaleTranslationFunctionalTest extends DrupalWebTestCase { $path = $matches[0]; foreach ($bad_translations as $key => $translation) { $edit = array( - "translations[$langcode]" => $translation, + "translations[$langcode][0]" => $translation, ); $this->drupalPost($path, $edit, t('Save translations')); // Check for a form error on the textarea. @@ -521,7 +521,7 @@ class LocaleTranslationFunctionalTest extends DrupalWebTestCase { preg_match('!admin/config/regional/translate/edit/(\d)+!', $this->getUrl(), $matches); $lid = $matches[1]; $edit = array( - "translations[$langcode]" => $translation, + "translations[$langcode][0]" => $translation, ); $this->drupalPost(NULL, $edit, t('Save translations')); @@ -586,26 +586,26 @@ class LocaleTranslationFunctionalTest extends DrupalWebTestCase { } /** - * Tests plural index computation functionality. + * Tests plural format handling functionality. */ class LocalePluralFormatTest extends DrupalWebTestCase { public static function getInfo() { return array( - 'name' => 'Plural formula evaluation', - 'description' => 'Tests plural formula evaluation for various languages.', + 'name' => 'Plural handling', + 'description' => 'Tests plural handling for various languages.', 'group' => 'Locale', ); } function setUp() { - parent::setUp('locale', 'locale_test'); + parent::setUp('locale'); $admin_user = $this->drupalCreateUser(array('administer languages', 'translate interface', 'access administration pages')); $this->drupalLogin($admin_user); } /** - * Tests locale_get_plural() functionality. + * Tests locale_get_plural() and format_plural() functionality. */ function testGetPluralFormat() { // Import some .po files with formulas to set up the environment. @@ -629,30 +629,174 @@ class LocalePluralFormatTest extends DrupalWebTestCase { // Reset static caches from locale_get_plural() to ensure we get fresh data. drupal_static_reset('locale_get_plural'); drupal_static_reset('locale_get_plural:plurals'); + drupal_static_reset('locale'); + + // Expected plural translation strings for each plural index. + $plural_strings = array( + // English is not imported in this case, so we assume built-in text + // and formulas. + 'en' => array( + 0 => '1 hour', + 1 => '@count hours', + ), + 'fr' => array( + 0 => '1 heure', + 1 => '@count heures', + ), + 'hr' => array( + 0 => '@count sat', + 1 => '@count sata', + 2 => '@count sati', + ), + // Hungarian is not imported, so it should assume the same text as + // English, but it will always pick the plural form as per the built-in + // logic, so only index -1 is relevant with the plural value. + 'hu' => array( + 0 => '1 hour', + -1 => '@count hours', + ), + ); - // Test locale_get_plural() for English (no formula presnt). - $this->assertIdentical(locale_get_plural(1, 'en'), 0, t("Computed plural index for 'en' with count 1 is 0.")); - $this->assertIdentical(locale_get_plural(0, 'en'), 1, t("Computed plural index for 'en' with count 0 is 1.")); - $this->assertIdentical(locale_get_plural(5, 'en'), 1, t("Computed plural index for 'en' with count 5 is 1.")); + // Expected plural indexes precomputed base on the plural formulas with + // given $count value. + $plural_tests = array( + 'en' => array( + 1 => 0, + 0 => 1, + 5 => 1, + ), + 'fr' => array( + 1 => 0, + 0 => 0, + 5 => 1, + ), + 'hr' => array( + 1 => 0, + 21 => 0, + 0 => 2, + 2 => 1, + 8 => 2, + ), + 'hu' => array( + 1 => -1, + 21 => -1, + 0 => -1, + ), + ); - // Test locale_get_plural() for French (simpler formula). - $this->assertIdentical(locale_get_plural(1, 'fr'), 0, t("Computed plural index for 'fr' with count 1 is 0.")); - $this->assertIdentical(locale_get_plural(0, 'fr'), 0, t("Computed plural index for 'fr' with count 0 is 0.")); - $this->assertIdentical(locale_get_plural(5, 'fr'), 1, t("Computed plural index for 'fr' with count 5 is 1.")); + foreach ($plural_tests as $langcode => $tests) { + foreach ($tests as $count => $expected_plural_index) { + // Assert that the we get the right plural index. + $this->assertIdentical(locale_get_plural($count, $langcode), $expected_plural_index, 'Computed plural index for ' . $langcode . ' for count ' . $count . ' is ' . $expected_plural_index); + // Assert that the we get the right translation for that. Change the + // 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(format_plural($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); + } + } + } - // Test locale_get_plural() for Croatian (more complex formula). - $this->assertIdentical(locale_get_plural( 1, 'hr'), 0, t("Computed plural index for 'hr' with count 1 is 0.")); - $this->assertIdentical(locale_get_plural(21, 'hr'), 0, t("Computed plural index for 'hr' with count 21 is 0.")); - $this->assertIdentical(locale_get_plural( 0, 'hr'), 2, t("Computed plural index for 'hr' with count 0 is 2.")); - $this->assertIdentical(locale_get_plural( 2, 'hr'), 1, t("Computed plural index for 'hr' with count 2 is 1.")); - $this->assertIdentical(locale_get_plural( 8, 'hr'), 2, t("Computed plural index for 'hr' with count 8 is 2.")); + /** + * Tests plural editing and export functionality. + */ + function testPluralEditExport() { + // Import some .po files with formulas to set up the environment. + // These will also add the languages to the system and enable them. + $this->importPoFile($this->getPoFileWithSimplePlural(), array( + 'langcode' => 'fr', + )); + $this->importPoFile($this->getPoFileWithComplexPlural(), array( + 'langcode' => 'hr', + )); - // Test locale_get_plural() for Hungarian (nonexistent language). - $this->assertIdentical(locale_get_plural( 1, 'hu'), -1, t("Computed plural index for 'hu' with count 1 is -1.")); - $this->assertIdentical(locale_get_plural(21, 'hu'), -1, t("Computed plural index for 'hu' with count 21 is -1.")); - $this->assertIdentical(locale_get_plural( 0, 'hu'), -1, t("Computed plural index for 'hu' with count 0 is -1.")); + // Get the French translations. + $this->drupalPost('admin/config/regional/translate/export', array( + 'langcode' => 'fr', + ), t('Export')); + // Ensure we have a translation file. + $this->assertRaw('# French translation of Drupal', t('Exported French translation file.')); + // Ensure our imported translations exist in the file. + $this->assertRaw("msgid \"Monday\"\nmsgstr \"lundi\"", t('French translations present in exported file.')); + // Check for plural export specifically. + $this->assertRaw("msgid \"1 hour\"\nmsgid_plural \"@count hours\"\nmsgstr[0] \"1 heure\"\nmsgstr[1] \"@count heures\"", t('Plural translations exported properly.')); + + // Get the Croatian translations. + $this->drupalPost('admin/config/regional/translate/export', array( + 'langcode' => 'hr', + ), t('Export')); + // Ensure we have a translation file. + $this->assertRaw('# Croatian translation of Drupal', t('Exported Croatian translation file.')); + // Ensure our imported translations exist in the file. + $this->assertRaw("msgid \"Monday\"\nmsgstr \"Ponedjeljak\"", t('Croatian translations present in exported file.')); + // Check for plural export specifically. + $this->assertRaw("msgid \"1 hour\"\nmsgid_plural \"@count hours\"\nmsgstr[0] \"@count sat\"\nmsgstr[1] \"@count sata\"\nmsgstr[2] \"@count sati\"", t('Plural translations exported properly.')); + + // Check if the source appears on the translation page. + $this->drupalGet('admin/config/regional/translate'); + $this->assertText("1 hour, @count hours"); + + // Look up editing page for this plural string and check fields. + $lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = ''", array(':source' => "1 hour" . LOCALE_PLURAL_DELIMITER . "@count hours"))->fetchField(); + $path = 'admin/config/regional/translate/edit/' . $lid; + $this->drupalGet($path); + // Labels for plural editing elements. + $this->assertText('Singular form'); + $this->assertText('First plural form'); + $this->assertText('2. plural form'); + // Plural values for both languages. + $this->assertFieldById('edit-translations-hr-0', '@count sat'); + $this->assertFieldById('edit-translations-hr-1', '@count sata'); + $this->assertFieldById('edit-translations-hr-2', '@count sati'); + $this->assertNoFieldById('edit-translations-hr-3'); + $this->assertFieldById('edit-translations-fr-0', '1 heure'); + $this->assertFieldById('edit-translations-fr-1', '@count heures'); + $this->assertNoFieldById('edit-translations-fr-2'); + + // Edit some translations and see if that took effect. + $edit = array( + 'translations[fr][0]' => '1 heure edited', + 'translations[hr][1]' => '@count sata edited', + ); + $this->drupalPost($path, $edit, t('Save translations')); + + // Inject a plural source string to the database. We need to use a specific + // 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. + format_plural(1, '1 day', '@count days', array(), array('langcode' => 'fr')); + // Look up editing page for this plural string and check fields. + $lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = ''", array(':source' => "1 day" . LOCALE_PLURAL_DELIMITER . "@count days"))->fetchField(); + $path = 'admin/config/regional/translate/edit/' . $lid; + + // Save complete translations for the string in both languages. + $edit = array( + 'translations[fr][0]' => '1 jour', + 'translations[fr][1]' => '@count jours', + 'translations[hr][0]' => '@count dan', + 'translations[hr][1]' => '@count dana', + 'translations[hr][2]' => '@count dana', + ); + $this->drupalPost($path, $edit, t('Save translations')); + + // Get the French translations. + $this->drupalPost('admin/config/regional/translate/export', array( + 'langcode' => 'fr', + ), t('Export')); + // Check for plural export specifically. + $this->assertRaw("msgid \"1 hour\"\nmsgid_plural \"@count hours\"\nmsgstr[0] \"1 heure edited\"\nmsgstr[1] \"@count heures\"", t('Edited French plural translations for hours exported properly.')); + $this->assertRaw("msgid \"1 day\"\nmsgid_plural \"@count days\"\nmsgstr[0] \"1 jour\"\nmsgstr[1] \"@count jours\"", t('Added French plural translations for days exported properly.')); + + // Get the Croatian translations. + $this->drupalPost('admin/config/regional/translate/export', array( + 'langcode' => 'hr', + ), t('Export')); + // Check for plural export specifically. + $this->assertRaw("msgid \"1 hour\"\nmsgid_plural \"@count hours\"\nmsgstr[0] \"@count sat\"\nmsgstr[1] \"@count sata edited\"\nmsgstr[2] \"@count sati\"", t('Edited Croatian plural translations exported properly.')); + $this->assertRaw("msgid \"1 day\"\nmsgid_plural \"@count days\"\nmsgstr[0] \"@count dan\"\nmsgstr[1] \"@count dana\"\nmsgstr[2] \"@count dana\"", t('Added Croatian plural translations exported properly.')); } + /** * Imports a standalone .po file in a given language. * @@ -792,8 +936,8 @@ class LocaleImportFunctionalTest extends DrupalWebTestCase { // The import should automatically create the corresponding language. $this->assertRaw(t('The language %language has been created.', array('%language' => 'French')), t('The language has been automatically created.')); - // The import should have created 7 strings. - $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 9, '%update' => 0, '%delete' => 0)), t('The translation file was successfully imported.')); + // The import should have created 8 strings. + $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 8, '%update' => 0, '%delete' => 0)), t('The translation file was successfully imported.')); // This import should have saved plural forms to have 2 variants. $locale_plurals = variable_get('locale_translation_plurals', array()); @@ -1284,7 +1428,7 @@ class LocaleUninstallFunctionalTest extends DrupalWebTestCase { $string = db_query('SELECT min(lid) AS lid FROM {locales_source} WHERE location LIKE :location', array( ':location' => '%.js%', ))->fetchObject(); - $edit = array('translations[fr]' => 'french translation'); + $edit = array('translations[fr][0]' => 'french translation'); $this->drupalPost('admin/config/regional/translate/edit/' . $string->lid, $edit, t('Save translations')); _locale_rebuild_js('fr'); $locale_javascripts = variable_get('locale_translation_javascript', array()); @@ -2214,8 +2358,8 @@ class LocaleUILanguageNegotiationTest extends DrupalWebTestCase { // Should find the string and now click edit to post translated string. $this->clickLink('edit'); $edit = array( - "translations[$langcode_browser_fallback]" => $language_browser_fallback_string, - "translations[$langcode]" => $language_string, + "translations[$langcode_browser_fallback][0]" => $language_browser_fallback_string, + "translations[$langcode][0]" => $language_string, ); $this->drupalPost(NULL, $edit, t('Save translations')); diff --git a/core/modules/simpletest/tests/upgrade/drupal-7.language.database.php b/core/modules/simpletest/tests/upgrade/drupal-7.language.database.php index dfe37fa38e4ad03896145513ac70f9583a29d5e7..99dc318fd0c5509db603fa886b65db22993f260f 100644 --- a/core/modules/simpletest/tests/upgrade/drupal-7.language.database.php +++ b/core/modules/simpletest/tests/upgrade/drupal-7.language.database.php @@ -183,6 +183,19 @@ 'weight' => '0', 'javascript' => '', )) +->values(array( + 'language' => 'hr', + 'name' => 'Croatian', + 'native' => 'Hrvatski', + 'direction' => '0', + 'enabled' => '1', + 'plurals' => '3', + 'formula' => '((($n%10)==1)&&(($n%100)!=11))?(0):((((($n%10)>=2)&&(($n%10)<=4))&&((($n%100)<10)||(($n%100)>=20)))?(1):2));', + 'domain' => '', + 'prefix' => '', + 'weight' => '0', + 'javascript' => '', +)) ->execute(); // Add locales_source table from locale.install schema and fill with some @@ -413,6 +426,30 @@ 'context' => '', 'version' => 'none', )) +->values(array( + 'lid' => '22', + 'location' => '', + 'textgroup' => 'default', + 'source' => '1 byte', + 'context' => '', + 'version' => 'none', +)) +->values(array( + 'lid' => '23', + 'location' => '', + 'textgroup' => 'default', + 'source' => '@count bytes', + 'context' => '', + 'version' => 'none', +)) +->values(array( + 'lid' => '24', + 'location' => '', + 'textgroup' => 'default', + 'source' => '@count[2] bytes', + 'context' => '', + 'version' => 'none', +)) ->execute(); // Add locales_target table from locale.install schema. @@ -472,6 +509,49 @@ 'module' => 'locale', 'name' => 'locales_target', )); +db_insert('locales_target')->fields(array( + 'lid', + 'translation', + 'language', + 'plid', + 'plural', +)) +->values(array( + 'lid' => 22, + 'translation' => '1 byte', + 'language' => 'ca', + 'plid' => 0, + 'plural' => 0, +)) +->values(array( + 'lid' => 23, + 'translation' => '@count bytes', + 'language' => 'ca', + 'plid' => 22, + 'plural' => 1, +)) +->values(array( + 'lid' => 22, + 'translation' => '@count bajt', + 'language' => 'hr', + 'plid' => 0, + 'plural' => 0, +)) +->values(array( + 'lid' => 23, + 'translation' => '@count bajta', + 'language' => 'hr', + 'plid' => 22, + 'plural' => 1, +)) +->values(array( + 'lid' => 24, + 'translation' => '@count[2] bajtova', + 'language' => 'hr', + 'plid' => 23, + 'plural' => 2, +)) +->execute(); // Set up variables needed for language support. db_insert('variable')->fields(array( diff --git a/core/modules/simpletest/tests/upgrade/upgrade.language.test b/core/modules/simpletest/tests/upgrade/upgrade.language.test index feae8f821486911a6f51800f31c217c06502ef8e..ecbbd12229a9c264b9e222dc39c963940b4dd276 100644 --- a/core/modules/simpletest/tests/upgrade/upgrade.language.test +++ b/core/modules/simpletest/tests/upgrade/upgrade.language.test @@ -104,6 +104,23 @@ class LanguageUpgradePathTestCase extends UpgradePathTestCase { // renamed. $current_weights = variable_get('locale_language_negotiation_methods_weight_language_interface', array()); $this->assertTrue(serialize($expected_weights) == serialize($current_weights), t('Language negotiation method weights upgraded.')); + + // Look up migrated plural string. + $source_string = db_query('SELECT * FROM {locales_source} WHERE lid = 22')->fetchObject(); + $this->assertEqual($source_string->source, implode(LOCALE_PLURAL_DELIMITER, array('1 byte', '@count bytes'))); + + $translation_string = db_query("SELECT * FROM {locales_target} WHERE lid = 22 AND language = 'hr'")->fetchObject(); + $this->assertEqual($translation_string->translation, implode(LOCALE_PLURAL_DELIMITER, array('@count bajt', '@count bajta', '@count bajtova'))); + $this->assertTrue(!isset($translation_string->plural), 'Chained plural indicator removed.'); + $this->assertTrue(!isset($translation_string->plid), 'Chained plural indicator removed.'); + + $source_string = db_query('SELECT * FROM {locales_source} WHERE lid IN (23, 24)')->fetchObject(); + $this->assertTrue(empty($source_string), 'Individual plural variant source removed'); + $translation_string = db_query("SELECT * FROM {locales_target} WHERE lid IN (23, 24)")->fetchObject(); + $this->assertTrue(empty($translation_string), 'Individual plural variant translation removed'); + + $translation_string = db_query("SELECT * FROM {locales_target} WHERE lid = 22 AND language = 'ca'")->fetchObject(); + $this->assertEqual($translation_string->translation, implode(LOCALE_PLURAL_DELIMITER, array('1 byte', '@count bytes'))); } /**