From e1ccecc9fb648962014dad08e678bdf401d3b0f1 Mon Sep 17 00:00:00 2001 From: webchick <webchick@24967.no-reply.drupal.org> Date: Sat, 27 Aug 2011 23:04:48 +0100 Subject: [PATCH] Issue #1219236 follow-up: Commit missing files. --- includes/gettext.inc | 1099 ++++++++++++++++++++++++++++++++ modules/locale/locale.bulk.inc | 309 +++++++++ 2 files changed, 1408 insertions(+) create mode 100644 includes/gettext.inc create mode 100644 modules/locale/locale.bulk.inc diff --git a/includes/gettext.inc b/includes/gettext.inc new file mode 100644 index 000000000000..39a6fbe9ec3a --- /dev/null +++ b/includes/gettext.inc @@ -0,0 +1,1099 @@ +<?php + +/** + * @file + * Gettext parsing and generating API. + * + * @todo Decouple these functions from Locale API and put to gettext_ namespace. + */ + +/** + * @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. + */ +function _locale_import_po($file, $langcode, $mode) { + // Try to allocate enough time to parse and import the data. + drupal_set_time_limit(240); + + // 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); + 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. + */ +function _locale_import_read_po($op, $file, $mode = NULL, $lang = NULL) { + + // 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)) { + // 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); + + // 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); + + // 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); + $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); + } + 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'. + */ +function _locale_import_one_string($op, $value = NULL, $mode = NULL, $lang = NULL, $file = NULL) { + $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 the plural formula and update in database. + 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(); + } + else { + db_update('languages') + ->fields(array( + 'plurals' => 0, + 'formula' => '', + )) + ->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, $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, $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 $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, $location, $mode, $plid = 0, $plural = 0) { + $lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = :context", array(':source' => $source, ':context' => $context))->fetchField(); + + if (!empty($translation)) { + // Skip this string unless it passes a check for dangerous code. + if (!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, + )) + ->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 + } +} + +/** + * 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. + */ +function _locale_export_get_strings($language = NULL) { + if (isset($language)) { + $result = db_query("SELECT s.lid, s.source, s.context, s.location, t.translation, t.plid, t.plural FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language ORDER BY t.plid, t.plural", array(':language' => $language->language)); + } + else { + $result = db_query("SELECT s.lid, s.source, s.context, s.location, t.plid, t.plural FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid ORDER BY t.plid, t.plural"); + } + $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 <EMAIL@ADDRESS>\\n\"\n"; + $header .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\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 <EMAIL@ADDRESS>\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 <EMAIL@ADDRESS>\\n\"\n"; + $header .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\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" + */ diff --git a/modules/locale/locale.bulk.inc b/modules/locale/locale.bulk.inc new file mode 100644 index 000000000000..8d6ed8f3c173 --- /dev/null +++ b/modules/locale/locale.bulk.inc @@ -0,0 +1,309 @@ +<?php + +/** + * @file + * Mass import-export and batch import functionality for Gettext .po files. + */ + +include_once DRUPAL_ROOT . '/includes/gettext.inc'; + +/** + * User interface for the translation import screen. + */ +function locale_translate_import_form($form) { + // Get all languages, except English + drupal_static_reset('language_list'); + $names = locale_language_list('name'); + unset($names['en']); + + if (!count($names)) { + $languages = _locale_prepare_predefined_list(); + $default = key($languages); + } + else { + $languages = array( + t('Already added languages') => $names, + t('Languages not yet added') => _locale_prepare_predefined_list() + ); + $default = key($names); + } + + $form['import'] = array('#type' => 'fieldset', + '#title' => t('Import translation'), + ); + $form['import']['file'] = array('#type' => 'file', + '#title' => t('Language file'), + '#size' => 50, + '#description' => t('A Gettext Portable Object (<em>.po</em>) file.'), + ); + $form['import']['langcode'] = array('#type' => 'select', + '#title' => t('Import into'), + '#options' => $languages, + '#default_value' => $default, + '#description' => t('Choose the language you want to add strings into. If you choose a language which is not yet set up, it will be added.'), + ); + $form['import']['mode'] = array('#type' => 'radios', + '#title' => t('Mode'), + '#default_value' => LOCALE_IMPORT_KEEP, + '#options' => array( + LOCALE_IMPORT_OVERWRITE => t('Strings in the uploaded file replace existing ones, new ones are added. The plural format is updated.'), + LOCALE_IMPORT_KEEP => t('Existing strings and the plural format are kept, only new strings are added.') + ), + ); + $form['import']['submit'] = array('#type' => 'submit', '#value' => t('Import')); + + return $form; +} + +/** + * Process the locale import form submission. + */ +function locale_translate_import_form_submit($form, &$form_state) { + $validators = array('file_validate_extensions' => array('po')); + // Ensure we have the file uploaded + if ($file = file_save_upload('file', $validators)) { + + // Add language, if not yet supported + drupal_static_reset('language_list'); + $languages = language_list('language'); + $langcode = $form_state['values']['langcode']; + if (!isset($languages[$langcode])) { + include_once DRUPAL_ROOT . '/includes/iso.inc'; + $predefined = _locale_get_predefined_list(); + locale_add_language($langcode); + drupal_set_message(t('The language %language has been created.', array('%language' => t($predefined[$langcode][0])))); + } + + // Now import strings into the language + if ($return = _locale_import_po($file, $langcode, $form_state['values']['mode']) == FALSE) { + $variables = array('%filename' => $file->filename); + drupal_set_message(t('The translation import of %filename failed.', $variables), 'error'); + watchdog('locale', 'The translation import of %filename failed.', $variables, WATCHDOG_ERROR); + } + } + else { + drupal_set_message(t('File to import not found.'), 'error'); + $form_state['redirect'] = 'admin/config/regional/translate/import'; + return; + } + + $form_state['redirect'] = 'admin/config/regional/translate'; + return; +} + +/** + * User interface for the translation export screen. + */ +function locale_translate_export_screen() { + // Get all languages, except English + drupal_static_reset('language_list'); + $names = locale_language_list('name'); + unset($names['en']); + $output = ''; + // Offer translation export if any language is set up. + if (count($names)) { + $elements = drupal_get_form('locale_translate_export_po_form', $names); + $output = drupal_render($elements); + } + $elements = drupal_get_form('locale_translate_export_pot_form'); + $output .= drupal_render($elements); + return $output; +} + +/** + * Form to export PO files for the languages provided. + * + * @param $names + * An associate array with localized language names + */ +function locale_translate_export_po_form($form, &$form_state, $names) { + $form['export_title'] = array('#type' => 'item', + '#title' => t('Export translation'), + ); + $form['langcode'] = array('#type' => 'select', + '#title' => t('Language name'), + '#options' => $names, + '#description' => t('Select the language to export in Gettext Portable Object (<em>.po</em>) format.'), + ); + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Export')); + return $form; +} + +/** + * Translation template export form. + */ +function locale_translate_export_pot_form() { + // Complete template export of the strings + $form['export_title'] = array('#type' => 'item', + '#title' => t('Export template'), + '#description' => t('Generate a Gettext Portable Object Template (<em>.pot</em>) file with all strings from the Drupal locale database.'), + ); + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Export')); + // Reuse PO export submission callback. + $form['#submit'][] = 'locale_translate_export_po_form_submit'; + return $form; +} + +/** + * Process a translation (or template) export form submission. + */ +function locale_translate_export_po_form_submit($form, &$form_state) { + // If template is required, language code is not given. + $language = NULL; + if (isset($form_state['values']['langcode'])) { + $languages = language_list(); + $language = $languages[$form_state['values']['langcode']]; + } + _locale_export_po($language, _locale_export_po_generate($language, _locale_export_get_strings($language))); +} + +/** + * 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' => drupal_get_path('module', 'locale') . '/locale.bulk.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; +} + +/** + * 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' => basename($filepath), 'uri' => $filepath); + _locale_import_read_po('db-store', $file, LOCALE_IMPORT_KEEP, $langcode[2]); + $context['results'][] = $filepath; + } +} + +/** + * 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.')); + } +} + +/** + * 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.')); + } +} -- GitLab