Skip to content
i18n_string.module 25.7 KiB
Newer Older
<?php

/**
 * @file
 * Internationalization (i18n) package - translatable strings.
 *
 * Object oriented string translation using locale and textgroups. As opposed to core locale strings,
 * all strings handled by this module will have a unique id (name), composed by several parts
 *
 * A string name or string id will have the form 'textgroup:type:objectid:property'. Examples:
 *
 * - 'profile:field:profile_name:title', will be the title for the profile field 'profile_name'
 * - 'taxonomy:term:tid:name', will be the name for the taxonomy term tid
 * - 'views:view_name:display_id:footer', footer text
 *
 * Notes:
 * - The object id must be an integer. This is intended for quick indexing of some properties
 *
 * Some concepts
 * - Textgroup. Group the string belongs to, defined by locale hook.
 * - Location. Unique id of the string for this textgroup.
 * - Name. Unique absolute id of the string: textgroup + location.
 * - Context. Object with textgroup, type, objectid, property.
 *
 * Default language
 * - Default language may be English or not. It will be the language set as default.
 *   Source strings will be stored in default language.
 * - In the traditional i18n use case you shouldn't change the default language once defined.
 *
 * Default language changes
 * - You might result in the need to change the default language at a later point.
 * - Enabling translation of default language will curcumvent previous limitations.
 * - Check i18n_string_translate_langcode() for more details.
 *
 * The API other modules to translate/update/remove user defined strings consists of
 *
 * @see i18n_string($name, $string, $langcode)
 * @see i18n_string_update($name, $string, $format)
 * @see i18n_string_remove($name, $string)
 *
 * @author Jose A. Reyero, 2007
 */

/**
 * Translated string is current.
 */
define('I18N_STRING_STATUS_CURRENT', 0);

/**
 * Translated string needs updating as the source has been edited.
 */
define('I18N_STRING_STATUS_UPDATE', 1);

/**
 * Source string is obsoleted, cannot be found anymore. To be deleted.
 */
define('I18N_STRING_STATUS_DELETE', 2);

/**
 * Get textgroup handler
 */
function i18n_string_textgroup($textgroup) {
  $groups = &drupal_static(__FUNCTION__);
  if (!isset($groups[$textgroup])) {
    $class = i18n_string_group_info($textgroup, 'class', 'i18n_string_default');
    $groups[$textgroup] = new $class($textgroup);
  }
  return $groups[$textgroup];
}

/**
 * Get list of allowed formats for text translation
 */
function i18n_string_allowed_formats() {
  return variable_get('i18n_string_allowed_formats', array(filter_fallback_format()));
}

/**
 * Implements hook_help().
 */
