diff -r 07239de796bb -r e756a8c72c3d cms/drupal/includes/locale.inc --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cms/drupal/includes/locale.inc Fri Sep 08 12:04:06 2017 +0200 @@ -0,0 +1,2492 @@ +language) ? $language->language : FALSE; +} + +/** + * Identify language from the Accept-language HTTP header we got. + * + * We perform browser accept-language parsing only if page cache is disabled, + * otherwise we would cache a user-specific preference. + * + * @param $languages + * An array of language objects for enabled languages ordered by weight. + * + * @return + * A valid language code on success, FALSE otherwise. + */ +function locale_language_from_browser($languages) { + if (empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + return FALSE; + } + + // The Accept-Language header contains information about the language + // preferences configured in the user's browser / operating system. + // RFC 2616 (section 14.4) defines the Accept-Language header as follows: + // Accept-Language = "Accept-Language" ":" + // 1#( language-range [ ";" "q" "=" qvalue ] ) + // language-range = ( ( 1*8ALPHA *( "-" 1*8ALPHA ) ) | "*" ) + // 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)) { + foreach ($matches as $match) { + // 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. + $langcode = strtolower($match[1]); + $qvalue = isset($match[2]) ? (float) $match[2] : 1; + $browser_langcodes[$langcode] = (int) ($qvalue * 1000); + } + } + + // We should take pristine values from the HTTP headers, but Internet Explorer + // from version 7 sends only specific language tags (eg. fr-CA) without the + // corresponding generic tag (fr) unless explicitly configured. In that case, + // we assume that the lowest value of the specific tags is the value of the + // generic language to be as close to the HTTP 1.1 spec as possible. + // See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4 and + // 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; + } + } + + // Find the enabled language with the greatest qvalue, following the rules + // of RFC 2616 (section 14.4). If several languages have the same qvalue, + // prefer the one with the greatest weight. + $best_match_langcode = FALSE; + $max_qvalue = 0; + foreach ($languages as $langcode => $language) { + // Language tags are case insensitive (RFC2616, sec 3.10). + $langcode = strtolower($langcode); + + // If nothing matches below, the default qvalue is the one of the wildcard + // language, if set, or is 0 (which will never match). + $qvalue = isset($browser_langcodes['*']) ? $browser_langcodes['*'] : 0; + + // Find the longest possible prefix of the browser-supplied language + // ('the language-range') that matches this site language ('the language tag'). + $prefix = $langcode; + do { + if (isset($browser_langcodes[$prefix])) { + $qvalue = $browser_langcodes[$prefix]; + break; + } + } + while ($prefix = substr($prefix, 0, strrpos($prefix, '-'))); + + // Find the best match. + if ($qvalue > $max_qvalue) { + $best_match_langcode = $language->language; + $max_qvalue = $qvalue; + } + } + + return $best_match_langcode; +} + +/** + * Identify language from the user preferences. + * + * @param $languages + * An array of valid language objects. + * + * @return + * A valid language code on success, FALSE otherwise. + */ +function locale_language_from_user($languages) { + // User preference (only for logged users). + global $user; + + if ($user->uid) { + return $user->language; + } + + // No language preference from the user. + return FALSE; +} + +/** + * Identify language from a request/session parameter. + * + * @param $languages + * An array of valid language objects. + * + * @return + * A valid language code on success, FALSE otherwise. + */ +function locale_language_from_session($languages) { + $param = variable_get('locale_language_negotiation_session_param', 'language'); + + // Request parameter: we need to update the session parameter only if we have + // an authenticated user. + if (isset($_GET[$param]) && isset($languages[$langcode = $_GET[$param]])) { + global $user; + if ($user->uid) { + $_SESSION[$param] = $langcode; + } + return $langcode; + } + + // Session parameter. + if (isset($_SESSION[$param])) { + return $_SESSION[$param]; + } + + return FALSE; +} + +/** + * Identify language via URL prefix or domain. + * + * @param $languages + * An array of valid language objects. + * + * @return + * A valid language code on success, FALSE otherwise. + */ +function locale_language_from_url($languages) { + $language_url = FALSE; + + if (!language_negotiation_get_any(LOCALE_LANGUAGE_NEGOTIATION_URL)) { + return $language_url; + } + + switch (variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX)) { + case LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX: + // $_GET['q'] might not be available at this time, because + // path initialization runs after the language bootstrap phase. + list($language, $_GET['q']) = language_url_split_prefix(isset($_GET['q']) ? $_GET['q'] : NULL, $languages); + if ($language !== FALSE) { + $language_url = $language->language; + } + break; + + case LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN: + // Get only the host, not the port. + $http_host= $_SERVER['HTTP_HOST']; + if (strpos($http_host, ':') !== FALSE) { + $http_host_tmp = explode(':', $http_host); + $http_host = current($http_host_tmp); + } + foreach ($languages as $language) { + // Skip check if the language doesn't have a domain. + if ($language->domain) { + // Only compare the domains not the protocols or ports. + // Remove protocol and add http:// so parse_url works + $host = 'http://' . str_replace(array('http://', 'https://'), '', $language->domain); + $host = parse_url($host, PHP_URL_HOST); + if ($http_host == $host) { + $language_url = $language->language; + break; + } + } + } + break; + } + + return $language_url; +} + +/** + * Determines the language to be assigned to URLs when none is detected. + * + * The language negotiation process has a fallback chain that ends with the + * default language provider. Each built-in language type has a separate + * initialization: + * - Interface language, which is the only configurable one, always gets a valid + * value. If no request-specific language is detected, the default language + * will be used. + * - Content language merely inherits the interface language by default. + * - URL language is detected from the requested URL and will be used to rewrite + * URLs appearing in the page being rendered. If no language can be detected, + * there are two possibilities: + * - If the default language has no configured path prefix or domain, then the + * default language is used. This guarantees that (missing) URL prefixes are + * preserved when navigating through the site. + * - If the default language has a configured path prefix or domain, a + * requested URL having an empty prefix or domain is an anomaly that must be + * fixed. This is done by introducing a prefix or domain in the rendered + * page matching the detected interface language. + * + * @param $languages + * (optional) An array of valid language objects. This is passed by + * language_provider_invoke() to every language provider callback, but it is + * not actually needed here. Defaults to NULL. + * @param $language_type + * (optional) The language type to fall back to. Defaults to the interface + * language. + * + * @return + * A valid language code. + */ +function locale_language_url_fallback($language = NULL, $language_type = LANGUAGE_TYPE_INTERFACE) { + $default = language_default(); + $prefix = (variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX) == LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX); + + // If the default language is not configured to convey language information, + // a missing URL language information indicates that URL language should be + // the default one, otherwise we fall back to an already detected language. + if (($prefix && empty($default->prefix)) || (!$prefix && empty($default->domain))) { + return $default->language; + } + else { + return $GLOBALS[$language_type]->language; + } +} + +/** + * Return the URL language switcher block. Translation links may be provided by + * other modules. + */ +function locale_language_switcher_url($type, $path) { + $languages = language_list('enabled'); + $links = array(); + + foreach ($languages[1] as $language) { + $links[$language->language] = array( + 'href' => $path, + 'title' => $language->native, + 'language' => $language, + 'attributes' => array('class' => array('language-link')), + ); + } + + return $links; +} + +/** + * Return the session language switcher block. + */ +function locale_language_switcher_session($type, $path) { + drupal_add_css(drupal_get_path('module', 'locale') . '/locale.css'); + + $param = variable_get('locale_language_negotiation_session_param', 'language'); + $language_query = isset($_SESSION[$param]) ? $_SESSION[$param] : $GLOBALS[$type]->language; + + $languages = language_list('enabled'); + $links = array(); + + $query = $_GET; + unset($query['q']); + + foreach ($languages[1] as $language) { + $langcode = $language->language; + $links[$langcode] = array( + 'href' => $path, + 'title' => $language->native, + 'attributes' => array('class' => array('language-link')), + 'query' => $query, + ); + if ($language_query != $langcode) { + $links[$langcode]['query'][$param] = $langcode; + } + else { + $links[$langcode]['attributes']['class'][] = 'session-active'; + } + } + + return $links; +} + +/** + * Rewrite URLs for the URL language provider. + */ +function locale_language_url_rewrite_url(&$path, &$options) { + static $drupal_static_fast; + if (!isset($drupal_static_fast)) { + $drupal_static_fast['languages'] = &drupal_static(__FUNCTION__); + } + $languages = &$drupal_static_fast['languages']; + + if (!isset($languages)) { + $languages = language_list('enabled'); + $languages = array_flip(array_keys($languages[1])); + } + + // Language can be passed as an option, or we go for current URL language. + if (!isset($options['language'])) { + global $language_url; + $options['language'] = $language_url; + } + // We allow only enabled languages here. + elseif (!isset($languages[$options['language']->language])) { + unset($options['language']); + return; + } + + if (isset($options['language'])) { + switch (variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX)) { + case LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN: + if ($options['language']->domain) { + // Save the original base URL. If it contains a port, we need to + // retain it below. + if (!empty($options['base_url'])) { + // The colon in the URL scheme messes up the port checking below. + $normalized_base_url = str_replace(array('https://', 'http://'), '', $options['base_url']); + } + + // Ask for an absolute URL with our modified base_url. + global $is_https; + $url_scheme = ($is_https) ? 'https://' : 'http://'; + $options['absolute'] = TRUE; + + // Take the domain without ports or protocols so we can apply the + // protocol needed. The setting might include a protocol. + // This is changed in Drupal 8 but we need to keep backwards + // compatibility for Drupal 7. + $host = 'http://' . str_replace(array('http://', 'https://'), '', $options['language']->domain); + $host = parse_url($host, PHP_URL_HOST); + + // Apply the appropriate protocol to the URL. + $options['base_url'] = $url_scheme . $host; + + // In case either the original base URL or the HTTP host contains a + // port, retain it. + $http_host = $_SERVER['HTTP_HOST']; + if (isset($normalized_base_url) && strpos($normalized_base_url, ':') !== FALSE) { + list($host, $port) = explode(':', $normalized_base_url); + $options['base_url'] .= ':' . $port; + } + elseif (strpos($http_host, ':') !== FALSE) { + list($host, $port) = explode(':', $http_host); + $options['base_url'] .= ':' . $port; + } + + if (isset($options['https']) && variable_get('https', FALSE)) { + if ($options['https'] === TRUE) { + $options['base_url'] = str_replace('http://', 'https://', $options['base_url']); + } + elseif ($options['https'] === FALSE) { + $options['base_url'] = str_replace('https://', 'http://', $options['base_url']); + } + } + } + break; + + case LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX: + if (!empty($options['language']->prefix)) { + $options['prefix'] = $options['language']->prefix . '/'; + } + break; + } + } +} + +/** + * Rewrite URLs for the Session language provider. + */ +function locale_language_url_rewrite_session(&$path, &$options) { + static $query_rewrite, $query_param, $query_value; + + // The following values are not supposed to change during a single page + // request processing. + if (!isset($query_rewrite)) { + global $user; + if (!$user->uid) { + $languages = language_list('enabled'); + $languages = $languages[1]; + $query_param = check_plain(variable_get('locale_language_negotiation_session_param', 'language')); + $query_value = isset($_GET[$query_param]) ? check_plain($_GET[$query_param]) : NULL; + $query_rewrite = isset($languages[$query_value]) && language_negotiation_get_any(LOCALE_LANGUAGE_NEGOTIATION_SESSION); + } + else { + $query_rewrite = FALSE; + } + } + + // If the user is anonymous, the user language provider is enabled, and the + // corresponding option has been set, we must preserve any explicit user + // language preference even with cookies disabled. + if ($query_rewrite) { + if (is_string($options['query'])) { + $options['query'] = drupal_get_query_array($options['query']); + } + if (!isset($options['query'][$query_param])) { + $options['query'][$query_param] = $query_value; + } + } +} + +/** + * @} End of "locale-languages-negotiation" + */ + +/** + * Check that a string is safe to be added or imported as a translation. + * + * This test can be used to detect possibly bad translation strings. It should + * not have any false positives. But it is only a test, not a transformation, + * as it destroys valid HTML. We cannot reliably filter translation strings + * on import because some strings are irreversibly corrupted. For example, + * a & in the translation would get encoded to &amp; by filter_xss() + * before being put in the database, and thus would be displayed incorrectly. + * + * The allowed tag list is like filter_xss_admin(), but omitting div and img as + * not needed for translation and likely to cause layout issues (div) or a + * possible attack vector (img). + */ +function locale_string_is_safe($string) { + // Some strings have tokens in them. For tokens in the first part of href or + // src HTML attributes, filter_xss() removes part of the token, the part + // before the first colon. filter_xss() assumes it could be an attempt to + // inject javascript. When filter_xss() removes part of tokens, it causes the + // string to not be translatable when it should be translatable. See + // LocaleStringIsSafeTest::testLocaleStringIsSafe(). + // + // We can recognize tokens since they are wrapped with brackets and are only + // composed of alphanumeric characters, colon, underscore, and dashes. We can + // be sure these strings are safe to strip out before the string is checked in + // filter_xss() because no dangerous javascript will match that pattern. + // + // @todo Do not strip out the token. Fix filter_xss() to not incorrectly + // alter the string. https://www.drupal.org/node/2372127 + $string = preg_replace('/\[[a-z0-9_-]+(:[a-z0-9_-]+)+\]/i', '', $string); + + return decode_entities($string) == decode_entities(filter_xss($string, array('a', 'abbr', 'acronym', 'address', 'b', 'bdo', 'big', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'ul', 'var'))); +} + +/** + * @defgroup locale-api-add Language addition API + * @{ + * Add a language. + * + * The language addition API is used to create languages and store them. + */ + +/** + * API function to add a language. + * + * @param $langcode + * Language code. + * @param $name + * English name of the language + * @param $native + * Native name of the language + * @param $direction + * LANGUAGE_LTR or LANGUAGE_RTL + * @param $domain + * Optional custom domain name with protocol, without + * trailing slash (eg. http://de.example.com). + * @param $prefix + * Optional path prefix for the language. Defaults to the + * language code if omitted. + * @param $enabled + * Optionally TRUE to enable the language when created or FALSE to disable. + * @param $default + * Optionally set this language to be the default. + */ +function locale_add_language($langcode, $name = NULL, $native = NULL, $direction = LANGUAGE_LTR, $domain = '', $prefix = '', $enabled = TRUE, $default = FALSE) { + // Default prefix on language code. + if (empty($prefix)) { + $prefix = $langcode; + } + + // If name was not set, we add a predefined language. + if (!isset($name)) { + include_once DRUPAL_ROOT . '/includes/iso.inc'; + $predefined = _locale_get_predefined_list(); + $name = $predefined[$langcode][0]; + $native = isset($predefined[$langcode][1]) ? $predefined[$langcode][1] : $predefined[$langcode][0]; + $direction = isset($predefined[$langcode][2]) ? $predefined[$langcode][2] : LANGUAGE_LTR; + } + + db_insert('languages') + ->fields(array( + 'language' => $langcode, + 'name' => $name, + 'native' => $native, + 'direction' => $direction, + 'domain' => $domain, + 'prefix' => $prefix, + 'enabled' => $enabled, + )) + ->execute(); + + // Only set it as default if enabled. + if ($enabled && $default) { + variable_set('language_default', (object) array('language' => $langcode, 'name' => $name, 'native' => $native, 'direction' => $direction, 'enabled' => (int) $enabled, 'plurals' => 0, 'formula' => '', 'domain' => '', 'prefix' => $prefix, 'weight' => 0, 'javascript' => '')); + } + + if ($enabled) { + // Increment enabled language count if we are adding an enabled language. + variable_set('language_count', variable_get('language_count', 1) + 1); + } + + // Kill the static cache in language_list(). + drupal_static_reset('language_list'); + + // Force JavaScript translation file creation for the newly added language. + _locale_invalidate_js($langcode); + + watchdog('locale', 'The %language language (%code) has been created.', array('%language' => $name, '%code' => $langcode)); + + module_invoke_all('multilingual_settings_changed'); +} +/** + * @} End of "locale-api-add" + */ + +/** + * @defgroup locale-api-import-export Translation import/export API. + * @{ + * Functions to import and export translations. + * + * These functions provide the ability to import translations from + * external files and to export translations and translation templates. + */ + +/** + * Parses Gettext Portable Object file information and inserts into database + * + * @param $file + * Drupal file object corresponding to the PO file to import. + * @param $langcode + * Language code. + * @param $mode + * Should existing translations be replaced LOCALE_IMPORT_KEEP or + * LOCALE_IMPORT_OVERWRITE. + * @param $group + * Text group to import PO file into (eg. 'default' for interface + * translations). + */ +function _locale_import_po($file, $langcode, $mode, $group = NULL) { + // Check if we have the language already in the database. + if (!db_query("SELECT COUNT(language) FROM {languages} WHERE language = :language", array(':language' => $langcode))->fetchField()) { + drupal_set_message(t('The language selected for import is not supported.'), 'error'); + return FALSE; + } + + // Get strings from file (returns on failure after a partial import, or on success) + $status = _locale_import_read_po('db-store', $file, $mode, $langcode, $group); + if ($status === FALSE) { + // Error messages are set in _locale_import_read_po(). + return FALSE; + } + + // Get status information on import process. + list($header_done, $additions, $updates, $deletes, $skips) = _locale_import_one_string('db-report'); + + if (!$header_done) { + drupal_set_message(t('The translation file %filename appears to have a missing or malformed header.', array('%filename' => $file->filename)), 'error'); + } + + // Clear cache and force refresh of JavaScript translations. + _locale_invalidate_js($langcode); + cache_clear_all('locale:', 'cache', TRUE); + + // Rebuild the menu, strings may have changed. + menu_rebuild(); + + drupal_set_message(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => $additions, '%update' => $updates, '%delete' => $deletes))); + watchdog('locale', 'Imported %file into %locale: %number new strings added, %update updated and %delete removed.', array('%file' => $file->filename, '%locale' => $langcode, '%number' => $additions, '%update' => $updates, '%delete' => $deletes)); + if ($skips) { + $skip_message = format_plural($skips, 'One translation string was skipped because it contains disallowed HTML.', '@count translation strings were skipped because they contain disallowed HTML.'); + drupal_set_message($skip_message); + watchdog('locale', '@count disallowed HTML string(s) in %file', array('@count' => $skips, '%file' => $file->uri), WATCHDOG_WARNING); + } + return TRUE; +} + +/** + * Parses Gettext Portable Object file into an array + * + * @param $op + * Storage operation type: db-store or mem-store. + * @param $file + * Drupal file object corresponding to the PO file to import. + * @param $mode + * Should existing translations be replaced LOCALE_IMPORT_KEEP or + * LOCALE_IMPORT_OVERWRITE. + * @param $lang + * Language code. + * @param $group + * Text group to import PO file into (eg. 'default' for interface + * translations). + */ +function _locale_import_read_po($op, $file, $mode = NULL, $lang = NULL, $group = 'default') { + + // The file will get closed by PHP on returning from this function. + $fd = fopen($file->uri, 'rb'); + if (!$fd) { + _locale_import_message('The translation import failed, because the file %filename could not be read.', $file); + return FALSE; + } + + /* + * The parser context. Can be: + * - 'COMMENT' (#) + * - 'MSGID' (msgid) + * - 'MSGID_PLURAL' (msgid_plural) + * - 'MSGCTXT' (msgctxt) + * - 'MSGSTR' (msgstr or msgstr[]) + * - 'MSGSTR_ARR' (msgstr_arg) + */ + $context = 'COMMENT'; + + // Current entry being read. + $current = array(); + + // Current plurality for 'msgstr[]'. + $plural = 0; + + // Current line. + $lineno = 0; + + while (!feof($fd)) { + // Refresh the time limit every 10 parsed rows to ensure there is always + // enough time to import the data for large PO files. + if (!($lineno % 10)) { + drupal_set_time_limit(30); + } + + // A line should not be longer than 10 * 1024. + $line = fgets($fd, 10 * 1024); + + if ($lineno == 0) { + // The first line might come with a UTF-8 BOM, which should be removed. + $line = str_replace("\xEF\xBB\xBF", '', $line); + } + + $lineno++; + + // Trim away the linefeed. + $line = trim(strtr($line, array("\\\n" => ""))); + + if (!strncmp('#', $line, 1)) { + // Lines starting with '#' are comments. + + if ($context == 'COMMENT') { + // Already in comment token, insert the comment. + $current['#'][] = substr($line, 1); + } + elseif (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) { + // We are currently in string token, close it out. + _locale_import_one_string($op, $current, $mode, $lang, $file, $group); + + // Start a new entry for the comment. + $current = array(); + $current['#'][] = substr($line, 1); + + $context = 'COMMENT'; + } + else { + // A comment following any other token is a syntax error. + _locale_import_message('The translation file %filename contains an error: "msgstr" was expected but not found on line %line.', $file, $lineno); + return FALSE; + } + } + elseif (!strncmp('msgid_plural', $line, 12)) { + // A plural form for the current message. + + if ($context != 'MSGID') { + // A plural form cannot be added to anything else but the id directly. + _locale_import_message('The translation file %filename contains an error: "msgid_plural" was expected but not found on line %line.', $file, $lineno); + return FALSE; + } + + // Remove 'msgid_plural' and trim away whitespace. + $line = trim(substr($line, 12)); + // At this point, $line should now contain only the plural form. + + $quoted = _locale_import_parse_quoted($line); + if ($quoted === FALSE) { + // The plural form must be wrapped in quotes. + _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno); + return FALSE; + } + + // Append the plural form to the current entry. + $current['msgid'] .= "\0" . $quoted; + + $context = 'MSGID_PLURAL'; + } + elseif (!strncmp('msgid', $line, 5)) { + // Starting a new message. + + if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) { + // We are currently in a message string, close it out. + _locale_import_one_string($op, $current, $mode, $lang, $file, $group); + + // Start a new context for the id. + $current = array(); + } + elseif ($context == 'MSGID') { + // We are currently already in the context, meaning we passed an id with no data. + _locale_import_message('The translation file %filename contains an error: "msgid" is unexpected on line %line.', $file, $lineno); + return FALSE; + } + + // Remove 'msgid' and trim away whitespace. + $line = trim(substr($line, 5)); + // At this point, $line should now contain only the message id. + + $quoted = _locale_import_parse_quoted($line); + if ($quoted === FALSE) { + // The message id must be wrapped in quotes. + _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno); + return FALSE; + } + + $current['msgid'] = $quoted; + $context = 'MSGID'; + } + elseif (!strncmp('msgctxt', $line, 7)) { + // Starting a new context. + + if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) { + // We are currently in a message, start a new one. + _locale_import_one_string($op, $current, $mode, $lang, $file, $group); + $current = array(); + } + elseif (!empty($current['msgctxt'])) { + // A context cannot apply to another context. + _locale_import_message('The translation file %filename contains an error: "msgctxt" is unexpected on line %line.', $file, $lineno); + return FALSE; + } + + // Remove 'msgctxt' and trim away whitespaces. + $line = trim(substr($line, 7)); + // At this point, $line should now contain the context. + + $quoted = _locale_import_parse_quoted($line); + if ($quoted === FALSE) { + // The context string must be quoted. + _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno); + return FALSE; + } + + $current['msgctxt'] = $quoted; + + $context = 'MSGCTXT'; + } + elseif (!strncmp('msgstr[', $line, 7)) { + // A message string for a specific plurality. + + if (($context != 'MSGID') && ($context != 'MSGCTXT') && ($context != 'MSGID_PLURAL') && ($context != 'MSGSTR_ARR')) { + // Message strings must come after msgid, msgxtxt, msgid_plural, or other msgstr[] entries. + _locale_import_message('The translation file %filename contains an error: "msgstr[]" is unexpected on line %line.', $file, $lineno); + return FALSE; + } + + // Ensure the plurality is terminated. + if (strpos($line, ']') === FALSE) { + _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno); + return FALSE; + } + + // Extract the plurality. + $frombracket = strstr($line, '['); + $plural = substr($frombracket, 1, strpos($frombracket, ']') - 1); + + // Skip to the next whitespace and trim away any further whitespace, bringing $line to the message data. + $line = trim(strstr($line, " ")); + + $quoted = _locale_import_parse_quoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno); + return FALSE; + } + + $current['msgstr'][$plural] = $quoted; + + $context = 'MSGSTR_ARR'; + } + elseif (!strncmp("msgstr", $line, 6)) { + // A string for the an id or context. + + if (($context != 'MSGID') && ($context != 'MSGCTXT')) { + // Strings are only valid within an id or context scope. + _locale_import_message('The translation file %filename contains an error: "msgstr" is unexpected on line %line.', $file, $lineno); + return FALSE; + } + + // Remove 'msgstr' and trim away away whitespaces. + $line = trim(substr($line, 6)); + // At this point, $line should now contain the message. + + $quoted = _locale_import_parse_quoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno); + return FALSE; + } + + $current['msgstr'] = $quoted; + + $context = 'MSGSTR'; + } + elseif ($line != '') { + // Anything that is not a token may be a continuation of a previous token. + + $quoted = _locale_import_parse_quoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno); + return FALSE; + } + + // Append the string to the current context. + if (($context == 'MSGID') || ($context == 'MSGID_PLURAL')) { + $current['msgid'] .= $quoted; + } + elseif ($context == 'MSGCTXT') { + $current['msgctxt'] .= $quoted; + } + elseif ($context == 'MSGSTR') { + $current['msgstr'] .= $quoted; + } + elseif ($context == 'MSGSTR_ARR') { + $current['msgstr'][$plural] .= $quoted; + } + else { + // No valid context to append to. + _locale_import_message('The translation file %filename contains an error: there is an unexpected string on line %line.', $file, $lineno); + return FALSE; + } + } + } + + // End of PO file, closed out the last entry. + if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) { + _locale_import_one_string($op, $current, $mode, $lang, $file, $group); + } + elseif ($context != 'COMMENT') { + _locale_import_message('The translation file %filename ended unexpectedly at line %line.', $file, $lineno); + return FALSE; + } +} + +/** + * Sets an error message occurred during locale file parsing. + * + * @param $message + * The message to be translated. + * @param $file + * Drupal file object corresponding to the PO file to import. + * @param $lineno + * An optional line number argument. + */ +function _locale_import_message($message, $file, $lineno = NULL) { + $vars = array('%filename' => $file->filename); + if (isset($lineno)) { + $vars['%line'] = $lineno; + } + $t = get_t(); + drupal_set_message($t($message, $vars), 'error'); +} + +/** + * Imports a string into the database + * + * @param $op + * Operation to perform: 'db-store', 'db-report', 'mem-store' or 'mem-report'. + * @param $value + * Details of the string stored. + * @param $mode + * Should existing translations be replaced LOCALE_IMPORT_KEEP or + * LOCALE_IMPORT_OVERWRITE. + * @param $lang + * Language to store the string in. + * @param $file + * Object representation of file being imported, only required when op is + * 'db-store'. + * @param $group + * Text group to import PO file into (eg. 'default' for interface + * translations). + */ +function _locale_import_one_string($op, $value = NULL, $mode = NULL, $lang = NULL, $file = NULL, $group = 'default') { + $report = &drupal_static(__FUNCTION__, array('additions' => 0, 'updates' => 0, 'deletes' => 0, 'skips' => 0)); + $header_done = &drupal_static(__FUNCTION__ . ':header_done', FALSE); + $strings = &drupal_static(__FUNCTION__ . ':strings', array()); + + switch ($op) { + // Return stored strings + case 'mem-report': + return $strings; + + // Store string in memory (only supports single strings) + case 'mem-store': + $strings[isset($value['msgctxt']) ? $value['msgctxt'] : ''][$value['msgid']] = $value['msgstr']; + return; + + // Called at end of import to inform the user + case 'db-report': + return array($header_done, $report['additions'], $report['updates'], $report['deletes'], $report['skips']); + + // Store the string we got in the database. + case 'db-store': + // We got header information. + if ($value['msgid'] == '') { + $languages = language_list(); + if (($mode != LOCALE_IMPORT_KEEP) || empty($languages[$lang]->plurals)) { + // Since we only need to parse the header if we ought to update the + // plural formula, only run this if we don't need to keep existing + // data untouched or if we don't have an existing plural formula. + $header = _locale_import_parse_header($value['msgstr']); + + // Get and store the plural formula if available. + if (isset($header["Plural-Forms"]) && $p = _locale_import_parse_plural_forms($header["Plural-Forms"], $file->uri)) { + list($nplurals, $plural) = $p; + db_update('languages') + ->fields(array( + 'plurals' => $nplurals, + 'formula' => $plural, + )) + ->condition('language', $lang) + ->execute(); + } + } + $header_done = TRUE; + } + + else { + // Some real string to import. + $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, $group, $comments, $mode, $plid, $key); + } + } + + 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, $group, $comments, $mode); + } + } + } // end of db-store operation +} + +/** + * Import one string into the database. + * + * @param $report + * Report array summarizing the number of changes done in the form: + * array(inserts, updates, deletes). + * @param $langcode + * Language code to import string into. + * @param $context + * The context of this string. + * @param $source + * Source string. + * @param $translation + * Translation to language specified in $langcode. + * @param $textgroup + * Name of textgroup to store translation in. + * @param $location + * 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, $textgroup, $location, $mode, $plid = 0, $plural = 0) { + $lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = :context AND textgroup = :textgroup", array(':source' => $source, ':context' => $context, ':textgroup' => $textgroup))->fetchField(); + + if (!empty($translation)) { + // Skip this string unless it passes a check for dangerous code. + // Text groups other than default still can contain HTML tags + // (i.e. translatable blocks). + if ($textgroup == "default" && !locale_string_is_safe($translation)) { + $report['skips']++; + $lid = 0; + } + elseif ($lid) { + // We have this source string saved already. + db_update('locales_source') + ->fields(array( + 'location' => $location, + )) + ->condition('lid', $lid) + ->execute(); + + $exists = db_query("SELECT COUNT(lid) FROM {locales_target} WHERE lid = :lid AND language = :language", array(':lid' => $lid, ':language' => $langcode))->fetchField(); + + if (!$exists) { + // No translation in this language. + db_insert('locales_target') + ->fields(array( + 'lid' => $lid, + 'language' => $langcode, + 'translation' => $translation, + 'plid' => $plid, + 'plural' => $plural, + )) + ->execute(); + + $report['additions']++; + } + elseif ($mode == LOCALE_IMPORT_OVERWRITE) { + // Translation exists, only overwrite if instructed. + db_update('locales_target') + ->fields(array( + 'translation' => $translation, + 'plid' => $plid, + 'plural' => $plural, + )) + ->condition('language', $langcode) + ->condition('lid', $lid) + ->execute(); + + $report['updates']++; + } + } + else { + // No such source string in the database yet. + $lid = db_insert('locales_source') + ->fields(array( + 'location' => $location, + 'source' => $source, + 'context' => (string) $context, + 'textgroup' => $textgroup, + )) + ->execute(); + + db_insert('locales_target') + ->fields(array( + 'lid' => $lid, + 'language' => $langcode, + 'translation' => $translation, + 'plid' => $plid, + 'plural' => $plural + )) + ->execute(); + + $report['additions']++; + } + } + elseif ($mode == LOCALE_IMPORT_OVERWRITE) { + // Empty translation, remove existing if instructed. + db_delete('locales_target') + ->condition('language', $langcode) + ->condition('lid', $lid) + ->condition('plid', $plid) + ->condition('plural', $plural) + ->execute(); + + $report['deletes']++; + } + + return $lid; +} + +/** + * Parses a Gettext Portable Object file header + * + * @param $header + * A string containing the complete header. + * + * @return + * An associative array of key-value pairs. + */ +function _locale_import_parse_header($header) { + $header_parsed = array(); + $lines = array_map('trim', explode("\n", $header)); + foreach ($lines as $line) { + if ($line) { + list($tag, $contents) = explode(":", $line, 2); + $header_parsed[trim($tag)] = trim($contents); + } + } + return $header_parsed; +} + +/** + * Parses a Plural-Forms entry from a Gettext Portable Object file header + * + * @param $pluralforms + * A string containing the Plural-Forms entry. + * @param $filepath + * A string containing the filepath. + * + * @return + * An array containing the number of plurals and a + * formula in PHP for computing the plural form. + */ +function _locale_import_parse_plural_forms($pluralforms, $filepath) { + // First, delete all whitespace + $pluralforms = strtr($pluralforms, array(" " => "", "\t" => "")); + + // Select the parts that define nplurals and plural + $nplurals = strstr($pluralforms, "nplurals="); + if (strpos($nplurals, ";")) { + $nplurals = substr($nplurals, 9, strpos($nplurals, ";") - 9); + } + else { + return FALSE; + } + $plural = strstr($pluralforms, "plural="); + if (strpos($plural, ";")) { + $plural = substr($plural, 7, strpos($plural, ";") - 7); + } + else { + return FALSE; + } + + // Get PHP version of the plural formula + $plural = _locale_import_parse_arithmetic($plural); + + if ($plural !== FALSE) { + return array($nplurals, $plural); + } + else { + drupal_set_message(t('The translation file %filepath contains an error: the plural formula could not be parsed.', array('%filepath' => $filepath)), 'error'); + return FALSE; + } +} + +/** + * Parses and sanitizes an arithmetic formula into a PHP expression + * + * While parsing, we ensure, that the operators have the right + * precedence and associativity. + * + * @param $string + * A string containing the arithmetic formula. + * + * @return + * The PHP version of the formula. + */ +function _locale_import_parse_arithmetic($string) { + // Operator precedence table + $precedence = array("(" => -1, ")" => -1, "?" => 1, ":" => 1, "||" => 3, "&&" => 4, "==" => 5, "!=" => 5, "<" => 6, ">" => 6, "<=" => 6, ">=" => 6, "+" => 7, "-" => 7, "*" => 8, "/" => 8, "%" => 8); + // Right associativity + $right_associativity = array("?" => 1, ":" => 1); + + $tokens = _locale_import_tokenize_formula($string); + + // Parse by converting into infix notation then back into postfix + // Operator stack - holds math operators and symbols + $operator_stack = array(); + // Element Stack - holds data to be operated on + $element_stack = array(); + + foreach ($tokens as $token) { + $current_token = $token; + + // Numbers and the $n variable are simply pushed into $element_stack + if (is_numeric($token)) { + $element_stack[] = $current_token; + } + elseif ($current_token == "n") { + $element_stack[] = '$n'; + } + elseif ($current_token == "(") { + $operator_stack[] = $current_token; + } + elseif ($current_token == ")") { + $topop = array_pop($operator_stack); + while (isset($topop) && ($topop != "(")) { + $element_stack[] = $topop; + $topop = array_pop($operator_stack); + } + } + elseif (!empty($precedence[$current_token])) { + // If it's an operator, then pop from $operator_stack into $element_stack until the + // precedence in $operator_stack is less than current, then push into $operator_stack + $topop = array_pop($operator_stack); + while (isset($topop) && ($precedence[$topop] >= $precedence[$current_token]) && !(($precedence[$topop] == $precedence[$current_token]) && !empty($right_associativity[$topop]) && !empty($right_associativity[$current_token]))) { + $element_stack[] = $topop; + $topop = array_pop($operator_stack); + } + if ($topop) { + $operator_stack[] = $topop; // Return element to top + } + $operator_stack[] = $current_token; // Parentheses are not needed + } + else { + return FALSE; + } + } + + // Flush operator stack + $topop = array_pop($operator_stack); + while ($topop != NULL) { + $element_stack[] = $topop; + $topop = array_pop($operator_stack); + } + + // Now extract formula from stack + $previous_size = count($element_stack) + 1; + while (count($element_stack) < $previous_size) { + $previous_size = count($element_stack); + for ($i = 2; $i < count($element_stack); $i++) { + $op = $element_stack[$i]; + if (!empty($precedence[$op])) { + $f = ""; + if ($op == ":") { + $f = $element_stack[$i - 2] . "):" . $element_stack[$i - 1] . ")"; + } + elseif ($op == "?") { + $f = "(" . $element_stack[$i - 2] . "?(" . $element_stack[$i - 1]; + } + else { + $f = "(" . $element_stack[$i - 2] . $op . $element_stack[$i - 1] . ")"; + } + array_splice($element_stack, $i - 2, 3, $f); + break; + } + } + } + + // If only one element is left, the number of operators is appropriate + if (count($element_stack) == 1) { + return $element_stack[0]; + } + else { + return FALSE; + } +} + +/** + * Backward compatible implementation of token_get_all() for formula parsing + * + * @param $string + * A string containing the arithmetic formula. + * + * @return + * The PHP version of the formula. + */ +function _locale_import_tokenize_formula($formula) { + $formula = str_replace(" ", "", $formula); + $tokens = array(); + for ($i = 0; $i < strlen($formula); $i++) { + if (is_numeric($formula[$i])) { + $num = $formula[$i]; + $j = $i + 1; + while ($j < strlen($formula) && is_numeric($formula[$j])) { + $num .= $formula[$j]; + $j++; + } + $i = $j - 1; + $tokens[] = $num; + } + elseif ($pos = strpos(" =<>!&|", $formula[$i])) { // We won't have a space + $next = $formula[$i + 1]; + switch ($pos) { + case 1: + case 2: + case 3: + case 4: + if ($next == '=') { + $tokens[] = $formula[$i] . '='; + $i++; + } + else { + $tokens[] = $formula[$i]; + } + break; + case 5: + if ($next == '&') { + $tokens[] = '&&'; + $i++; + } + else { + $tokens[] = $formula[$i]; + } + break; + case 6: + if ($next == '|') { + $tokens[] = '||'; + $i++; + } + else { + $tokens[] = $formula[$i]; + } + break; + } + } + else { + $tokens[] = $formula[$i]; + } + } + return $tokens; +} + +/** + * Modify a string to contain proper count indices + * + * This is a callback function used via array_map() + * + * @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); +} + +/** + * Generate a short, one string version of the passed comment array + * + * @param $comment + * An array of strings containing a comment. + * + * @return + * Short one string version of the comment. + */ +function _locale_import_shorten_comments($comment) { + $comm = ''; + while (count($comment)) { + $test = $comm . substr(array_shift($comment), 1) . ', '; + if (strlen($comm) < 130) { + $comm = $test; + } + else { + break; + } + } + return trim(substr($comm, 0, -2)); +} + +/** + * Parses a string in quotes + * + * @param $string + * A string specified with enclosing quotes. + * + * @return + * The string parsed from inside the quotes. + */ +function _locale_import_parse_quoted($string) { + if (substr($string, 0, 1) != substr($string, -1, 1)) { + return FALSE; // Start and end quotes must be the same + } + $quote = substr($string, 0, 1); + $string = substr($string, 1, -1); + if ($quote == '"') { // Double quotes: strip slashes + return stripcslashes($string); + } + elseif ($quote == "'") { // Simple quote: return as-is + return $string; + } + else { + return FALSE; // Unrecognized quote + } +} +/** + * @} End of "locale-api-import-export" + */ + +/** + * Parses a JavaScript file, extracts strings wrapped in Drupal.t() and + * Drupal.formatPlural() and inserts them into the database. + */ +function _locale_parse_js_file($filepath) { + global $language; + + // The file path might contain a query string, so make sure we only use the + // actual file. + $parsed_url = drupal_parse_url($filepath); + $filepath = $parsed_url['path']; + // Load the JavaScript file. + $file = file_get_contents($filepath); + + // Match all calls to Drupal.t() in an array. + // Note: \s also matches newlines with the 's' modifier. + preg_match_all('~ + [^\w]Drupal\s*\.\s*t\s* # match "Drupal.t" with whitespace + \(\s* # match "(" argument list start + (' . LOCALE_JS_STRING . ')\s* # capture string argument + (?:,\s*' . LOCALE_JS_OBJECT . '\s* # optionally capture str args + (?:,\s*' . LOCALE_JS_OBJECT_CONTEXT . '\s*) # optionally capture context + ?)? # close optional args + [,\)] # match ")" or "," to finish + ~sx', $file, $t_matches); + + // Match all Drupal.formatPlural() calls in another array. + preg_match_all('~ + [^\w]Drupal\s*\.\s*formatPlural\s* # match "Drupal.formatPlural" with whitespace + \( # match "(" argument list start + \s*.+?\s*,\s* # match count argument + (' . LOCALE_JS_STRING . ')\s*,\s* # match singular string argument + ( # capture plural string argument + (?: # non-capturing group to repeat string pieces + (?: + \' # match start of single-quoted string + (?:\\\\\'|[^\'])* # match any character except unescaped single-quote + @count # match "@count" + (?:\\\\\'|[^\'])* # match any character except unescaped single-quote + \' # match end of single-quoted string + | + " # match start of double-quoted string + (?:\\\\"|[^"])* # match any character except unescaped double-quote + @count # match "@count" + (?:\\\\"|[^"])* # match any character except unescaped double-quote + " # match end of double-quoted string + ) + (?:\s*\+\s*)? # match "+" with possible whitespace, for str concat + )+ # match multiple because we supports concatenating strs + )\s* # end capturing of plural string argument + (?:,\s*' . LOCALE_JS_OBJECT . '\s* # optionally capture string args + (?:,\s*' . LOCALE_JS_OBJECT_CONTEXT . '\s*)? # optionally capture context + )? + [,\)] + ~sx', $file, $plural_matches); + + $matches = array(); + + // Add strings from Drupal.t(). + foreach ($t_matches[1] as $key => $string) { + $matches[] = array( + 'string' => $string, + 'context' => $t_matches[2][$key], + ); + } + + // Add string from Drupal.formatPlural(). + foreach ($plural_matches[1] as $key => $string) { + $matches[] = array( + 'string' => $string, + 'context' => $plural_matches[3][$key], + ); + + // If there is also a plural version of this string, add it to the strings array. + if (isset($plural_matches[2][$key])) { + $matches[] = array( + 'string' => $plural_matches[2][$key], + 'context' => $plural_matches[3][$key], + ); + } + } + + foreach ($matches as $key => $match) { + // Remove the quotes and string concatenations from the string. + $string = implode('', preg_split('~(? $string, ':context' => $context))->fetchObject(); + if ($source) { + // We already have this source string and now have to add the location + // to the location column, if this file is not yet present in there. + $locations = preg_split('~\s*;\s*~', $source->location); + + if (!in_array($filepath, $locations)) { + $locations[] = $filepath; + $locations = implode('; ', $locations); + + // Save the new locations string to the database. + db_update('locales_source') + ->fields(array( + 'location' => $locations, + )) + ->condition('lid', $source->lid) + ->execute(); + } + } + else { + // We don't have the source string yet, thus we insert it into the database. + db_insert('locales_source') + ->fields(array( + 'location' => $filepath, + 'source' => $string, + 'context' => $context, + 'textgroup' => 'default', + )) + ->execute(); + } + } +} + +/** + * @addtogroup locale-api-import-export + * @{ + */ + +/** + * Generates a structured array of all strings with translations in + * $language, if given. This array can be used to generate an export + * of the string in the database. + * + * @param $language + * Language object to generate the output for, or NULL if generating + * translation template. + * @param $group + * Text group to export PO file from (eg. 'default' for interface + * translations). + */ +function _locale_export_get_strings($language = NULL, $group = 'default') { + 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 WHERE s.textgroup = :textgroup ORDER BY t.plid, t.plural", array(':language' => $language->language, ':textgroup' => $group)); + } + 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 WHERE s.textgroup = :textgroup ORDER BY t.plid, t.plural", array(':textgroup' => $group)); + } + $strings = array(); + foreach ($result as $child) { + $string = 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; +} + +/** + * Generates the PO(T) file contents for given strings. + * + * @param $language + * Language object to generate the output for, or NULL if generating + * translation template. + * @param $strings + * Array of strings to export. See _locale_export_get_strings() + * on how it should be formatted. + * @param $header + * The header portion to use for the output file. Defaults + * are provided for PO and POT files. + */ +function _locale_export_po_generate($language = NULL, $strings = array(), $header = NULL) { + global $user; + + if (!isset($header)) { + if (isset($language)) { + $header = '# ' . $language->name . ' translation of ' . variable_get('site_name', 'Drupal') . "\n"; + $header .= '# Generated by ' . $user->name . ' <' . $user->mail . ">\n"; + $header .= "#\n"; + $header .= "msgid \"\"\n"; + $header .= "msgstr \"\"\n"; + $header .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n"; + $header .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n"; + $header .= "\"PO-Revision-Date: " . date("Y-m-d H:iO") . "\\n\"\n"; + $header .= "\"Last-Translator: NAME \\n\"\n"; + $header .= "\"Language-Team: LANGUAGE \\n\"\n"; + $header .= "\"MIME-Version: 1.0\\n\"\n"; + $header .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; + $header .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; + if ($language->formula && $language->plurals) { + $header .= "\"Plural-Forms: nplurals=" . $language->plurals . "; plural=" . strtr($language->formula, array('$' => '')) . ";\\n\"\n"; + } + } + else { + $header = "# LANGUAGE translation of PROJECT\n"; + $header .= "# Copyright (c) YEAR NAME \n"; + $header .= "#\n"; + $header .= "msgid \"\"\n"; + $header .= "msgstr \"\"\n"; + $header .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n"; + $header .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n"; + $header .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n"; + $header .= "\"Last-Translator: NAME \\n\"\n"; + $header .= "\"Language-Team: LANGUAGE \\n\"\n"; + $header .= "\"MIME-Version: 1.0\\n\"\n"; + $header .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; + $header .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; + $header .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n"; + } + } + + $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 < $language->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 = ''; + } + } + } + else { + $output .= 'msgstr[0] ""' . "\n"; + $output .= 'msgstr[1] ""' . "\n"; + } + } + else { + $output .= 'msgstr ' . _locale_export_string($string['translation']); + } + $output .= "\n"; + } + } + return $output; +} + +/** + * Write a generated PO or POT file to the output. + * + * @param $language + * Language object to generate the output for, or NULL if generating + * translation template. + * @param $output + * The PO(T) file to output as a string. See _locale_export_generate_po() + * on how it can be generated. + */ +function _locale_export_po($language = NULL, $output = NULL) { + // Log the export event. + if (isset($language)) { + $filename = $language->language . '.po'; + watchdog('locale', 'Exported %locale translation file: %filename.', array('%locale' => $language->name, '%filename' => $filename)); + } + else { + $filename = 'drupal.pot'; + watchdog('locale', 'Exported translation file: %filename.', array('%filename' => $filename)); + } + // Download the file for the client. + header("Content-Disposition: attachment; filename=$filename"); + header("Content-Type: text/plain; charset=utf-8"); + print $output; + drupal_exit(); +} + +/** + * Print out a string on multiple lines + */ +function _locale_export_string($str) { + $stri = addcslashes($str, "\0..\37\\\""); + $parts = array(); + + // Cut text into several lines + while ($stri != "") { + $i = strpos($stri, "\\n"); + if ($i === FALSE) { + $curstr = $stri; + $stri = ""; + } + else { + $curstr = substr($stri, 0, $i + 2); + $stri = substr($stri, $i + 2); + } + $curparts = explode("\n", _locale_export_wrap($curstr, 70)); + $parts = array_merge($parts, $curparts); + } + + // Multiline string + if (count($parts) > 1) { + return "\"\"\n\"" . implode("\"\n\"", $parts) . "\"\n"; + } + // Single line string + elseif (count($parts) == 1) { + return "\"$parts[0]\"\n"; + } + // No translation + else { + return "\"\"\n"; + } +} + +/** + * Custom word wrapping for Portable Object (Template) files. + */ +function _locale_export_wrap($str, $len) { + $words = explode(' ', $str); + $return = array(); + + $cur = ""; + $nstr = 1; + while (count($words)) { + $word = array_shift($words); + if ($nstr) { + $cur = $word; + $nstr = 0; + } + elseif (strlen("$cur $word") > $len) { + $return[] = $cur . " "; + $cur = $word; + } + else { + $cur = "$cur $word"; + } + } + $return[] = $cur; + + 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" + */ + +/** + * @defgroup locale-api-seek Translation search API + * @{ + * Functions to search in translation files. + * + * These functions provide the functionality to search for specific + * translations. + */ + +/** + * Perform a string search and display results in a table + */ +function _locale_translate_seek() { + $output = ''; + + // We have at least one criterion to match + if (!($query = _locale_translate_seek_query())) { + $query = array( + 'translation' => 'all', + 'group' => 'all', + 'language' => 'all', + 'string' => '', + ); + } + + $sql_query = db_select('locales_source', 's'); + + $limit_language = NULL; + if ($query['language'] != 'en' && $query['language'] != 'all') { + $sql_query->leftJoin('locales_target', 't', "t.lid = s.lid AND t.language = :langcode", array(':langcode' => $query['language'])); + $limit_language = $query['language']; + } + else { + $sql_query->leftJoin('locales_target', 't', 't.lid = s.lid'); + } + + $sql_query->fields('s', array('source', 'location', 'context', 'lid', 'textgroup')); + $sql_query->fields('t', array('translation', 'language')); + + // Compute LIKE section. + switch ($query['translation']) { + case 'translated': + $sql_query->condition('t.translation', '%' . db_like($query['string']) . '%', 'LIKE'); + $sql_query->orderBy('t.translation', 'DESC'); + break; + case 'untranslated': + $sql_query->condition(db_and() + ->condition('s.source', '%' . db_like($query['string']) . '%', 'LIKE') + ->isNull('t.translation') + ); + $sql_query->orderBy('s.source'); + break; + case 'all' : + default: + $condition = db_or() + ->condition('s.source', '%' . db_like($query['string']) . '%', 'LIKE'); + if ($query['language'] != 'en') { + // Only search in translations if the language is not forced to English. + $condition->condition('t.translation', '%' . db_like($query['string']) . '%', 'LIKE'); + } + $sql_query->condition($condition); + break; + } + + // Add a condition on the text group. + if (!empty($query['group']) && $query['group'] != 'all') { + $sql_query->condition('s.textgroup', $query['group']); + } + + $sql_query = $sql_query->extend('PagerDefault')->limit(50); + $locales = $sql_query->execute(); + + $groups = module_invoke_all('locale', 'groups'); + $header = array(t('Text group'), t('String'), t('Context'), ($limit_language) ? t('Language') : t('Languages'), array('data' => t('Operations'), 'colspan' => '2')); + + $strings = array(); + foreach ($locales as $locale) { + if (!isset($strings[$locale->lid])) { + $strings[$locale->lid] = array( + 'group' => $locale->textgroup, + 'languages' => array(), + 'location' => $locale->location, + 'source' => $locale->source, + 'context' => $locale->context, + ); + } + if (isset($locale->language)) { + $strings[$locale->lid]['languages'][$locale->language] = $locale->translation; + } + } + + $rows = array(); + foreach ($strings as $lid => $string) { + $rows[] = array( + $groups[$string['group']], + array('data' => check_plain(truncate_utf8($string['source'], 150, FALSE, TRUE)) . '
' . $string['location'] . ''), + $string['context'], + array('data' => _locale_translate_language_list($string, $limit_language), 'align' => 'center'), + array('data' => l(t('edit'), "admin/config/regional/translate/edit/$lid", array('query' => drupal_get_destination())), 'class' => array('nowrap')), + array('data' => l(t('delete'), "admin/config/regional/translate/delete/$lid", array('query' => drupal_get_destination())), 'class' => array('nowrap')), + ); + } + + $output .= theme('table', array('header' => $header, 'rows' => $rows, 'empty' => t('No strings available.'))); + $output .= theme('pager'); + + return $output; +} + +/** + * Build array out of search criteria specified in request variables + */ +function _locale_translate_seek_query() { + $query = &drupal_static(__FUNCTION__); + if (!isset($query)) { + $query = array(); + $fields = array('string', 'language', 'translation', 'group'); + foreach ($fields as $field) { + if (isset($_SESSION['locale_translation_filter'][$field])) { + $query[$field] = $_SESSION['locale_translation_filter'][$field]; + } + } + } + return $query; +} + +/** + * Force the JavaScript translation file(s) to be refreshed. + * + * This function sets a refresh flag for a specified language, or all + * languages except English, if none specified. JavaScript translation + * files are rebuilt (with locale_update_js_files()) the next time a + * request is served in that language. + * + * @param $langcode + * The language code for which the file needs to be refreshed. + * + * @return + * New content of the 'javascript_parsed' variable. + */ +function _locale_invalidate_js($langcode = NULL) { + $parsed = variable_get('javascript_parsed', array()); + + if (empty($langcode)) { + // Invalidate all languages. + $languages = language_list(); + unset($languages['en']); + foreach ($languages as $lcode => $data) { + $parsed['refresh:' . $lcode] = 'waiting'; + } + } + else { + // Invalidate single language. + $parsed['refresh:' . $langcode] = 'waiting'; + } + + variable_set('javascript_parsed', $parsed); + return $parsed; +} + +/** + * (Re-)Creates the JavaScript translation file for a language. + * + * @param $language + * The language, the translation file should be (re)created for. + */ +function _locale_rebuild_js($langcode = NULL) { + if (!isset($langcode)) { + global $language; + } + else { + // Get information about the locale. + $languages = language_list(); + $language = $languages[$langcode]; + } + + // Construct the array for JavaScript translations. + // Only add strings with a translation to the translations array. + $result = db_query("SELECT s.lid, s.source, s.context, t.translation FROM {locales_source} s INNER JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.location LIKE '%.js%' AND s.textgroup = :textgroup", array(':language' => $language->language, ':textgroup' => 'default')); + + $translations = array(); + foreach ($result as $data) { + $translations[$data->context][$data->source] = $data->translation; + } + + // Construct the JavaScript file, if there are translations. + $data_hash = NULL; + $data = $status = ''; + if (!empty($translations)) { + + $data = "Drupal.locale = { "; + + if (!empty($language->formula)) { + $data .= "'pluralFormula': function (\$n) { return Number({$language->formula}); }, "; + } + + $data .= "'strings': " . drupal_json_encode($translations) . " };"; + $data_hash = drupal_hash_base64($data); + } + + // Construct the filepath where JS translation files are stored. + // There is (on purpose) no front end to edit that variable. + $dir = 'public://' . variable_get('locale_js_directory', 'languages'); + + // Delete old file, if we have no translations anymore, or a different file to be saved. + $changed_hash = $language->javascript != $data_hash; + if (!empty($language->javascript) && (!$data || $changed_hash)) { + file_unmanaged_delete($dir . '/' . $language->language . '_' . $language->javascript . '.js'); + $language->javascript = ''; + $status = 'deleted'; + } + + // Only create a new file if the content has changed or the original file got + // lost. + $dest = $dir . '/' . $language->language . '_' . $data_hash . '.js'; + if ($data && ($changed_hash || !file_exists($dest))) { + // Ensure that the directory exists and is writable, if possible. + file_prepare_directory($dir, FILE_CREATE_DIRECTORY); + + // Save the file. + if (file_unmanaged_save_data($data, $dest)) { + $language->javascript = $data_hash; + // If we deleted a previous version of the file and we replace it with a + // new one we have an update. + if ($status == 'deleted') { + $status = 'updated'; + } + // If the file did not exist previously and the data has changed we have + // a fresh creation. + elseif ($changed_hash) { + $status = 'created'; + } + // If the data hash is unchanged the translation was lost and has to be + // rebuilt. + else { + $status = 'rebuilt'; + } + } + else { + $language->javascript = ''; + $status = 'error'; + } + } + + // Save the new JavaScript hash (or an empty value if the file just got + // deleted). Act only if some operation was executed that changed the hash + // code. + if ($status && $changed_hash) { + db_update('languages') + ->fields(array( + 'javascript' => $language->javascript, + )) + ->condition('language', $language->language) + ->execute(); + + // Update the default language variable if the default language has been altered. + // This is necessary to keep the variable consistent with the database + // version of the language and to prevent checking against an outdated hash. + $default_langcode = language_default('language'); + if ($default_langcode == $language->language) { + $default = db_query("SELECT * FROM {languages} WHERE language = :language", array(':language' => $default_langcode))->fetchObject(); + variable_set('language_default', $default); + } + } + + // Log the operation and return success flag. + switch ($status) { + case 'updated': + watchdog('locale', 'Updated JavaScript translation file for the language %language.', array('%language' => t($language->name))); + return TRUE; + case 'rebuilt': + watchdog('locale', 'JavaScript translation file %file.js was lost.', array('%file' => $language->javascript), WATCHDOG_WARNING); + // Proceed to the 'created' case as the JavaScript translation file has + // been created again. + case 'created': + watchdog('locale', 'Created JavaScript translation file for the language %language.', array('%language' => t($language->name))); + return TRUE; + case 'deleted': + watchdog('locale', 'Removed JavaScript translation file for the language %language, because no translations currently exist for that language.', array('%language' => t($language->name))); + return TRUE; + case 'error': + watchdog('locale', 'An error occurred during creation of the JavaScript translation file for the language %language.', array('%language' => t($language->name)), WATCHDOG_ERROR); + return FALSE; + default: + // No operation needed. + return TRUE; + } +} + +/** + * List languages in search result table + */ +function _locale_translate_language_list($string, $limit_language) { + // Add CSS. + drupal_add_css(drupal_get_path('module', 'locale') . '/locale.css'); + + // Include both translated and not yet translated target languages in the + // list. The source language is English for built-in strings and the default + // language for other strings. + $languages = language_list(); + $default = language_default(); + $omit = $string['group'] == 'default' ? 'en' : $default->language; + unset($languages[$omit]); + $output = ''; + foreach ($languages as $langcode => $language) { + if (!$limit_language || $limit_language == $langcode) { + $output .= (!empty($string['languages'][$langcode])) ? $langcode . ' ' : "$langcode "; + } + } + + return $output; +} +/** + * @} End of "locale-api-seek" + */ + +/** + * @defgroup locale-api-predefined List of predefined languages + * @{ + * API to provide a list of predefined languages. + */ + +/** + * Prepares the language code list for a select form item with only the unsupported ones + */ +function _locale_prepare_predefined_list() { + include_once DRUPAL_ROOT . '/includes/iso.inc'; + $languages = language_list(); + $predefined = _locale_get_predefined_list(); + foreach ($predefined as $key => $value) { + if (isset($languages[$key])) { + unset($predefined[$key]); + continue; + } + // Include native name in output, if possible + if (count($value) > 1) { + $tname = t($value[0]); + $predefined[$key] = ($tname == $value[1]) ? $tname : "$tname ($value[1])"; + } + else { + $predefined[$key] = t($value[0]); + } + } + asort($predefined); + return $predefined; +} + +/** + * @} End of "locale-api-languages-predefined" + */ + +/** + * @defgroup locale-autoimport Automatic interface translation import + * @{ + * Functions to create batches for importing translations. + * + * These functions can be used to import translations for installed + * modules. + */ + +/** + * Prepare a batch to import translations for all enabled + * modules in a given language. + * + * @param $langcode + * Language code to import translations for. + * @param $finished + * Optional finished callback for the batch. + * @param $skip + * Array of component names to skip. Used in the installer for the + * second pass import, when most components are already imported. + * + * @return + * A batch structure or FALSE if no files found. + */ +function locale_batch_by_language($langcode, $finished = NULL, $skip = array()) { + // Collect all files to import for all enabled modules and themes. + $files = array(); + $components = array(); + $query = db_select('system', 's'); + $query->fields('s', array('name', 'filename')); + $query->condition('s.status', 1); + if (count($skip)) { + $query->condition('name', $skip, 'NOT IN'); + } + $result = $query->execute(); + foreach ($result as $component) { + // Collect all files for all components, names as $langcode.po or + // with names ending with $langcode.po. This allows for filenames + // like node-module.de.po to let translators use small files and + // be able to import in smaller chunks. + $files = array_merge($files, file_scan_directory(dirname($component->filename) . '/translations', '/(^|\.)' . $langcode . '\.po$/', array('recurse' => FALSE))); + $components[] = $component->name; + } + + return _locale_batch_build($files, $finished, $components); +} + +/** + * Prepare a batch to run when installing modules or enabling themes. + * + * This batch will import translations for the newly added components + * in all the languages already set up on the site. + * + * @param $components + * An array of component (theme and/or module) names to import + * translations for. + * @param $finished + * Optional finished callback for the batch. + */ +function locale_batch_by_component($components, $finished = '_locale_batch_system_finished') { + $files = array(); + $languages = language_list('enabled'); + unset($languages[1]['en']); + if (count($languages[1])) { + $language_list = join('|', array_keys($languages[1])); + // Collect all files to import for all $components. + $result = db_query("SELECT name, filename FROM {system} WHERE status = 1"); + foreach ($result as $component) { + if (in_array($component->name, $components)) { + // Collect all files for this component in all enabled languages, named + // as $langcode.po or with names ending with $langcode.po. This allows + // for filenames like node-module.de.po to let translators use small + // files and be able to import in smaller chunks. + $files = array_merge($files, file_scan_directory(dirname($component->filename) . '/translations', '/(^|\.)(' . $language_list . ')\.po$/', array('recurse' => FALSE))); + } + } + return _locale_batch_build($files, $finished); + } + return FALSE; +} + +/** + * Build a locale batch from an array of files. + * + * @param $files + * Array of files to import. + * @param $finished + * Optional finished callback for the batch. + * @param $components + * Optional list of component names the batch covers. Used in the installer. + * + * @return + * A batch structure. + */ +function _locale_batch_build($files, $finished = NULL, $components = array()) { + $t = get_t(); + if (count($files)) { + $operations = array(); + foreach ($files as $file) { + // We call _locale_batch_import for every batch operation. + $operations[] = array('_locale_batch_import', array($file->uri)); + } + $batch = array( + 'operations' => $operations, + 'title' => $t('Importing interface translations'), + 'init_message' => $t('Starting import'), + 'error_message' => $t('Error importing interface translations'), + 'file' => 'includes/locale.inc', + // This is not a batch API construct, but data passed along to the + // installer, so we know what did we import already. + '#components' => $components, + ); + if (isset($finished)) { + $batch['finished'] = $finished; + } + return $batch; + } + return FALSE; +} + +/** + * Implements callback_batch_operation(). + * + * Perform interface translation import as a batch step. + * + * @param $filepath + * Path to a file to import. + * @param $results + * Contains a list of files imported. + */ +function _locale_batch_import($filepath, &$context) { + // The filename is either {langcode}.po or {prefix}.{langcode}.po, so + // we can extract the language code to use for the import from the end. + if (preg_match('!(/|\.)([^\./]+)\.po$!', $filepath, $langcode)) { + $file = (object) array('filename' => drupal_basename($filepath), 'uri' => $filepath); + _locale_import_read_po('db-store', $file, LOCALE_IMPORT_KEEP, $langcode[2]); + $context['results'][] = $filepath; + } +} + +/** + * Implements callback_batch_finished(). + * + * Finished callback of system page locale import batch. + * Inform the user of translation files imported. + */ +function _locale_batch_system_finished($success, $results) { + if ($success) { + drupal_set_message(format_plural(count($results), 'One translation file imported for the newly installed modules.', '@count translation files imported for the newly installed modules.')); + } +} + +/** + * Implements callback_batch_finished(). + * + * Finished callback of language addition locale import batch. + * Inform the user of translation files imported. + */ +function _locale_batch_language_finished($success, $results) { + if ($success) { + drupal_set_message(format_plural(count($results), 'One translation file imported for the enabled modules.', '@count translation files imported for the enabled modules.')); + } +} + +/** + * @} End of "locale-autoimport" + */ + +/** + * Get list of all predefined and custom countries. + * + * @return + * An array of all country code => country name pairs. + */ +function country_get_list() { + include_once DRUPAL_ROOT . '/includes/iso.inc'; + $countries = _country_get_predefined_list(); + // Allow other modules to modify the country list. + drupal_alter('countries', $countries); + return $countries; +} + +/** + * Save locale specific date formats to the database. + * + * @param $langcode + * Language code, can be 2 characters, e.g. 'en' or 5 characters, e.g. + * 'en-CA'. + * @param $type + * Date format type, e.g. 'short', 'medium'. + * @param $format + * The date format string. + */ +function locale_date_format_save($langcode, $type, $format) { + $locale_format = array(); + $locale_format['language'] = $langcode; + $locale_format['type'] = $type; + $locale_format['format'] = $format; + + $is_existing = (bool) db_query_range('SELECT 1 FROM {date_format_locale} WHERE language = :langcode AND type = :type', 0, 1, array(':langcode' => $langcode, ':type' => $type))->fetchField(); + if ($is_existing) { + $keys = array('type', 'language'); + drupal_write_record('date_format_locale', $locale_format, $keys); + } + else { + drupal_write_record('date_format_locale', $locale_format); + } +} + +/** + * Select locale date format details from database. + * + * @param $languages + * An array of language codes. + * + * @return + * An array of date formats. + */ +function locale_get_localized_date_format($languages) { + $formats = array(); + + // Get list of different format types. + $format_types = system_get_date_types(); + $short_default = variable_get('date_format_short', 'm/d/Y - H:i'); + + // Loop through each language until we find one with some date formats + // configured. + foreach ($languages as $language) { + $date_formats = system_date_format_locale($language); + if (!empty($date_formats)) { + // We have locale-specific date formats, so check for their types. If + // we're missing a type, use the default setting instead. + foreach ($format_types as $type => $type_info) { + // If format exists for this language, use it. + if (!empty($date_formats[$type])) { + $formats['date_format_' . $type] = $date_formats[$type]; + } + // Otherwise get default variable setting. If this is not set, default + // to the short format. + else { + $formats['date_format_' . $type] = variable_get('date_format_' . $type, $short_default); + } + } + + // Return on the first match. + return $formats; + } + } + + // No locale specific formats found, so use defaults. + $system_types = array('short', 'medium', 'long'); + // Handle system types separately as they have defaults if no variable exists. + $formats['date_format_short'] = $short_default; + $formats['date_format_medium'] = variable_get('date_format_medium', 'D, m/d/Y - H:i'); + $formats['date_format_long'] = variable_get('date_format_long', 'l, F j, Y - H:i'); + + // For non-system types, get the default setting, otherwise use the short + // format. + foreach ($format_types as $type => $type_info) { + if (!in_array($type, $system_types)) { + $formats['date_format_' . $type] = variable_get('date_format_' . $type, $short_default); + } + } + + return $formats; +}