diff --git a/includes/locale.inc b/includes/locale.inc index c168da0..39652c0 100644 --- a/includes/locale.inc +++ b/includes/locale.inc @@ -119,6 +119,15 @@ function locale_language_from_interface() { /** * Identify language from the Accept-language HTTP header we got. * + * The algorithm works as follows: + * - map browser language codes to Drupal language codes. + * - order all browser language codes by qvalue from high to low. + * - add generic browser language codes if they aren't already specified + * but with a slightly lower qvalue. + * - find the most specific Drupal language code with the highest qvalue. + * - if 2 or more languages are having the same qvalue, respect the order of + * them inside the $languages array. + * * We perform browser accept-language parsing only if page cache is disabled, * otherwise we would cache a user-specific preference. * @@ -142,7 +151,18 @@ function locale_language_from_browser($languages) { // Samples: "hu, en-us;q=0.66, en;q=0.33", "hu,en-us;q=0.5" $browser_langcodes = array(); if (preg_match_all('@(?<=[, ]|^)([a-zA-Z-]+|\*)(?:;q=([0-9.]+))?(?:$|\s*,\s*)@', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']), $matches, PREG_SET_ORDER)) { + // Load custom mappings to support browsers that are sending non standard + // language codes. + $mappings = variable_get('locale_language_mappings', array()); foreach ($matches as $match) { + if ($mappings) { + $langcode = strtolower($match[1]); + foreach ($mappings as $browser_langcode => $drupal_langcode) { + if ($langcode == $browser_langcode) { + $match[1] = $drupal_langcode; + } + } + } // We can safely use strtolower() here, tags are ASCII. // RFC2616 mandates that the decimal part is no more than three digits, // so we multiply the qvalue by 1000 to avoid floating point comparisons. @@ -161,9 +181,23 @@ function locale_language_from_browser($languages) { // http://blogs.msdn.com/b/ie/archive/2006/10/17/accept-language-header-for-internet-explorer-7.aspx asort($browser_langcodes); foreach ($browser_langcodes as $langcode => $qvalue) { - $generic_tag = strtok($langcode, '-'); - if (!isset($browser_langcodes[$generic_tag])) { - $browser_langcodes[$generic_tag] = $qvalue; + // For Chinese languages the generic tag is either zh-hans or zh-hant, so we + // need to handle this separately, we can not split $langcode on the + // first occurence of '-' otherwise we get a non-existing language zh. + // All other languages use a langcode without a '-', so we can safely split + // on the first occurence of it. + $generic_tag = ''; + if (strlen($langcode) > 7 && (substr($langcode, 0, 7) == 'zh-hant' || substr($langcode, 0, 7) == 'zh-hans')) { + $generic_tag = substr($langcode, 0, 7); + } + else { + $generic_tag = strtok($langcode, '-'); + } + if (!empty($generic_tag) && !isset($browser_langcodes[$generic_tag])) { + // Add the generic langcode, but make sure it has a lower qvalue as the + // more specific one, so the more specific one gets selected if it's + // defined by both the browser and Drupal. + $browser_langcodes[$generic_tag] = $qvalue - 0.1; } } diff --git a/modules/locale/locale.admin.inc b/modules/locale/locale.admin.inc index 2535357..cf982b9 100644 --- a/modules/locale/locale.admin.inc +++ b/modules/locale/locale.admin.inc @@ -1436,3 +1436,200 @@ function locale_date_format_reset_form_submit($form, &$form_state) { ->execute(); $form_state['redirect'] = 'admin/config/regional/date-time/locale'; } + +/** + * Builds the browser language negotiation method configuration form. + */ +function language_negotiation_configure_browser_form($form, &$form_state) { + $form = array(); + + // Initialize a language list to the ones available, including English. + $languages = language_list(); + + $existing_languages = array(); + foreach ($languages as $langcode => $language) { + $existing_languages[$langcode] = $language->name; + } + + // If we have no languages available, present the list of predefined languages + // only. If we do have already added languages, set up two option groups with + // the list of existing and then predefined languages. + if (empty($existing_languages)) { + $language_options = language_list(); + $default = key($language_options); + } + else { + $default = key($existing_languages); + $language_options = array( + t('Existing languages') => $existing_languages, + t('Languages not yet added') => _locale_prepare_predefined_list(), + ); + } + + $form['mappings'] = array( + '#tree' => TRUE, + '#theme' => 'language_negotiation_configure_browser_form_table', + ); + + $mappings = language_get_browser_drupal_langcode_mappings(); + foreach ($mappings as $browser_langcode => $drupal_langcode) { + $form['mappings'][$browser_langcode] = array( + 'browser_langcode' => array( + '#type' => 'textfield', + '#default_value' => $browser_langcode, + '#size' => 20, + '#required' => TRUE, + ), + 'drupal_langcode' => array( + '#type' => 'select', + '#options' => $language_options, + '#default_value' => $drupal_langcode, + '#required' => TRUE, + ), + ); + } + + // Add empty row. + $form['new_mapping'] = array( + '#type' => 'fieldset', + '#title' => t('Add a new mapping'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#tree' => TRUE, + ); + $form['new_mapping']['browser_langcode'] = array( + '#type' => 'textfield', + '#title' => t('Browser language code'), + '#description' => t('Use language codes as defined by the W3C for interoperability. Examples: "en", "en-gb" and "zh-hant".', array('@w3ctags' => 'http://www.w3.org/International/articles/language-tags/')), + '#default_value' => '', + '#size' => 20, + ); + $form['new_mapping']['drupal_langcode'] = array( + '#type' => 'select', + '#title' => t('Drupal language'), + '#options' => $language_options, + '#default_value' => '', + ); + + $form['actions']['#type'] = 'actions'; + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Save configuration'), + ); + + return $form; +} + +/** + * Theme browser configuration form as table. + * + * @param $variables + * An associative array containing: + * - form: A render element representing the form. + * + * @ingroup themeable + */ +function theme_language_negotiation_configure_browser_form_table($variables) { + $form = $variables['form']; + $rows = array(); + $link_attributes = array( + 'attributes' => array( + 'class' => array('image-style-link'), + ), + ); + foreach (element_children($form, TRUE) as $key) { + $row = array(); + $row[] = drupal_render($form[$key]['browser_langcode']); + $row[] = drupal_render($form[$key]['drupal_langcode']); + $row[] = l(t('Delete'), 'admin/config/regional/language/configure/browser/delete/' . $key, $link_attributes); + + $rows[] = array( + 'data' => $row, + ); + } + + $header = array( + t('Browser language code'), + t('Drupal language'), + t('Operations'), + ); + + $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'lang-neg-browser'))); + + return $output; +} + +/** + * Browser language negotiation form validation. + */ +function language_negotiation_configure_browser_form_validate($form, &$form_state) { + // Array to check if all browser language codes are unique. + $unique_values = array(); + + // Check all mappings. + $mappings = array(); + if (isset($form_state['values']['mappings'])) { + $mappings = $form_state['values']['mappings']; + foreach ($mappings as $key => $data) { + // Make sure browser_langcode is unique. + if (array_key_exists($data['browser_langcode'], $unique_values)) { + form_set_error('mappings][' . $key . '][browser_langcode', t('Browser language codes must be unique.')); + } + elseif (preg_match('/[^a-z\-]/', $data['browser_langcode'])) { + form_set_error('mappings][' . $key . '][browser_langcode', t('Browser language codes can only contain lowercase letters and a hyphen(-).')); + } + $unique_values[$data['browser_langcode']] = $data['drupal_langcode']; + } + } + + // Check new mapping. + $data = $form_state['values']['new_mapping']; + if (!empty($data['browser_langcode'])) { + // Make sure browser_langcode is unique. + if (array_key_exists($data['browser_langcode'], $unique_values)) { + form_set_error('mappings][' . $key . '][browser_langcode', t('Browser language codes must be unique.')); + } + elseif (preg_match('/[^a-z\-]/', $data['browser_langcode'])) { + form_set_error('mappings][' . $key . '][browser_langcode', t('Browser language codes can only contain lowercase letters and a hyphen(-).')); + } + $unique_values[$data['browser_langcode']] = $data['drupal_langcode']; + } + + $form_state['mappings'] = $unique_values; +} + +/** + * Browser language negotiation form submit. + */ +function language_negotiation_configure_browser_form_submit($form, &$form_state) { + $mappings = $form_state['mappings']; + if (!empty($mappings)) { + language_set_browser_drupal_langcode_mappings($mappings); + } + $form_state['redirect'] = 'admin/config/regional/language/configure'; +} + +/** + * Form for deleting a browser language negotiation mapping. + */ +function language_negotiation_configure_browser_delete_form($form, &$form_state, $browser_langcode) { + $form_state['browser_langcode'] = $browser_langcode; + $question = t('Are you sure you want to delete %browser_langcode?', array( + '%browser_langcode' => $browser_langcode, + )); + $path = 'admin/config/regional/language/configure/browser'; + return confirm_form($form, $question, $path, ''); +} + +/** + * Form submit handler to delete a browser language negotiation mapping. + */ +function language_negotiation_configure_browser_delete_form_submit($form, &$form_state) { + $browser_langcode = $form_state['browser_langcode']; + $mappings = language_get_browser_drupal_langcode_mappings(); + if (array_key_exists($browser_langcode, $mappings)) { + unset($mappings[$browser_langcode]); + language_set_browser_drupal_langcode_mappings($mappings); + } + $form_state['redirect'] = 'admin/config/regional/language/configure/browser'; +} \ No newline at end of file diff --git a/modules/locale/locale.install b/modules/locale/locale.install index b4db757..0b36853 100644 --- a/modules/locale/locale.install +++ b/modules/locale/locale.install @@ -24,6 +24,17 @@ function locale_install() { 'javascript' => '', )) ->execute(); + + // Set default mappings for Chinese languages. + language_set_browser_drupal_langcode_mappings(array( + 'zh-tw' => 'zh-hant', // Taiwan Chinese in traditional script + 'zh-hk' => 'zh-hant', // Hong Kong Chinese in traditional script + 'zh-mo' => 'zh-hant', // Macao Chinese in traditional script + 'zh-cht' => 'zh-hant', // traditional Chinese + 'zh-cn' => 'zh-hans', // PRC Mainland Chinese in simplified script + 'zh-sg' => 'zh-hans', // Singapore Chinese in simplified script + 'zh-chs' => 'zh-hans', // simplified Chinese + )); } /** @@ -215,6 +226,21 @@ function locale_update_7005() { } /** + * Set default mappings for Chinese languages. + */ +function locale_update_7006() { + language_set_browser_drupal_langcode_mappings(array( + 'zh-tw' => 'zh-hant', // Taiwan Chinese in traditional script + 'zh-hk' => 'zh-hant', // Hong Kong Chinese in traditional script + 'zh-mo' => 'zh-hant', // Macao Chinese in traditional script + 'zh-cht' => 'zh-hant', // traditional Chinese + 'zh-cn' => 'zh-hans', // PRC Mainland Chinese in simplified script + 'zh-sg' => 'zh-hans', // Singapore Chinese in simplified script + 'zh-chs' => 'zh-hans', // simplified Chinese + )); +} + +/** * @} End of "addtogroup updates-7.x-extra". */ @@ -251,6 +277,7 @@ function locale_uninstall() { variable_del('javascript_parsed'); variable_del('locale_field_language_fallback'); variable_del('locale_cache_length'); + variable_del('locale_language_mappings'); foreach (language_types() as $type) { variable_del("language_negotiation_$type"); diff --git a/modules/locale/locale.module b/modules/locale/locale.module index 94e7cd1..f05dbfb 100644 --- a/modules/locale/locale.module +++ b/modules/locale/locale.module @@ -47,6 +47,9 @@ function locale_help($path, $arg) { case 'admin/config/regional/language/configure/session': $output = '

' . t('Determine the language from a request/session parameter. Example: "http://example.com?language=de" sets language to German based on the use of "de" within the "language" parameter.') . '

'; return $output; + case 'admin/config/regional/language/configure/browser': + $output = '

' . t('Browsers use different language codes to refer to the same languages. You can add and edit mappings from browser language codes to the languages used by Drupal.', array('@configure-languages' => url('admin/config/regional/language'))) . '

'; + return $output; case 'admin/config/regional/translate': $output = '

' . t('This page provides an overview of available translatable strings. Drupal displays translatable strings in text groups; modules may define additional text groups containing other translatable strings. Because text groups provide a method of grouping related strings, they are often used to focus translation efforts on specific areas of the Drupal interface.') . '

'; $output .= '

' . t('See the Languages page for more information on adding support for additional languages.', array('@languages' => url('admin/config/regional/language'))) . '

'; @@ -214,7 +217,21 @@ function locale_menu() { 'access arguments' => array('administer site configuration'), 'file' => 'locale.admin.inc', ); - + $items['admin/config/regional/language/configure/browser'] = array( + 'title' => 'Browser language detection configuration', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('language_negotiation_configure_browser_form'), + 'access arguments' => array('administer languages'), + 'file' => 'locale.admin.inc', + 'type' => MENU_VISIBLE_IN_BREADCRUMB, + ); + $items['admin/config/regional/language/configure/browser/delete/%'] = array( + 'title' => 'Delete language mapping', + 'page arguments' => array('language_negotiation_configure_browser_delete_form', 7), + 'type' => MENU_CALLBACK, + 'access arguments' => array('administer languages'), + 'file' => 'locale.admin.inc', + ); return $items; } @@ -428,6 +445,10 @@ function locale_theme() { 'locale_date_format_form' => array( 'render element' => 'form', ), + 'language_negotiation_configure_browser_form_table' => array( + 'render element' => 'form', + 'file' => 'locale.admin.inc', + ), ); } @@ -573,6 +594,7 @@ function locale_language_negotiation_info() { 'cache' => 0, 'name' => t('Browser'), 'description' => t("Determine the language from the browser's language settings."), + 'config' => 'admin/config/regional/language/configure/browser', ); $providers[LOCALE_LANGUAGE_NEGOTIATION_INTERFACE] = array( @@ -1072,3 +1094,25 @@ function locale_form_comment_form_alter(&$form, &$form_state, $form_id) { $form['language']['#value'] = $language_content->language; } } + +/** + * Returns language mappings between browser and Drupal language codes. + * + * @return array + * An array containing browser language codes as keys with corresponding + * Drupal language codes as values. + */ +function language_get_browser_drupal_langcode_mappings() { + return variable_get('locale_language_mappings', array()); +} + +/** + * Stores language mappings between browser and Drupal language codes. + * + * @param array $mappings + * An array containing browser language codes as keys with corresponding + * Drupal language codes as values. + */ +function language_set_browser_drupal_langcode_mappings($mappings) { + variable_set('locale_language_mappings', $mappings); +} diff --git a/modules/locale/locale.test b/modules/locale/locale.test index 632506e..e8f15ab 100644 --- a/modules/locale/locale.test +++ b/modules/locale/locale.test @@ -1637,7 +1637,9 @@ class LocaleLanguageSwitchingFunctionalTest extends DrupalWebTestCase { /** * Test browser language detection. */ -class LocaleBrowserDetectionTest extends DrupalUnitTestCase { +class LocaleBrowserDetectionTest extends DrupalWebTestCase { + + public static $modules = array('locale'); public static function getInfo() { return array( @@ -1647,12 +1649,18 @@ class LocaleBrowserDetectionTest extends DrupalUnitTestCase { ); } + function setUp() { + parent::setUp('locale'); + } + /** * Unit tests for the locale_language_from_browser() function. */ function testLanguageFromBrowser() { - // Load the required functions. - require_once DRUPAL_ROOT . '/includes/locale.inc'; + // The order of the languages is only important if the browser language + // codes are having the same qvalue, otherwise the one with the highest + // qvalue is prefered. The automatically generated generic tags are always + // having a lower qvalue. $languages = array( // In our test case, 'en' has priority over 'en-US'. @@ -1682,6 +1690,16 @@ class LocaleBrowserDetectionTest extends DrupalUnitTestCase { 'eh-oh-laa-laa' => (object) array( 'language' => 'eh-oh-laa-laa', ), + // Chinese languages. + 'zh-hans' => (object) array( + 'language' => 'zh-hans', + ), + 'zh-hant' => (object) array( + 'language' => 'zh-hant', + ), + 'zh-hant-tw' => (object) array( + 'language' => 'zh-hant', + ), ); $test_cases = array( @@ -1690,8 +1708,8 @@ class LocaleBrowserDetectionTest extends DrupalUnitTestCase { 'en-US,en,fr-CA,fr,es-MX' => 'en', 'fr,en' => 'en', 'en,fr' => 'en', - 'en-US,fr' => 'en', - 'fr,en-US' => 'en', + 'en-US,fr' => 'en-US', + 'fr,en-US' => 'en-US', 'fr,fr-CA' => 'fr-CA', 'fr-CA,fr' => 'fr-CA', 'fr' => 'fr-CA', @@ -1744,6 +1762,21 @@ class LocaleBrowserDetectionTest extends DrupalUnitTestCase { 'de,pl' => FALSE, 'iecRswK4eh' => FALSE, $this->randomName(10) => FALSE, + + // Chinese langcodes. + 'zh-cn, en-us;q=0.90, en;q=0.80, zh;q=0.70' => 'zh-hans', + 'zh-tw, en-us;q=0.90, en;q=0.80, zh;q=0.70' => 'zh-hant', + 'zh-hant, en-us;q=0.90, en;q=0.80, zh;q=0.70' => 'zh-hant', + 'zh-hans, en-us;q=0.90, en;q=0.80, zh;q=0.70' => 'zh-hans', + 'zh-cn' => 'zh-hans', + 'zh-sg' => 'zh-hans', + 'zh-tw' => 'zh-hant', + 'zh-hk' => 'zh-hant', + 'zh-mo' => 'zh-hant', + 'zh-hans' => 'zh-hans', + 'zh-hant' => 'zh-hant', + 'zh-chs' => 'zh-hans', + 'zh-cht' => 'zh-hant', ); foreach ($test_cases as $accept_language => $expected_result) { @@ -1752,6 +1785,72 @@ class LocaleBrowserDetectionTest extends DrupalUnitTestCase { $this->assertIdentical($result, $expected_result, t("Language selection '@accept-language' selects '@result', result = '@actual'", array('@accept-language' => $accept_language, '@result' => $expected_result, '@actual' => isset($result) ? $result : 'none'))); } } + + /** + * Tests for adding, editing and deleting mappings between browser language + * codes and Drupal language codes. + */ + function testUIBrowserLanguageMappings() { + // User to manage languages. + $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages')); + $this->drupalLogin($admin_user); + + // Check that the configure link exists. + $this->drupalGet('admin/config/regional/language/configure'); + $this->assertLinkByHref('admin/config/regional/language/configure/browser'); + + // Check that defaults are loaded from language.mappings.yml. + $this->drupalGet('admin/config/regional/language/configure/browser'); + $this->assertField('edit-mappings-zh-cn-browser-langcode', 'zh-cn', 'Chinese browser language code found.'); + $this->assertField('edit-mappings-zh-cn-drupal-langcode', 'zh-hans-cn', 'Chinese Drupal language code found.'); + + // Delete zh-cn language code. + $browser_langcode = 'zh-cn'; + $this->drupalGet('admin/config/regional/language/configure/browser/delete/' . $browser_langcode); + $message = t('Are you sure you want to delete @browser_langcode?', array( + '@browser_langcode' => $browser_langcode, + )); + $this->assertRaw($message); + + // Confirm the delete. + $edit = array(); + $this->drupalPost('admin/config/regional/language/configure/browser/delete/' . $browser_langcode, $edit, t('Confirm')); + + // Check that ch-zn no longer exists. + $this->assertNoField('edit-mappings-zh-cn-browser-langcode', 'Chinese browser language code no longer exists.'); + + // Add a new custom mapping. + $edit = array( + 'new_mapping[browser_langcode]' => 'xx', + 'new_mapping[drupal_langcode]' => 'en', + ); + $this->drupalPost('admin/config/regional/language/configure/browser', $edit, t('Save configuration')); + $this->drupalGet('admin/config/regional/language/configure/browser'); + $this->assertField('edit-mappings-xx-browser-langcode', 'xx', 'Browser language code found.'); + $this->assertField('edit-mappings-xx-drupal-langcode', 'en', 'Drupal language code found.'); + + // Add the same custom mapping again. + $this->drupalPost('admin/config/regional/language/configure/browser', $edit, t('Save configuration')); + $this->assertText('Browser language codes must be unique.'); + + // Change browser language code of our custom mapping to zh-sg. + $edit = array( + 'mappings[xx][browser_langcode]' => 'zh-sg', + 'mappings[xx][drupal_langcode]' => 'en', + ); + $this->drupalPost('admin/config/regional/language/configure/browser', $edit, t('Save configuration')); + $this->assertText(t('Browser language codes must be unique.')); + + // Change Drupal language code of our custom mapping to zh-hans. + $edit = array( + 'mappings[xx][browser_langcode]' => 'xx', + 'mappings[xx][drupal_langcode]' => 'zh-hans', + ); + $this->drupalPost('admin/config/regional/language/configure/browser', $edit, t('Save configuration')); + $this->drupalGet('admin/config/regional/language/configure/browser'); + $this->assertField('edit-mappings-xx-browser-langcode', 'xx', 'Browser language code found.'); + $this->assertField('edit-mappings-xx-drupal-langcode', 'zh-hans', 'Drupal language code found.'); + } } /**