function i18n_string_help($path, $arg) {
  switch ($path) {
    case 'admin/help#i18n_string':
      $output = '<p>' . t('This module adds support for other modules to translate user defined strings. Depending on which modules you have enabled that use this feature you may see different text groups to translate.') . '<p>';
      $output .= '<p>' . t('This works differently to Drupal standard localization system: The strings will be translated from the default language (which may not be English), so changing the default language may cause all these translations to be broken.') . '</p>';
      $output .= '<ul>';
      $output .= '<li>' . t('To search and translate strings, use the <a href="@translate-interface">translation interface</a> pages.', array('@translate-interface' => url('admin/config/regional/translate'))) . '</li>';
      $output .= '<li>' . t('If you are missing strings to translate, use the <a href="@refresh-strings">refresh strings</a> page.', array('@refresh-strings' => url('admin/build/translate/refresh'))) . '</li>';
      $output .= '</ul>';
      $output .= '<p>' . t('Read more on the <em>Internationalization handbook</em>: <a href="http://drupal.org/node/313293">Translating user defined strings</a>.') . '</p>';
      return $output;

    case 'admin/build/translate/refresh':
      $output = '<p>' . t('On this page you can refresh and update values for user defined strings.') . '</p>';
      $output .= '<ul>';
      $output .= '<li>' . t('Use the refresh option when you are missing strings to translate for a given text group. All the strings will be re-created keeping existing translations.') . '</li>';
      $output .= '<li>' . t('Use the update option when some of the strings had been previously translated with the localization system, but the translations are not showing up for the configurable strings.') . '</li>';
      $output .= '</ul>';
      $output .= '<p>' . t('To search and translate strings, use the <a href="@translate-interface">translation interface</a> pages.', array('@translate-interface' => url('admin/config/regional/translate'))) . '</p>';
      $output .= '<p>' . t('<strong>Important:</strong> To configure which text formats are safe for translation, visit the <a href="@configure-strings">configure strings</a> page before refreshing your strings.', array('@configure-strings' => url('admin/config/regional/i18n/strings'))) . '</p>';
      return $output;

    case 'admin/config/language':
      $output = '<p>' . t('<strong>Warning</strong>: Changing the default language may have unwanted effects on string translations. Read more about <a href="@i18n_string-help">String translation</a>', array('@i18n_string-help' => url('admin/help/i18n_string'))) . '</p>';
      return $output;
    case 'admin/config/regional/i18n/strings':
      $output = '<p>' . t('When translating user defined strings that have a text format associated, translators will be able to edit the text before it is filtered which may be a security risk for some filters. An obvious example is when using the PHP filter but other filters may also be dangerous.') . '</p>';
      $output .= '<p>' . t('As a general rule <strong>do not allow any filtered text to be translated unless the translators already have access to that text format</strong>. However if you are doing all your translations through this site\'s translation UI or the Localization client, and never importing translations for other textgroups than <i>default</i>, filter access will be checked for translators on every translation page.') . '</p>';
      $output .= '<p>' . t('<strong>Important:</strong> After disallowing some text format, use the <a href="@refresh-strings">refresh strings</a> page so forbidden strings are deleted and not allowed anymore for translators.', array('@refresh-strings' => url('admin/config/regional/translate/i18n_string'))) . '</p>';
      return $output;
    case 'admin/config/filters':
      return '<p>' . t('After updating your text formats do not forget to review the list of formats allowed for string translations on the <a href="@configure-strings">configure translatable strings</a> page.', array('@configure-strings' => url('admin/config/regional/i18n/strings'))) . '</p>';
  }
}

/**
 * Implements hook_menu().
 */
function i18n_string_menu() {
  $items['admin/config/regional/translate/i18n_string'] = array(
    'title' => 'Strings',
    'description' => 'Refresh user defined strings.',
    'weight' => 20,
    'type' => MENU_LOCAL_TASK,
    'page callback' => 'drupal_get_form',
    'page arguments' => array('i18n_string_admin_refresh_form'),
    'file' => 'i18n_string.admin.inc',
    'access arguments' => array('translate interface'),
  );
  $items['admin/config/regional/i18n/strings'] = array(
    'title' => 'Strings',
    'description' => 'Options for user defined strings.',
    'weight' => 20,
    'type' => MENU_LOCAL_TASK,
    'page callback' => 'drupal_get_form',
    'page arguments' => array('i18n_string_admin_settings'),
    'file' => 'i18n_string.admin.inc',
    'access arguments' => array('administer filters'),
  );

  // AJAX callback path for strings.
  $items['i18n_string/save'] = array(
    'title' => 'Save string',
    'page callback' => 'i18n_string_l10n_client_save_string',
    'access arguments' => array('use on-page translation'),
    'file' => 'i18n_string.inc',
    'type' => MENU_CALLBACK,
  );
  return $items;
}

/**
 * Implements hook_form_alter().
 *
 * Add English language in some string forms when it is not the default.
 */
function i18n_string_form_alter(&$form, &$form_state, $form_id) {
  switch ($form_id) {
    case 'locale_translate_export_po_form':
    case 'locale_translate_import_form':
      $names = locale_language_list('name', TRUE);
      if (language_default('language') != 'en' && array_key_exists('en', $names)) {
        if (isset($form['export'])) {
          $form['export']['langcode']['#options']['en'] = $names['en'];
        }
        else {
          $form['import']['langcode']['#options'][t('Already added languages')]['en'] = $names['en'];
        }
      }
      break;

    case 'locale_translate_edit_form':
      // Restrict filter permissions and handle validation and submission for i18n strings
      $context = db_select('i18n_strings', 'i18ns')
        ->fields('i18ns')
        ->condition('lid', $form['lid']['#value'])
        ->execute()
        ->fetchObject();
      if ($context) {
        $form['i18n_string_context'] = array('#type' => 'value', '#value' => $context);
        // Replace validate callback
        $form['#validate'] = array('i18n_string_translate_edit_form_validate');
        if ($context->format) {
          $formats = filter_formats();
          $format = $formats[$context->format];
          $disabled = !filter_access($format);
            drupal_set_message(t('This string uses the %name text format. You are not allowed to translate or edit texts with this format.', array('%name' => $format->name)), 'warning');
          }
          foreach (element_children($form['translations']) as $langcode) {
            $form['translations'][$langcode]['#disabled'] = $disabled;
          }
          $form['translations']['format_help'] = array(
            '#type' => 'item',
            '#title' => t('Text format: @name', array('@name' => $format->name)),
            '#value' => theme('filter_tips', _filter_tips($context->format, FALSE))
          );
          $form['submit']['#disabled'] = $disabled;
        }
      }
      // Aditional submit callback
      $form['#submit'][] = 'i18n_string_translate_edit_form_submit';
      break;
    case 'l10n_client_form':
      $form['#action'] = url('i18n_string/save');
      break;
  }
}

/**
 * Process string editing form validations.
 *
 * If it is an allowed format, skip default validation, the text will be filtered later
 */
function i18n_string_translate_edit_form_validate($form, &$form_state) {
  $context = $form_state['values']['i18n_string_context'];
  if (empty($context->format)) {
    // If not text format use regular validation for all strings
    $copy_state = $form_state;
    $copy_state['values']['textgroup'] = 'default';
    locale_translate_edit_form_validate($form, $copy_state);
  }
  else {
    $formats = filter_formats();
    if (!filter_access($formats[$context->format])) {
      form_set_error('translations', t('You are not allowed to translate or edit texts with this text format.'));
    }
  }
}

/**
 * Process string editing form submissions.
 *
 * Mark translations as current.
 */
function i18n_string_translate_edit_form_submit($form, &$form_state) {
  $lid = $form_state['values']['lid'];
  foreach ($form_state['values']['translations'] as $key => $value) {
    if (!empty($value)) {
      // An update has been made, so we assume the translation is now current.
      db_update('locales_target')
        ->fields(array('i18n_status' => I18N_STRING_STATUS_CURRENT))
        ->condition('lid', $lid)
        ->condition('language', $key)
        ->execute();
    }
  }
}

/**
 * Check if translation is required for this language code.
 *
 * Translation is required when default language is different from the given
 * language, or when default language translation is explicitly enabled.
 *
 * No UI is provided to enable translation of default language. On the other
 * hand, you can enable/disable translation for a specific language by adding
 * the following to your settings.php
 *
 * @param $langcode
 * @param $reset
 *   Whether to reset the internal cache for the translated langcode.
 *
 * @code
 *   // Enable translation of specific language. Language code is 'xx'
 *   $conf['i18n_string_translate_langcode_xx'] = TRUE;
 *   // Disable translation of specific language. Language code is 'yy'
 *   $conf['i18n_string_translate_langcode_yy'] = FALSE;
 * @endcode
 */
function i18n_string_translate_langcode($langcode, $reset = FALSE) {
  $translate = &drupal_static(__FUNCTION__ , array(), $reset);
  if (!isset($translate[$langcode])) {
    $translate[$langcode] = variable_get('i18n_string_translate_langcode_' . $langcode, language_default('language') != $langcode);
  }
  return $translate[$langcode];
}

/**
 * Update / create translation source for user defined strings.
 *
 * @param $name
 *   Textgroup and location glued with ':'.
 * @param $string
 *   Source string in default language. Default language may or may not be English.
 *   Array of key => string to update multiple strings at once
 * @param $options
 *   Array with additional options:
 *   - 'format', String format if the string has text format
 *   - 'messages', Whether to print out status messages
 */
function i18n_string_update($name, $string, $options = array()) {
  if (is_array($string)) {
    return i18n_string_multiple('update', $name, $string, $options);
  }
  else {
    list($textgroup, $context) = i18n_string_context($name);
    return i18n_string_textgroup($textgroup)->update($context, $string, $options);
  }
}

/**
 * Update context for strings.
 *
 * As some string locations depend on configurable values, the field needs sometimes to be updated
 * without losing existing translations. I.e:
 * - profile fields indexed by field name.
 * - content types indexted by low level content type name.
 *
 * Example:
 *  'profile:field:oldfield:*' -> 'profile:field:newfield:*'
 */
function i18n_string_update_context($oldname, $newname) {
  return i18n_string_default::update_context($oldname, $newname);
}

/**
 * Convert string name into textgroup and string context
 * 
 * @param $name
 *   Array or string concatenated with ':' that contains textgroup and string context
 * @param $replace
 *   Parameter to replace the placeholder ('*') if we are dealing with multiple strings
 *   Or parameter to append to context if there's no placeholder
 * 
 * @return array
 *   The first element will be the text group name
 *   The second one will be an array with string name elements, without textgroup
 */
function i18n_string_context($name, $replace = NULL) {
  $parts = is_array($name) ? $name : explode(':', $name);
  if ($replace) {
    $key = array_search('*', $parts);
    if ($key !== FALSE) {
      $parts[$key] = $replace;
    }
    elseif (count($parts) < 4) {
      array_push($parts, $replace);
    }
  }
  $textgroup = array_shift($parts);
  $context = $parts;
  return array($textgroup, $context);
}

/**
 * Mark form element as localizable
 */
function i18n_string_element_mark(&$element) {
  $description = '<strong>'. t('This string will be localizable. You can translate it using the <a href="@translate-interface">translate interface</a> pages.', array('@translate-interface' => url('admin/config/regional/translate/translate'))) .'</strong>';
  if (empty($element['#description'])) {
    $element['#description'] = $description;
  }
  else {
    $element['#description'] .= ' ' . $description;
  }
}

/**
 * Translate / update multiple strings
 * 
 * @param $strings
 *   Array of name => string pairs
 */
function i18n_string_multiple($operation, $name, $strings, $options = array()) {  
  $result = array();
  // Strings may be an array of properties, we need to shift it
  if ($operation == 'remove') {
    $strings = array_flip($strings);
  }
  foreach ($strings as $key => $string) {
    list($textgroup, $context) = i18n_string_context($name, $key);
    array_unshift($context, $textgroup);
    $result[$key] = call_user_func('i18n_string_' . $operation, $context, $string, $options);
  }
  return $result;
}

/**
 * Get textgroup info, from hook_locale('info')
 *
 * @param $group
 *   Text group name.
 * @param $default
 *   Default value to return for a property if not set.
 */
function i18n_string_group_info($group = NULL, $property = NULL, $default = NULL) {
  $info = &drupal_static(__FUNCTION__ , NULL);

  if (!isset($info)) {
    $info = module_invoke_all('i18n_string_info');
    drupal_alter('i18n_string_info', $info);
  }

  if ($group && $property) {
    return isset($info[$group][$property]) ? $info[$group][$property] : $default;
  }
  elseif ($group) {
    return isset($info[$group]) ? $info[$group] : array();
  }
  else {
    return $info;
  }
}

/**
 * Implements hook_locale().
 *
 * Provide the information from i18n_string groups to locale module
 */
function i18n_string_locale($op = 'groups') {
  if ($op == 'groups') {
    $groups = array();
    foreach (i18n_string_group_info() as $name => $info) {
      $groups[$name] = $info['title'];
    }
    return $groups;
  }
}

/**
 * @ingroup i18napi
 * @{
 */

/**
 * Implements hook_modules_enabled()
 */
function i18n_string_modules_enabled($modules) {
  module_load_include('admin.inc', 'i18n_string');
  // Check if any of the modules has strings to update
  foreach ($modules as $module) {
    if ($strings = module_invoke($module, 'i18n_string_list', 'all')) {
      i18n_string_refresh_string_list($strings);
    }
    // Call module refresh if exists
    module_invoke($module, 'i18n_string_refresh', 'all');
  }
}

/**
 * Get translation for user defined string.
 *
 * This function is intended to return translations for plain strings that have NO text format
 *
 * @param $name
 *   Textgroup and context glued with ':'
 * @param $string
 *   String in default language or array of strings to be translated
 * @param $options
 *   An associative array of additional options, with the following keys:
 *   - 'langcode' (defaults to the current language) The language code to translate to a language other than what is used to display the page.
 *   - 'filter' Formatting or filtering callback to apply to the translated string only
 *   - 'callback' Callback to apply to the result (both to translated or untranslated string
 */
function i18n_string_translate($name, $string, $options = array()) {
  $options['langcode'] = i18n_langcode(isset($options['langcode']) ? $options['langcode'] : NULL);
  if (is_array($string)) {
    return i18n_string_translate_list($name, $string, $options);
  }
  elseif (i18n_string_translate_langcode($options['langcode'])) {
    list($textgroup, $context) = i18n_string_context($name);
    $translation = i18n_string_textgroup($textgroup)->translate($context, $string, $options);
    // Add for l10n client if available, we pass translation object that contains the format
    i18n_string_l10n_client_add($translation, $options);
    return i18n_string_format($translation, $options);
  }
  else {
    // If we don't want to translate to this language, format and return
    return i18n_string_format($string, $options);
  }
}

/**
 * Format the resulting translation or the default string applying callbacks
 * 
 * There's a hidden variable, 'i18n_string_debug', that when set to TRUE will display additional info
 */
function i18n_string_format($i18nstring, $options = array()) {
  static $debug;
  if (!isset($debug)) {
    $debug = variable_get('i18n_string_debug', FALSE);
  }
  $options += array('sanitize' => TRUE, 'cache' => FALSE);
  if (is_string($i18nstring)) {
    $string = $i18nstring;
  }
  elseif (!empty($i18nstring->translation)) {
    $string = $i18nstring->translation;
    if (!empty($i18nstring->format)) {
      $options += array('format' => $i18nstring->format);
    }
    if (isset($options['filter'])) {
      $string = call_user_func($options['filter'], $string);
    }
  }
  else {
    $string = isset($i18nstring->source) ? $i18nstring->source : '';
  }
  // Apply filter and callback to any of the strings
  if ($string) {
    if (!empty($options['format']) && !empty($options['sanitize'])) {
      $string = check_markup($string, $options['format'], $options['langcode'], $options['cache']);
    }
    if (isset($options['callback'])) {
      $string = call_user_func($options['callback'], $string);
    }
  }
  // Add debug information if enabled
  if ($debug && is_object($i18nstring)) {
    $info = array($i18nstring->textgroup, $i18nstring->context);
    if (!empty($i18nstring->format)) {
      $info[] = $i18nstring->format;
    }
    $string .= ' [' . implode(':', $info) . ']';
  }
  return $string;
}

/**
 * Get filtered translation.
 *
 * This function is intended to return translations for strings that have an text format
 *
 * @param $name
 *   Full string id
 * @param $default
 *   Default string to return if not found, already filtered
 * @param $options
 *   Array with additional options.
 */
function i18n_string_text($name, $default, $options = array()) {
  $options += array('format' => filter_fallback_format(), 'sanitize' => TRUE);
  return i18n_string_translate($name, $default, $options);
}

/**
 * Translation for plain string. In case it finds a translation it applies check_plain() to it
 *
 * @param $name
 *   Full string id
 * @param $default
 *   Default string to return if not found
 * @param $options
 *   Array with additional options
 */
function i18n_string_plain($name, $default, $options = array()) {
  $options += array('filter' => 'check_plain');
  return i18n_string_translate($name, $default, $options);
}

/**
 * Translation for list of options
 * 
 * @param $options
 *   Array with additional options, some changes
 *   - 'index' => field that will be mapped to the array key (defaults to 'property')
 */
function i18n_string_translate_list($name, $strings, $options = array()) {
  $options['langcode'] = i18n_langcode(isset($options['langcode']) ? $options['langcode'] : NULL);
  // If language is default, just return
  if (i18n_string_translate_langcode($options['langcode'])) {
    // Get textgroup context, preserve placeholder
    list($textgroup, $context) = i18n_string_context($name, '*');
    $translations = i18n_string_textgroup($textgroup)->multiple_translate($context, $strings, $options);
    // Add for l10n client if available, we pass translation object that contains the format
    foreach ($translations as $index => $translation) {
      i18n_string_l10n_client_add($translation, $options);
      $strings[$index] = $translation;
    }
  }
  // Format and return
  foreach ($strings as $key => $string) {
    $strings[$key] = i18n_string_format($string, $options);
  }
  return $strings;
}

/**
 * Remove source and translations for user defined string.
 *
 * Though for most strings the 'name' or 'string id' uniquely identifies that string,
 * there are some exceptions (like profile categories) for which we need to use the
 * source string itself as a search key.
 *
 * @param $context
 *   String context
 * @param $string
 *   Optional source string (string in default language).
 *   Array of string properties to remove
 */
function i18n_string_remove($name, $string = NULL, $options = array()) {
  if (is_array($string)) {
    return i18n_string_multiple('remove', $name, $string, $options);
  }
  else {
    list($textgroup, $context) = i18n_string_context($name);
    return i18n_string_textgroup($textgroup)->remove($context, $string, $options);
  }
}

/**
 * @} End of "ingroup i18napi".
 */

/*** l10n client related functions ***/

/**
 * Add string to l10n strings if enabled and allowed for this string
 *
 * @param $context
 *   String object
 */
function i18n_string_l10n_client_add($string, $options) {
  // If current language add to l10n client list for later on page translation.
  // If langcode translation was disabled we are not supossed to reach here.
  if (($string->language == i18n_langcode()) && function_exists('l10_client_add_string_to_page')) {
    $translation = !empty($string->translation) ? $string->translation : TRUE;
    $source = !empty($string->source) ? $string->source : FALSE;
    // Check whether the format is allowed
    if (empty($string->format) || in_array($string->format, i18n_string_allowed_formats())) {
      l10_client_add_string_to_page($string->source, $translation, $string->textgroup);

/**
 * Translate object properties
 * 
 * We clone the object previously so we don't risk translated properties being saved
 * 
 * @param $type
 *   Object type
 * @param $object
 *   Object or array
 */
function i18n_string_object_translate($type, $object, $options = array()) {
  // If the object is marked as translation do nothing
  if (is_object($object) && !empty($object->i18n_string) || is_array($object) && !empty($object['i18n_string'])) {
    return $object;
  }
  $info = i18n_object_info($type);
  if ($info && !empty($info['string translation'])) {
    $translate = is_object($object) ? clone $object : (object)$object;
    $translate->i18n_string = TRUE;
    $result = i18n_string_textgroup($info['string translation']['textgroup'])->translate_object($type, $translate, $options);
    return is_array($object) ? (array)$result : $result;
  }
  else {
    return $object;
  }
}

/**
 * Remove object strings, because object is deleted
 * 
 * @param $type
 *   Object type
 * @param $object
 *   Object or array
 */
function i18n_string_object_remove($type, $object, $options = array()) {
  $info = i18n_object_info($type);
  if ($info && !empty($info['string translation'])) {
    $string_info = $info['string translation'];
    $object = is_array($object) ? (object)$object : $object;
    return i18n_string_remove(array($string_info['textgroup'], $string_info['type'], $object->{$info['key']}, '*'), array_keys($string_info['properties']), $options);
  }  
}

/**
 * Update object properties
 */
function i18n_string_object_update($type, $object) {
  $info = i18n_object_info($type);
  if ($info && !empty($info['string translation'])) {
    $object = is_array($object) ? (object)$object : $object;
    return i18n_string_textgroup($info['string translation']['textgroup'])->update_object($type, $object);
  }
}