diff --git a/core/includes/gettext.inc b/core/includes/gettext.inc deleted file mode 100644 index 4e3b221..0000000 --- a/core/includes/gettext.inc +++ /dev/null @@ -1,1131 +0,0 @@ - $file->filename)), 'error'); - } - - // Clear cache and force refresh of JavaScript translations. - _locale_invalidate_js($langcode); - cache()->deletePrefix('locale:'); - - // Rebuild the menu, strings may have changed. - menu_router_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) { - if (module_exists('dblog')) { - $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. See the log for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.', array('@url' => url('admin/reports/dblog'))); - } - else { - $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. See the log for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.'); - } - drupal_set_message($skip_message, 'error'); - watchdog('locale', '@count disallowed HTML string(s) in %file', array('@count' => $skips, '%file' => $file->uri), WATCHDOG_WARNING); - } - return TRUE; -} - -/** - * Parses a 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 $overwrite_options - * An associative array indicating what data should be overwritten, if any. - * - not_customized: not customized strings should be overwritten. - * - customized: customized strings should be overwritten. - * @param $lang - * Language code. - * @param $customized - * Whether the strings being imported should be saved as customized. - * Use LOCALE_CUSTOMIZED or LOCALE_NOT_CUSTOMIZED. - */ -function _locale_import_read_po($op, $file, $overwrite_options = NULL, $lang = NULL, $customized = LOCALE_NOT_CUSTOMIZED) { - - // 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, $overwrite_options, $lang, $file, $customized); - - // 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'] .= LOCALE_PLURAL_DELIMITER . $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, $overwrite_options, $lang, $file, $customized); - - // 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, $overwrite_options, $lang, $file, $customized); - $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, $overwrite_options, $lang, $file, $customized); - } - elseif ($context != 'COMMENT') { - _locale_import_message('The translation file %filename ended unexpectedly at line %line.', $file, $lineno); - return FALSE; - } -} - -/** - * Sets an error message if an error 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'); -} - -/** - * Performs the specified operation for one string. - * - * @param $op - * Operation to perform: 'db-store', 'db-report', 'mem-store' or 'mem-report'. - * @param $value - * Details of the string stored. - * @param $overwrite_options - * An associative array indicating what data should be overwritten, if any. - * - not_customized: not customized strings should be overwritten. - * - customized: customized strings should be overwritten. - * @param $lang - * Language to store the string in. - * @param $file - * Object representation of file being imported, only required when op is - * 'db-store'. - * @param $customized - * Whether the strings being imported should be saved as customized. - * Use LOCALE_CUSTOMIZED or LOCALE_NOT_CUSTOMIZED. - */ -function _locale_import_one_string($op, $value = NULL, $overwrite_options = NULL, $lang = NULL, $file = NULL, $customized = LOCALE_NOT_CUSTOMIZED) { - $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': - - if ($value['msgid'] == '') { - // If 'msgid' is empty, it means we got values for the header of the - // file as per the structure of the Gettext format. - $locale_plurals = variable_get('locale_translation_plurals', array()); - if (array_sum($overwrite_options) || empty($locale_plurals[$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, $formula) = $p; - $locale_plurals[$lang] = array( - 'plurals' => $nplurals, - 'formula' => $formula, - ); - variable_set('locale_translation_plurals', $locale_plurals); - } - } - $header_done = TRUE; - } - - else { - // Found a string to store, clean up and prepare the data. - $comments = _locale_import_shorten_comments(empty($value['#']) ? array() : $value['#']); - - if (is_array($value['msgstr'])) { - // Sort plural variants by their form index. - ksort($value['msgstr']); - // Serialize plural variants in one string by LOCALE_PLURAL_DELIMITER. - $value['msgstr'] = implode(LOCALE_PLURAL_DELIMITER, $value['msgstr']); - } - - _locale_import_one_string_db( - $report, - $lang, - isset($value['msgctxt']) ? $value['msgctxt'] : '', - $value['msgid'], - $value['msgstr'], - $comments, - $overwrite_options, - $customized - ); - } - } // end of db-store operation -} - -/** - * Imports 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 $overwrite_options - * An associative array indicating what data should be overwritten, if any. - * - not_customized: not customized strings should be overwritten. - * - customized: customized strings should be overwritten. - * @param $customized - * (optional) Whether the strings being imported should be saved as customized. - * Use LOCALE_CUSTOMIZED or LOCALE_NOT_CUSTOMIZED. - * - * @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, $overwrite_options, $customized = LOCALE_NOT_CUSTOMIZED) { - - // Initialize overwrite options if not set. - $overwrite_options += array( - 'not_customized' => FALSE, - 'customized' => FALSE, - ); - - // Look up the source string and any existing translation. - $string = db_query("SELECT s.lid, t.customized FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.source = :source AND s.context = :context", array( - ':source' => $source, - ':context' => $context, - ':language' => $langcode, - )) - ->fetchObject(); - - if (!empty($translation)) { - // Skip this string unless it passes a check for dangerous code. - if (!locale_string_is_safe($translation)) { - watchdog('locale', 'Import of string "%string" was skipped because of disallowed or malformed HTML.', array('%string' => $translation), WATCHDOG_ERROR); - $report['skips']++; - return 0; - } - elseif (isset($string->lid)) { - // We have this source string saved already. - db_update('locales_source') - ->fields(array( - 'location' => $location, - )) - ->condition('lid', $string->lid) - ->execute(); - - if (!isset($string->customized)) { - // No translation in this language. - db_insert('locales_target') - ->fields(array( - 'lid' => $string->lid, - 'language' => $langcode, - 'translation' => $translation, - 'customized' => $customized, - )) - ->execute(); - - $report['additions']++; - } - elseif ($overwrite_options[$string->customized ? 'customized' : 'not_customized']) { - // Translation exists, only overwrite if instructed. - db_update('locales_target') - ->fields(array( - 'translation' => $translation, - 'customized' => $customized, - )) - ->condition('language', $langcode) - ->condition('lid', $string->lid) - ->execute(); - - $report['updates']++; - } - return $string->lid; - } - 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, - 'customized' => $customized, - )) - ->execute(); - - $report['additions']++; - return $lid; - } - } - elseif (isset($string->lid) && isset($string->customized) && $overwrite_options[$string->customized ? 'customized' : 'not_customized']) { - // Empty translation, remove existing if instructed. - db_delete('locales_target') - ->condition('language', $langcode) - ->condition('lid', $string->lid) - ->execute(); - - $report['deletes']++; - return $string->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; - } -} - -/** - * Provides backward-compatible formula parsing for token_get_all(). - * - * @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; -} - -/** - * Generates 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 translated strings for the language. - * - * @param $language - * Language object to generate the output for, or NULL if generating - * translation template. - * @param $options - * (optional) An associative array specifying what to include in the output: - * - customized: include customized strings (if TRUE) - * - uncustomized: include non-customized string (if TRUE) - * - untranslated: include untranslated source strings (if TRUE) - * Ignored if $language is NULL. - * - * @return - * An array of translated strings that can be used to generate an export. - */ -function _locale_export_get_strings($language = NULL, $options = array()) { - - // Assume FALSE for all options if not provided by the API. - $options += array( - 'customized' => FALSE, - 'not_customized' => FALSE, - 'not_translated' => FALSE, - ); - if (array_sum($options) == 0) { - // If user asked to not include anything in the translation files, - // that would not make sense, so just fall back on providing a template. - $language = NULL; - } - - // Build and execute query to collect source strings and translations. - $query = db_select('locales_source', 's'); - if (!empty($language)) { - if ($options['not_translated']) { - // Left join to keep untranslated strings in. - $query->leftJoin('locales_target', 't', 's.lid = t.lid AND t.language = :language', array(':language' => $language->langcode)); - } - else { - // Inner join to filter for only translations. - $query->innerJoin('locales_target', 't', 's.lid = t.lid AND t.language = :language', array(':language' => $language->langcode)); - } - if ($options['customized']) { - if (!$options['not_customized']) { - // Filter for customized strings only. - $query->condition('t.customized', LOCALE_CUSTOMIZED); - } - // Else no filtering needed in this case. - } - else { - if ($options['not_customized']) { - // Filter for non-customized strings only. - $query->condition('t.customized', LOCALE_NOT_CUSTOMIZED); - } - else { - // Filter for strings without translation. - $query->isNull('t.translation'); - } - } - $query->fields('t', array('translation')); - } - else { - $query->leftJoin('locales_target', 't', 's.lid = t.lid'); - } - $query->fields('s', array('lid', 'source', 'context', 'location')); - $result = $query->execute(); - - // Structure results in an array with metainformation on the strings. - $strings = array(); - foreach ($result as $child) { - $strings[$child->lid] = array( - 'comment' => $child->location, - 'source' => $child->source, - 'context' => $child->context, - 'translation' => isset($child->translation) ? $child->translation : '', - ); - } - return $strings; -} - -/** - * Generates the PO(T) file contents for the 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; - - $locale_plurals = variable_get('locale_translation_plurals', array()); - - 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 (!empty($locale_plurals[$language->langcode]['formula'])) { - $header .= "\"Plural-Forms: nplurals=" . $locale_plurals[$language->langcode]['plurals'] . "; plural=" . strtr($locale_plurals[$language->langcode]['formula'], array('$' => '')) . ";\\n\"\n"; - // Remember number of plural variants to optimize the export. - $nplurals = $locale_plurals[$language->langcode]['plurals']; - } - else { - // Remember we did not have a plural number for the export. - $nplurals = 0; - } - } - else { - $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) { - if ($string['comment']) { - $output .= '#: ' . $string['comment'] . "\n"; - } - if (!empty($string['context'])) { - $output .= 'msgctxt ' . _locale_export_string($string['context']); - } - if (strpos($string['source'], LOCALE_PLURAL_DELIMITER) !== FALSE) { - // Export plural string. - $export_array = explode(LOCALE_PLURAL_DELIMITER, $string['source']); - $output .= 'msgid ' . _locale_export_string($export_array[0]); - $output .= 'msgid_plural ' . _locale_export_string($export_array[1]); - if (isset($language)) { - $export_array = explode(LOCALE_PLURAL_DELIMITER, $string['translation']); - for ($i = 0; $i < $nplurals; $i++) { - if (isset($export_array[$i])) { - $output .= 'msgstr[' . $i . '] ' . _locale_export_string($export_array[$i]); - } - else { - $output .= 'msgstr[' . $i . '] ""' . "\n"; - } - } - } - else { - $output .= 'msgstr[0] ""' . "\n"; - $output .= 'msgstr[1] ""' . "\n"; - } - } - else { - $output .= 'msgid ' . _locale_export_string($string['source']); - $output .= 'msgstr ' . _locale_export_string($string['translation']); - } - $output .= "\n"; - } - return $output; -} - -/** - * Writes 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->langcode . '.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(); -} - -/** - * Prints 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"; - } -} - -/** - * Wraps text 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); -} - -/** - * @} End of "defgroup locale-api-import-export". - */ diff --git a/core/includes/gettext.sketch.inc b/core/includes/gettext.sketch.inc new file mode 100644 index 0000000..fdaa2c5 --- /dev/null +++ b/core/includes/gettext.sketch.inc @@ -0,0 +1,195 @@ +setURI('public://source.po.txt'); +$reader->setLangcode($langcode); +$reader->open(); + +// Get header +$header = $reader->getHeader(); + +$writer = new PoDatabaseWriter(); +$writer->setLangcode($langcode); + +// Write 10 items +$writer->writeItems($reader, 10); +$state = $reader->getState(); +// Store state in Batch +$reader->setState($state); + +// Write remaining items +$writer->writeItems($reader); + +// Invoke it through batch +gettext_transfer($reader, $writer); + +/** + * Example code to export translations to a po file. + * + * Export gettext data to a PO file. + * The file may be local, remote (via stream wrapper) or even XML. + * The import has a small memory footprint. + * The import may be split up to enable batch handling. + */ + +$reader = new PoDatabaseReader; +$reader->setLangcode($langcode); +$destination = 'public://destination.po.txt'; +$writer = new PoFileWriter(); +$writer->setURI($destination); +$writer->setLangcode($langcode); +$writer->setHeader($reader->getHeader()); + + +$writer->writeItems($reader); + +/** + * Transfers gettext data from source to destination. + */ +function gettext_transfer($reader, $writer) { + // Use batch processing if both source and destination support it and the source + // is large enough. If not, process in once. + if ($reader instanceof BatchStateInterface && $writer instanceof BatchStateInterface) { + // Transfer data in batches. + // Built and execute batch. + $batch = gettext_transfer_batch_setup($reader, $writer); + batch_set($batch); + } + else { + $writer->writeItems($reader); + } +} + +/** + * Set up a batch process to transfer Gettext data. + */ +function gettext_transfer_batch_setup($reader, $writer) { + $batch = array( + 'operations' => array( + // TODO: can this be objects? + // Shouldn't we use $reader->getState() / $writer->getState(); + array('gettext_transfer_batch_op', array($reader, $writer)), + ), + 'finished' => 'gettext_transfer_batch_finished', + ); + return $batch; +} + +/** + * Batch operation transferring gettext data. + * + * @todo Add source filter conditions (context, language(?)) as parameter. + */ +function gettext_transfer_batch_op($source, $destination) { + // Initialize sandbox for batch reading. + if (empty($context['sandbox'])) { + $context['sandbox']['transfer'] = array( + 'chunk' => min($source->chunkSize(), $destination->chunkSize()), + ); + } + + // Execute one transfer cycle, which will process bite size 'chunks'. + $success = gettext_transfer_execute($source, $destination, $context['sandbox']['transfer']); + + // After a number of cycles executing gettext_transfer_execute() the transfer + // is completed. + // See gettextapi_import_batch_op() for details. + if ($context['sandbox']['transfer']['finished']) { + $context['finished'] = 1; + + if ($success) { + // Collect statistics from $context['sandbox']['transfer'] + // and tell the world about what great work we did. + } + } +} + +/** + * Batch wrap-up for gettext data transfer. + */ +function gettext_transfer_batch_finished($success, $results, $operations) { + if ($success) { + // Call post processing handler defined by the destination object. + // Can we get the callback and arguments via $results? + $post_process_callback = $results['post_process_callback']; + $arguments = $results['post_process_arguments']; + $post_process_callback($arguments); + + // We did it, Magoo! :) + } + else { + // Sadly reporting where it went wrong. + } +} + +/** + * Post processing for gettext language import into the database. + * + * To be called after successfull (batch) import. + */ +function gettext_post_process_import($langcode) { + // After succesfull import we refresh all affected parts of the system. + _locale_invalidate_js($langcode); + cache_clear_all('locale:', 'cache', TRUE); + menu_rebuild(); +} + +/** + * Transfer gettext data from source to destination in bite size chunks. + */ +function gettext_transfer_execute($source, $destination, &$transfer) { + // If transfer is not yet started we set the header data. + // The header (if supported) is written before the first translation is written. + if (!$destination->inProgress()) { + $destination->setHeader($source->getHeader()); + } + + //TODO: this is not yet rewriten to PoFile|Database|Reader|Writer + $chunk = $transfer['chunk']; + // Transfer translations as long as valid data is available and + // the bite size chunk is not yet swallowed. + while ($source->valid() && $chunk > 0) { + // Get one translation from source, write it to destination. + $translation = $source->read(); + $destination->write($translation); + $chunk--; + } + + // Report the percentage of completion for progress reporting. + // Ai, user interface. Why do we bother ;) + $transfer['state']['percentage_of_completion'] = $source->poc(); + + // Close connections when we are done and report the results. + if ($source->finished()) { + $destination->finish(); + $transfer['finished'] = TRUE; + } + + // Report statistics including errors and error log. + $transfer['result'] = $destination->statistics(); +} diff --git a/core/includes/install.inc b/core/includes/install.inc index 96e84b3..fbd279e 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -2,6 +2,7 @@ use Drupal\Core\Database\Database; use Drupal\Core\Config\FileStorage; +use Drupal\Core\Gettext\Gettext; /** * Indicates that a module has not been installed yet. @@ -717,13 +718,7 @@ function st($string, array $args = array(), array $options = array()) { // that multiple files end with the same extension, even if unlikely. $files = install_find_translation_files($install_state['parameters']['langcode']); if (!empty($files)) { - // Include cross-dependent code from locale module and gettext.inc. - require_once DRUPAL_ROOT . '/core/modules/locale/locale.module'; - require_once DRUPAL_ROOT . '/core/includes/gettext.inc'; - foreach ($files as $file) { - _locale_import_read_po('mem-store', $file); - } - $strings = _locale_import_one_string('mem-report'); + $strings = Gettext::filesToArray($install_state['parameters']['langcode'], $files); } } } diff --git a/core/lib/Drupal/Component/Gettext/BatchStateInterface.php b/core/lib/Drupal/Component/Gettext/BatchStateInterface.php new file mode 100644 index 0000000..081ac24 --- /dev/null +++ b/core/lib/Drupal/Component/Gettext/BatchStateInterface.php @@ -0,0 +1,51 @@ + + * class MyReader implements BatchStateInterface { + * function __construct(){ + * // empty + * } + * ... + * function getState() { + * return array( + * '__CLASS__' => __CLASS__, + * 'my_key' => 'my value', + * ); + * } + * } + * + */ +interface BatchStateInterface { + + /** + * Returns the current state used for resetting state later on. + * + * The state is used to reconstruct the state of the object by calling + * setState(). + * + * The Class implemeting this interface must have an empty constructor. + * + * @return array + * key/value pairs of which one must be __CLASS__ + */ + function getState(); + + /** + * Sets the object ready to roll. + * + * After calling setState it is assumed the object is ready to do it's work. + */ + function setState(array $state); +} diff --git a/core/lib/Drupal/Component/Gettext/PoFileReader.php b/core/lib/Drupal/Component/Gettext/PoFileReader.php new file mode 100644 index 0000000..a6d972f --- /dev/null +++ b/core/lib/Drupal/Component/Gettext/PoFileReader.php @@ -0,0 +1,556 @@ +_uri; + } + + public function setURI($uri) { + $this->_uri = $uri; + } + + public function getLangcode() { + return $this->_langcode; + } + + public function setLangcode($langcode) { + $this->_langcode = $langcode; + } + + + public function open() { + if (!empty($this->_uri)) { + $this->_fd = fopen($this->_uri, 'rb'); + $this->_size = ftell($this->_fd); + // We immediately read the header as we are at BOF + $this->readHeader(); + } + else { + throw new \Exception("Cannot open without URI set"); + } + } + + public function close() { + if ($this->_fd) { + fclose($this->_fd); + } + } + + + public function setState(array $state) { + $this->setURI($state['uri']); + $this->setLangcode($state['langcode']); + // Make sure to (re)read the PoHeader + $this->open(); + // Move to last read position. + if (isset($state['seekpos'])) { + fseek($this->_fd, $state['seekpos']); + } + if (isset($state['lineno'])) { + $this->lineno = $state['lineno']; + } + } + + public function getState() { + return array( + 'class' => __CLASS__, + 'uri' => $this->_uri, + 'langcode' => $this->_langcode, + 'seekpos' => ftell($this->_fd), + 'lineno' => $this->lineno, + ); + } + + /** + * Return a translation object (singular or plural) + * + * @todo Define a translation object for this purpose? + * Or use a standard class for better performance? + */ + public function readItem() { + $this->readTranslation(); + return $this->translation; + } + + private function readTranslation() { + $this->translation = NULL; + while (!$this->finished && is_null($this->translation)) { + $this->readLine(); + } + return $this->translation; + } + + public function getHeader() { + return $this->_header; + } + + public function setHeader(PoHeader $header) { + // TODO : throw exception? + } + + /** + * Reads the header from the given input stream. + * + * We need to read the optional first COMMENT + * Next read a MSGID and a MSGSTR + */ + private function readHeader() { + $translation = $this->readTranslation(); + $header = new PoHeader; + $header->setFromString(trim($translation->translation)); + $this->_header = $header; + } + + /** + * Reads a line from a PO file. + * + * While reading a line it's content is processed according to current + * context. + * + * The parser context. Can be: + * - 'COMMENT' (#) + * - 'MSGID' (msgid) + * - 'MSGID_PLURAL' (msgid_plural) + * - 'MSGCTXT' (msgctxt) + * - 'MSGSTR' (msgstr or msgstr[]) + * - 'MSGSTR_ARR' (msgstr_arg) + * + * @return boolean FALSE or NULL + */ + private function readLine() { + // a string or boolean FALSE + $line = fgets($this->_fd); + $this->finished = ($line === FALSE); + if (!$this->finished) { + + if ($this->lineno == 0) { + // The first line might come with a UTF-8 BOM, which should be removed. + $line = str_replace("\xEF\xBB\xBF", '', $line); + // Current plurality for 'msgstr[]'. + $this->plural = 0; + } + + $this->lineno++; + + // Trim away the linefeed. + $line = trim(strtr($line, array("\\\n" => ""))); + + if (!strncmp('#', $line, 1)) { + // Lines starting with '#' are comments. + + if ($this->context == 'COMMENT') { + // Already in comment token, insert the comment. + $this->current['#'][] = substr($line, 1); + } + elseif (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + // We are currently in string token, close it out. + $this->saveOneString(); + + // Start a new entry for the comment. + $this->current = array(); + $this->current['#'][] = substr($line, 1); + + $this->context = 'COMMENT'; + return TRUE; + } + else { + // A comment following any other token is a syntax error. + $this->log('The translation file %filename contains an error: "msgstr" was expected but not found on line %line.', $this->lineno); + return FALSE; + } + return; + } + elseif (!strncmp('msgid_plural', $line, 12)) { + // A plural form for the current message. + + if ($this->context != 'MSGID') { + // A plural form cannot be added to anything else but the id directly. + $this->log('The translation file %filename contains an error: "msgid_plural" was expected but not found on line %line.', $this->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 = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The plural form must be wrapped in quotes. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + // Append the plural form to the current entry. + if (is_string($this->current['msgid'])) { + // The first value was stored as string. Now we know the context is + // plural, it is converted to array. + $this->current['msgid'] = array($this->current['msgid']); + } + $this->current['msgid'][] = $quoted; + + $this->context = 'MSGID_PLURAL'; + return; + } + elseif (!strncmp('msgid', $line, 5)) { + // Starting a new message. + + if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + // We are currently in a message string, close it out. + $this->saveOneString(); + + // Start a new context for the id. + $this->current = array(); + } + elseif ($this->context == 'MSGID') { + // We are currently already in the context, meaning we passed an id with no data. + $this->log('The translation file %filename contains an error: "msgid" is unexpected on line %line.', $this->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 = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The message id must be wrapped in quotes. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + $this->current['msgid'] = $quoted; + $this->context = 'MSGID'; + return; + } + elseif (!strncmp('msgctxt', $line, 7)) { + // Starting a new context. + + if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + // We are currently in a message, start a new one. + $this->saveOneString($this->current); + $this->current = array(); + } + elseif (!empty($this->current['msgctxt'])) { + // A context cannot apply to another context. + $this->log('The translation file %filename contains an error: "msgctxt" is unexpected on line %line.', $this->lineno); + return FALSE; + } + + // Remove 'msgctxt' and trim away whitespaces. + $line = trim(substr($line, 7)); + // At this point, $line should now contain the context. + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The context string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + $this->current['msgctxt'] = $quoted; + + $this->context = 'MSGCTXT'; + return; + } + elseif (!strncmp('msgstr[', $line, 7)) { + // A message string for a specific plurality. + + if (($this->context != 'MSGID') && ($this->context != 'MSGCTXT') && ($this->context != 'MSGID_PLURAL') && ($this->context != 'MSGSTR_ARR')) { + // Message strings must come after msgid, msgxtxt, msgid_plural, or other msgstr[] entries. + $this->log('The translation file %filename contains an error: "msgstr[]" is unexpected on line %line.', $this->lineno); + return FALSE; + } + + // Ensure the plurality is terminated. + if (strpos($line, ']') === FALSE) { + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + // Extract the plurality. + $frombracket = strstr($line, '['); + $this->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 = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + if (!isset($this->current['msgstr']) || !is_array($this->current['msgstr'])) { + $this->current['msgstr'] = array(); + } + + $this->current['msgstr'][$this->plural] = $quoted; + + $this->context = 'MSGSTR_ARR'; + return; + } + elseif (!strncmp("msgstr", $line, 6)) { + // A string for the an id or context. + + if (($this->context != 'MSGID') && ($this->context != 'MSGCTXT')) { + // Strings are only valid within an id or context scope. + $this->log('The translation file %filename contains an error: "msgstr" is unexpected on line %line.', $this->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 = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + $this->current['msgstr'] = $quoted; + + $this->context = 'MSGSTR'; + return; + } + elseif ($line != '') { + // Anything that is not a token may be a continuation of a previous token. + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + // Append the string to the current context. + if (($this->context == 'MSGID') || ($this->context == 'MSGID_PLURAL')) { + if (is_array($this->current['msgid'])) { + // Add string to last array element. + $last_index = count($this->current['msgid']) - 1; + $this->current['msgid'][$last_index] .= $quoted; + } + else { + $this->current['msgid'] .= $quoted; + } + } + elseif ($this->context == 'MSGCTXT') { + $this->current['msgctxt'] .= $quoted; + } + elseif ($this->context == 'MSGSTR') { + $this->current['msgstr'] .= $quoted; + } + elseif ($this->context == 'MSGSTR_ARR') { + $this->current['msgstr'][$this->plural] .= $quoted; + } + else { + // No valid context to append to. + $this->log('The translation file %filename contains an error: there is an unexpected string on line %line.', $this->lineno); + return FALSE; + } + return; + } + } + + // Empty line read or EOF of PO file, closed out the last entry. + if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + $this->saveOneString($this->current); + $this->current = array(); + } + elseif ($this->context != 'COMMENT') { + $this->log('The translation file %filename ended unexpectedly at line %line.', $this->lineno); + return FALSE; + } + } + + /** + * Sets an error message if an error occurred during locale file parsing. + * + * @param $message + * The message to be translated. + * @param $lineno + * An optional line number argument. + */ + protected function log($message, $lineno = NULL) { + if (isset($lineno)) { + $vars['%line'] = $lineno; + } + $t = get_t(); + $this->errorLog[] = $t($message, $vars); + } + + /** + * Store the parsed values as translation object. + */ + public function saveOneString() { + $value = $this->current; + $plural = FALSE; + + $comments = ''; + if (isset($value['#'])) { + $comments = $this->shortenComments($value['#']); + } + + if (is_array($value['msgstr'])) { + // Sort plural variants by their form index. + ksort($value['msgstr']); + $plural = TRUE; + } + + $translation = new PoItem; + $translation->context = isset($value['msgctxt']) ? $value['msgctxt'] : ''; + $translation->source = $value['msgid']; + $translation->translation = $value['msgstr']; + $translation->plural = $plural; + $translation->comment = $comments; + $translation->langcode = $this->getLangcode(); + + $this->translation = $translation; + + $this->context = 'COMMENT'; + } + + /** + * Parses a string in quotes. + * + * @param $string + * A string specified with enclosing quotes. + * + * @return + * The string parsed from inside the quotes. + */ + function parseQuoted($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 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. + */ + private function shortenComments($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)); + } + +} diff --git a/core/lib/Drupal/Component/Gettext/PoFileWriter.php b/core/lib/Drupal/Component/Gettext/PoFileWriter.php new file mode 100644 index 0000000..182fcb8 --- /dev/null +++ b/core/lib/Drupal/Component/Gettext/PoFileWriter.php @@ -0,0 +1,112 @@ +_header; + } + + public function setHeader(PoHeader $header) { + $this->_header = $header; + } + + public function getLangcode() { + return $this->_langcode; + } + + public function setLangcode($langcode) { + $this->_langcode = $langcode; + } + + public function open() { + // Open in append mode + $this->_fd = fopen($this->getURI(), 'a'); + $this->_seekpos = ftell($this->_fd); + if ($this->_seekpos == 0) { + // If file is new position == 0 + $this->writeHeader(); + } + else { + $reader = new PoFileReader($this->uri); + $this->_header = $reader->getHeader(); + } + } + + public function close() { + fclose($this->_fd); + } + + public function setState(array $state) { + $this->_uri = $state['uri']; + $this->open(); + } + + public function getState() { + return array( + 'uri' => $this->_uri, + 'seekpos' => ftell($this->_fd), + ); + } + + private function write($data) { + $result = fputs($this->_fd, $data); + if ($result === FALSE) { + // TODO: better context for message + throw new \Exception("Unable to write data : " . substr($data, 0, 20)); + } + $this->_seekpos = ftell($this->_fd); + } + + private function writeHeader() { + $this->write($this->_header); + } + + public function writeItem(PoItem $item) { + $this->write($item); + } + + public function writeItems(PoReaderInterface $reader, $count = -1) { + $forever = $count == -1; + while (($count-- > 0 || $forever) && ($item = $reader->readItem())) { + $this->writeItem($item); + } + } + + public function getURI() { + if (empty($this->_uri)) { + throw new \Exception("Empty URI"); + } + return $this->_uri; + } + + public function setURI($uri) { + $this->_uri = $uri; + } + +} diff --git a/core/lib/Drupal/Component/Gettext/PoHeader.php b/core/lib/Drupal/Component/Gettext/PoHeader.php new file mode 100644 index 0000000..724f108 --- /dev/null +++ b/core/lib/Drupal/Component/Gettext/PoHeader.php @@ -0,0 +1,405 @@ +1);\n" + + * @author clemens + */ +class PoHeader { + + private $_langcode; + private $_projectIdVersion; + private $_potCreationDate; + private $_poRevisionDate; + private $_languageTeam; + private $_mimeVersion; + private $_contentType; + private $_contentTransferEncoding; + private $_pluralForms; + private $_authors; + private $_po_date; + private $_languageName; + private $_projectName; + + /** + * Creates a PoHeader with default values set. + * + * @param type $langcode + */ + public function __construct($langcode = NULL) { + $this->_langcode = $langcode; + $this->setDefaults(); + } + + static public function mapping() { + return array( + 'Project-Id-Version' => '_projectIdVersion', + // * Report-Msgid-Bugs-To + 'POT-Creation-Date' => '_potCreationDate', + 'PO-Revision-Date' => '_poRevisionDate', + // * Last-Translator + 'Language-Team' => '_languageTeam', + 'MIME-Version' => '_mimeVersion', + // * Language + 'Content-Type' => '_contentType', + 'Content-Transfer-Encoding' => '_contentTransferEncoding', + 'Plural-Forms' => '_pluralForms', + ); + } + + function getPlural() { + return $this->_pluralForms; + } + + function setLanguageName($languageName) { + $this->_languageName = $languageName; + } + + function getLanguageName() { + return $this->_languageName; + } + + function setProjectName($projectName) { + $this->_projectName = $projectName; + } + + function getProjectName() { + return $this->_projectName; + } + + /** + * Compile the PO header. + */ + private function compileHeader() { + $output = ''; + + $isTemplate = $this->_languageName == ''; + + $output .= '# ' . ($isTemplate ? 'LANGUAGE' : $this->_languageName) . ' translation of ' . ($isTemplate ? 'PROJECT' : $this->_projectName) . "\n"; + if (!empty($this->_authors)) { + $output .= '# Generated by ' . implode("\n# ", $this->_authors) . "\n"; + } + $output .= "#\n"; + + // Add the actual header information. + $output .= "msgid \"\"\n"; + $output .= "msgstr \"\"\n"; + $output .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n"; + $output .= "\"POT-Creation-Date: " . $this->_po_date . "\\n\"\n"; + $output .= "\"PO-Revision-Date: " . $this->_po_date . "\\n\"\n"; + $output .= "\"Last-Translator: NAME \\n\"\n"; + $output .= "\"Language-Team: LANGUAGE \\n\"\n"; + $output .= "\"MIME-Version: 1.0\\n\"\n"; + $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; + $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; + $output .= "\"Plural-Forms: " . $this->_pluralForms . "\\n\"\n"; + $output .= "\n"; + + return $output; + } + + /** + * Stores a given PO Header string + * + * TODO: the header string is cleaned by the parser :( + * we need to accept unclean version too + * + * @param type $header + */ + public function setFromString($header) { + $values = $this->parseHeader($header); + + $this->setDefaults($values); + } + + /** + * TODO: compare with Symfony::setDefaults() + * + * @param type $values + */ + public function setDefaults($values = array()) { + $defaults = array( + 'POT-Creation-Date' => date("Y-m-d H:iO"), + 'Plural-Forms' => 'nplurals=2; plural=(n > 1);', + ); + foreach ($defaults as $key => $value) { + if (empty($values[$key])) { + $values[$key] = $value; + } + } + $mapping = self::mapping(); + foreach ($mapping as $key => $var) { + if (isset($values[$key])) { + $this->{$var} = $values[$key]; + } + } + } + + public function __toString() { + $result = $this->compileHeader() . "\n"; + return $result; + } + + /** + * 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 parsePluralForms($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 = $this->parseArithmetic($plural); + + if ($plural !== FALSE) { + return array($nplurals, $plural); + } + else { + throw new Exception("The plural formula could not be parsed."); + } + } + + /** + * Parses a Gettext Portable Object file header. + * + * @param $header + * A string containing the complete header. + * + * @return + * An associative array of key-value pairs. + */ + private function parseHeader($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 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. + */ + private function parseArithmetic($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 = $this->tokenizeFormula($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; + } + } + + /** + * Provides backward-compatible formula parsing for token_get_all(). + * + * @param $string + * A string containing the arithmetic formula. + * + * @return + * The PHP version of the formula. + */ + private function tokenizeFormula($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; + } + +} diff --git a/core/lib/Drupal/Component/Gettext/PoInterface.php b/core/lib/Drupal/Component/Gettext/PoInterface.php new file mode 100644 index 0000000..8c01ee7 --- /dev/null +++ b/core/lib/Drupal/Component/Gettext/PoInterface.php @@ -0,0 +1,31 @@ + 'home', + * 'translation' => 'thuis', + * 'plural' => 0, + * 'context' => '', + * 'comment' => '', + * ), + * In case we want using just arrays we can make these methods static. + * + * @author clemens + * @see \Drupal\Component\Gettext\Gettext + */ +class PoItem { + + /** + * The context this translation belongs to. + * + * The default context should be an empty string + * @see PoMemoryWriter.writeItem() + * @var string + */ + public $context = ''; + public $source; + public $plural; + public $comment; + public $translation; + + static public function mapping() { + return array( + 'msgctxt' => 'context', + 'msgid' => 'source', + 'msgstr' => 'translation', + '#' => 'comment', + ); + } + + public function fromArray(array $values = array()) { + foreach ($values as $key => $value) { + $this->{$key} = $value; + } + if (isset($this->source) && strpos($this->source, LOCALE_PLURAL_DELIMITER) !== FALSE) { + $this->source = explode(LOCALE_PLURAL_DELIMITER, $this->source); + $this->translation = explode(LOCALE_PLURAL_DELIMITER, $this->translation); + $this->plural = count($this->translation); + } + } + + public function __toString() { + return $this->compileTranslation(); + } + + /** + * Compile PO translations strings from a translation object. + * + * Translation object consists of: + * source string (singular) or array of strings (plural) + * translation string (singular) or array of strings (plural) + * plural TRUE: source and translation are plurals + * context source context string + */ + private function compileTranslation() { + $output = ''; + + // Format string context. + if (!empty($this->context)) { + $output .= 'msgctxt ' . $this->formatString($this->context); + } + + // Format translation + if ($this->plural) { + $output .= $this->formatPlural(); + } + else { + $output .= $this->formatSingular(); + } + + // Add one empty line to separate the translations. + $output .= "\n"; + + return $output; + } + + /** + * Formats a plural translation. + */ + private function formatPlural() { + $output = ''; + + // Format source strings. + $output .= 'msgid ' . $this->formatString($this->source[0]); + $output .= 'msgid_plural ' . $this->formatString($this->source[1]); + + foreach ($this->translation as $i => $trans) { + if (isset($this->translation[$i])) { + $output .= 'msgstr[' . $i . '] ' . $this->formatString($trans); + } + else { + $output .= 'msgstr[' . $i . '] ""' . "\n"; + } + } + + return $output; + } + + /** + * Formats a singular translation. + */ + private function formatSingular() { + $output = ''; + $output .= 'msgid ' . $this->formatString($this->source); + $output .= 'msgstr ' . (isset($this->translation) ? $this->formatString($this->translation) : ''); + return $output; + } + + /** + * Formats a string for output on multiple lines. + */ + private function formatString($string) { + // Escape characters for processing. + $string = addcslashes($string, "\0..\37\\\""); + + // Always include a line break after the explicit \n line breaks from + // the source string. Otherwise wrap at 70 chars to accommodate the extra + // format overhead too. + $parts = explode("\n", wordwrap(str_replace('\n', "\\n\n", $string), 70, " \n")); + + // Multiline string should be exported starting with a "" and newline to + // have all lines aligned on the same column. + if (count($parts) > 1) { + return "\"\"\n\"" . implode("\"\n\"", $parts) . "\"\n"; + } + // Single line strings are output on the same line. + else { + return "\"$parts[0]\"\n"; + } + } + +} diff --git a/core/lib/Drupal/Component/Gettext/PoMemoryWriter.php b/core/lib/Drupal/Component/Gettext/PoMemoryWriter.php new file mode 100644 index 0000000..c64524e --- /dev/null +++ b/core/lib/Drupal/Component/Gettext/PoMemoryWriter.php @@ -0,0 +1,80 @@ +_items = array(); + } + + public function setState(array $state) { + // nothing to do? + } + + public function getState() { + return array(); + } + + /** + * Stores values into memory. + * + * The structure is context dependent. + * TODO: where is this structure documented? + * - array[context][source] = translation + * + * @param PoItem $item + */ + public function writeItem(PoItem $item) { + if (is_array($item->source)) { + $item->source = implode(LOCALE_PLURAL_DELIMITER, $item->source); + $item->translation = implode(LOCALE_PLURAL_DELIMITER, $item->translation); + } + $this->_items[isset($item->context) ? $item->context : ''][$item->source] = $item->translation; + } + + public function writeItems(PoReaderInterface $reader, $count = -1) { + $forever = $count == -1; + while (($count-- > 0 || $forever) && ($item = $reader->readItem())) { + $this->writeItem($item); + } + } + + public function getHeader() { + // TODO: what + } + + public function getLangcode() { + // TODO: what + } + + public function setHeader(PoHeader $header) { + // TODO: what + } + + public function setLangcode($langcode) { + // TODO: what + } + + public function getData() { + return $this->_items; + } +} diff --git a/core/lib/Drupal/Component/Gettext/PoReaderInterface.php b/core/lib/Drupal/Component/Gettext/PoReaderInterface.php new file mode 100644 index 0000000..9977767 --- /dev/null +++ b/core/lib/Drupal/Component/Gettext/PoReaderInterface.php @@ -0,0 +1,22 @@ + 'home', + 'translation' => 'thuis', + 'plural' => 0, + 'context' => '', + ), + array( + 'source' => 'delete', + 'translation' => 'verwijderen', + 'plural' => 0, + 'context' => '', + ), + array( + 'source' => array('1 day', '@count days'), + 'translation' => array('1 dag', '@count dagen'), + 'plural' => 1, + 'context' => '', + ), + ); + $items = array(); + foreach ($src as $values) { + $item = new PoItem(); + $item->fromArray($values); + $items[] = $item; + } + $result = array( + 'langcode' => $langcode, + 'items' => $items, + ); + + return $result; +} + +function testWriter($uri = '') { + if (empty($uri)) { + $uri = getPublicUri(__FUNCTION__, $langcode); + } + zapUri($uri); + logLine(__FUNCTION__); + $po = gettext_struct(); + $langcode = $po['langcode']; + $items = $po['items']; + + $writer = new PoFileWriter(); + + $writer->setLangcode($langcode); + $writer->setHeader(new PoHeader($langcode)); + $writer->setURI($uri); + $writer->open(); + foreach ($items as $item) { + $writer->writeItem($item); + } + $writer->close(); + return $uri; +} + +function testBatchState() { + logLine(__FUNCTION__, '='); + $langcode = 'nl'; + $uri = remoteToPublic($langcode); + + $reader = new PoFileReader(); + $reader->setLangcode($langcode); + $reader->setURI($uri); + + echo "Not yet opened $uri\n"; + dumpState($reader->getState()); + + echo "Opening stream $uri\n"; + $reader->open(); + dumpState($reader->getState()); + + echo "Reading 1 items\n"; + $reader->readItem(); + dumpState($reader->getState()); + + echo "Saving state\n"; + $state = $reader->getState(); + + $item = $reader->readItem(); + echo "Item read after state $item"; + + echo "Dropping \$reader\n"; + $reader = NULL; + + $reader = new PoFileReader(); + $reader->setLangcode($langcode); + $reader->setURI($uri); + + // This is an implicit open! + echo "Setting state simulating Batch\n"; + $reader->setState($state); + dumpState($reader->getState()); + $sameItem = $reader->readItem(); + echo "These should be the same\n\n$item\n$sameItem\n"; +} + +function getReadStream($uri) { + logLine(__FUNCTION__, '='); + $s = new GettextFileInterface($uri); + return $s; +} + +function testPoReader() { + logLine(__FUNCTION__, '='); + + $uri = 'public://test.po.txt'; + + logLine("Reading : $uri", '='); + logLine("File contents first 500 bytes"); + $contents = file_get_contents($uri); + echo substr($contents, 0, 500) . "\n"; + + logLine("Using PoFileReader"); + $reader = new PoFileReader($uri); + echo $reader->getHeader(); + + $i = 0; + while (($item = $reader->readItem()) && $i++ < 4) { + printItem($item, $i); + } +} + +function testHeader() { + logLine(__FUNCTION__, '='); + $h = new PoHeader(); + + echo "----------------\n"; + $h->setFromString(''); + echo "empty header\n"; + echo $h; + + echo "----------------\n"; + $h->setFromString('"Project-Id-Version: ' . __FILE__ . '\n"'); + echo "-- one item -- \n"; + echo $h; +} + +function testFileToDb() { + logLine(__FUNCTION__, '='); + $uri = getPublicUri(__FUNCTION__, 'nl'); + testWriter($uri); + logLine("Reading : $uri"); + logLine("File contents first 500 bytes"); + $contents = file_get_contents($uri); + echo substr($contents, 0, 500) . "\n"; + + logLine("POFileReader"); + $reader = new PoFileReader(); + $reader->setURI($uri); + $reader->open(); + $header = $reader->getHeader(); + logLine("Header read"); + logLine($header); + + $langcode = 'ca'; + logLine("PoDatabaseWriter"); + $writer = new PoDatabaseWriter(); + $writer->setLangcode($langcode); + $writer->setHeader($header); + + $i = 0; + while (($item = $reader->readItem()) && $i < 4) { + logLine($item); + $writer->writeItem($item); + $i++; + } + logLine(__FUNCTION__ . " #$i items written."); +} + +function testDbDump() { + logLine(__FUNCTION__, '='); + $reader = new PoDatabaseReader('en'); + echo $reader->getHeader() . "\n"; + + $i = 0; + while (($item = $reader->readItem()) && $i < 4) { + printItem($item, $i++); + } + + echo "Saving state to simulate a batch\n"; + $state = $reader->getState(); + + echo "Create a new PoDatabaseReader so simulate a batch\n"; + $reader = new PoDatabaseReader('en'); + + // Set the state + $reader->setState($state); + $i = 0; + while (($item = $reader->readItem()) && $i < 4) { + printItem($item, $i++); + } +} + +function printItem($item, $context = 0) { + logLine(__FUNCTION__, '='); + if ($item) { + logLine("$context : $item->lid"); + print_r($item); + } +} + +function readItem($reader, $context = 0) { + $item = $reader->readItem(); + if ($item) { + printItem($item); + } +} + +function dumpState($state) { + logLine(__FUNCTION__, '='); + print_r($state); +} + +function testPOFileReader() { + logLine(__FUNCTION__, '='); + $uri = 'public://nl-nl.po'; + + $reader = new PoFileReader($uri); + dumpState($reader->getState()); + echo $reader->getHeader() . "\n"; + dumpState($reader->getState()); + $i = 0; + while (($item = readItem($reader)) && $i < 4) { + printItem($item, $i++); + } + dumpState($reader->getState()); +} + +function getLanguages($langcode = NULL) { + logLine(__FUNCTION__, '='); + if (!is_null($langcode)) { + return array($langcode); + } + return array( + 'nl', + 'ar', + 'ca', + 'en', // does not exists on d.o (should it be?) + 'NOP', // does really not exists on d.o + ); +} + +function getRemoteUris($langcode = NULL) { + logLine(__FUNCTION__, '='); + $langcodes = getLanguages($langcode); + $uris = array(); + foreach ($langcodes as $langcode) { + $uri = "http://ftp.drupal.org/files/translations/7.x/drupal/drupal-7.11.$langcode.po"; + $uris[$langcode] = $uri; + } + return $uris; +} + +function getPublicUri($name, $langcode) { + $result = "public://$name-$langcode.po"; + logLine($result); + return $result; +} + +function getRemoteUri($langcode) { + $uris = getRemoteUris($langcode); + return $uris[$langcode]; +} + +function testRemotePOPumper() { + logLine(__FUNCTION__, '='); + $uris = getRemoteUris(); + foreach ($uris as $langcode => $uri) { + logLine("langcode: $langcode"); + $reader = new PoFileReader($uri); + $writer = new PoDatabaseWriter($langcode); + $writer->writeItems($reader, 10); + } +} + +function testPOFileWriter() { + logLine(__FUNCTION__, '='); + $src = "http://ftp.drupal.org/files/translations/7.x/drupal/drupal-7.11.ar.po"; + + $reader = new PoFileReader(); + $reader->setURI($src); + $reader->open(); + $header = $reader->getHeader(); + + $dst = 'public://drupal-7.11.ar.po'; + zapUri($dst); + $writer = new PoFileWriter(); + $writer->setURI($dst); + $writer->setHeader($header); + $writer->open(); + + $i = 0; + while (($item = $reader->readItem()) && $i < 4) { + printItem($item, $i); + $i++; + $writer->writeItem($item); + dumpState($writer->getState()); + } + + $writer->close(); +} + +function testDbToFile() { + logLine(__FUNCTION__, '='); + $langcode = 'ca'; + $reader = new PoDatabaseReader(); + $reader->setLangcode($langcode); + + $dst = 'public://drupal-7.11.dummy.po'; + $header = $reader->getHeader(); + + $writer = new PoFileWriter(); + $writer->setURI($dst); + $writer->setHeader($header); + $writer->open(); + + $i = 0; + while (($item = $reader->readItem()) && $i < 4) { + printItem($item, $i); + $i++; + $writer->writeItem($item); + dumpState($writer->getState()); + } + + $writer->writeItems($reader, 10); + + $writer->close(); +} + +function testBatchSimulation() { + logLine(__FUNCTION__, '='); + + // Grab first langcode + $uris = getRemoteUris(); + $langcode = key($uris); + $src = current($uris); + + logLine("Opening $langcode : $src"); + $reader = new PoFileReader(); + $reader->setURI($src); + $reader->open(); + + $header = $reader->getHeader(); + logLine($header); + + $dst = getPublicUri(__FUNCTION__, $langcode); + + zapUri($dst); + logLine("Writing $langcode : $dst"); + $writer = new PoFileWriter(); + $writer->setURI($dst); + $writer->setHeader($header); + $writer->open(); + + logLine('Written header only', '='); + echo file_get_contents($dst); + + processN($writer, $reader, 2); + + dumpFileContents($dst); + + $state = $reader->getState(); + dumpState($state); + + logLine('Replacing reader', '='); + $reader = new PoFileReader($src); + + logLine('setting state back'); + $reader->setState($state); + dumpState($state); + + processN($writer, $reader, 3); + $reader->setState($state); + dumpState($state); + + dumpFileContents($dst); +} + +function testDBReaderState() { + logLine(__FUNCTION__); + $langcode = 'nl'; + $reader = new PoDatabaseReader(); + $reader->setLangcode($langcode); + + logLine("Init PoDatabaseReader", '='); + $state = $reader->getState(); + dumpState($state); + + $header = $reader->getHeader(); + + $uri = getPublicUri(__FUNCTION__, $langcode); + zapUri($uri); + $writer = new PoFileWriter($uri, $header); + $writer->setHeader($header); + $writer->setURI($uri); + $writer->open(); + + processN($writer, $reader, 4); + + logLine("Read some", '='); + $state = $reader->getState(); + dumpState($state); + + $reader = new PoDatabaseReader($langcode); + $reader->setState($state); + processN($writer, $reader, 4); + $state = $reader->getState(); + dumpState($state); + + logLine("File contents from $uri", '='); + echo file_get_contents($uri); +} + +function zapUri($uri) { + logLine("Truncate $uri", '='); + ftruncate(fopen($uri, 'w')); +} + +function processN($writer, $reader, $count = 10) { + if ($count == -1) { + logLine("processing items: __ALL__"); + } + else { + logLine("processing items: $count"); + } + $writer->writeItems($reader, $count); +} + +function dumpFileContents($uri) { + logLine("Written: $uri", '='); + echo file_get_contents($uri); +} + +function newPOFileReader($uri, $langcode = NULL) { + logLine("Reading from $uri using langcode: '$langcode'"); + $reader = new PoFileReader(); + $reader->setURI($uri); + $reader->setLangcode($langcode); + $reader->open(); + return $reader; +} + +function remoteToPublic($langcode) { + logLine(__FUNCTION__, '='); + $uri = getRemoteUri($langcode); + + logLine("Reading from $uri"); + $reader = newPoFileReader($uri, $langcode); + $header = $reader->getHeader(); + + $uri = getPublicUri($langcode, $langcode); + zapUri($uri); + logLine("Writing to $uri"); + $writer = new PoFileWriter(); + $writer->setURI($uri); + $writer->setHeader($header); + $writer->open(); + + processN($writer, $reader, -1); + + return $uri; +} + +function publicToDb($langcode) { + $uri = getPublicUri($langcode, $langcode); + + logLine("Reading from $uri using langcode: '$langcode'"); + $reader = new PoFileReader(); + $reader->setURI($uri); + $reader->setLangcode($langcode); + $reader->open(); + + logLine("Writing to DB"); + $writer = new PoDatabaseWriter(); + $writer->setLangcode($langcode); + $writer->setHeader($reader->getHeader()); + + $locale_plurals = variable_get('locale_translation_plurals', array()); + print_r(array("Should have $langcode" => $locale_plurals)); + + $options = $writer->getOptions(); + print_r($writer->getOptions()); + //$options['overwrite_options']['not_customized'] = TRUE; + $writer->setOptions($options); + print_r($writer->getOptions()); + print_r($options); + processN($writer, $reader, -1); + + print_r($writer->getReport()); +} + +function publicToMemory($langcode) { + $uri = getPublicUri($langcode, $langcode); + + logLine("Reading from $uri using langcode: '$langcode'"); + $reader = new PoFileReader(); + $reader->setURI($uri); + $reader->open(); + + logLine("Writing to Memory"); + $writer = new PoMemoryWriter(); + $writer->setLangcode($langcode); + $writer->setHeader($reader->getHeader()); + + $locale_plurals = variable_get('locale_translation_plurals', array()); + print_r(array("Should have $langcode" => $locale_plurals)); + + processN($writer, $reader, 5); + + var_dump($writer); +} + +function pumpAround($langcode) { + $uri = remoteToPublic($langcode); + publicToMemory($langcode); + publicToDb($langcode); + + $reader = newPoFileReader($uri, $langcode); + + logLine("Writing to DB"); + $writer = new PoDatabaseWriter(); + $writer->setLangcode($langcode); + + processN($writer, $reader); + + logLine("Reading from DB"); + $reader = new PoDatabaseReader(); + $reader->setLangcode($langcode); + $reader->setOptions(array()); + + var_dump($reader->getOptions()); + + $header = $reader->getHeader(); + + $uri = getPublicUri(__FUNCTION__ . '-db', $langcode); + zapUri($uri); + logLine("Writing to $uri"); + $writer = new PoFileWriter(); + $writer->setURI($uri); + $writer->setHeader($header); + $writer->open(); + processN($writer, $reader, -1); +} + +function runAll() { + // Make sure all test languages are around + testLanguages(); + testWriter(); + testDBReaderState(); + testBatchSimulation(); + testBatchState(); + testPoReader(); + testHeader(); + testFileToDb(); + testDbDump(); + testPOFileReader(); + testPOFileWriter(); + testDbToFile(); + testRemotePOPumper(); + + pumpAll(); + + testFileToDb(); + + testT(); + testFormatPlural(); +} + +function pumpAll() { + $uris = getRemoteUris(); + foreach ($uris as $langcode => $uri) { + pumpAround($langcode); + } +} + +function testT() { + logLine(__FUNCTION__, '='); + $uris = getRemoteUris(); + foreach ($uris as $langcode => $uri) { + // TODO create a DBReader with filter like plural / context / etc + $sentences = array( + 'Home', + 'May', + 'Website', + 'Drupal', + 'One', + 'Two', + ); + + foreach ($sentences as $sentence) { + $result = t($sentence, array(), array('langcode' => $langcode)); + echo "$langcode: $sentence => $result\n"; + } + } +} + +function testFormatPlural() { + $uris = getRemoteUris(); + foreach ($uris as $langcode => $uri) { + $plurals = array(); + $plurals[] = array('1 day', '@count days'); + $plurals[] = array('1 pending update', '@count pending updates'); + foreach ($plurals as $plural) { + for ($i = 0; $i < 200; $i++) { + $result = format_plural($i, $plural[0], $plural[1], array('@count' => $i), array('langcode' => $langcode)); + echo "$langcode: $plural[0] | $plural[1] => $result\n"; + if ($i > 0) { + $i = $i * 3; + } + } + } + } +} + +/** + * Make sure languages are added to the Drupal install. + */ +function testLanguages() { + $uris = (getRemoteUris()); + unset($uris['NOP']); + $needed = array_keys($uris); + $list = language_list(); + $missing = array_diff($needed, array_keys($list)); + foreach ($missing as $langcode) { + $language = (object) array( + 'langcode' => $langcode, + 'default' => FALSE, + ); + language_save($language); + } + if (count($missing)) { + echo "The following languages were added to your Drupal install: " . implode(", ", $missing) . "\n"; + } +} + +//testT(); +//testFormatPlural(); +//$langs = language_list(); +//var_dump($langs); +//pumpAround('nl'); +runAll(); +//pumpAll(); + +//testBatchState(); diff --git a/core/modules/locale/lib/Drupal/locale/Gettext.php b/core/modules/locale/lib/Drupal/locale/Gettext.php new file mode 100644 index 0000000..76d9845 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/Gettext.php @@ -0,0 +1,86 @@ +setLangcode($langcode); + foreach ($files as $file) { + $reader = new PoFileReader(); + $reader->setURI($file->uri); + $reader->setLangcode($langcode); + $reader->open(); + $writer->writeItems($reader, -1); + } + return $writer->getData(); + } + + /** + * Reads the given PO files into a data structure. + * + * @param type $file + * @param type $langcode + * @param array $files + * @return array + */ + static function fileToDatabase($file, $langcode, $overwrite_options, $customized = LOCALE_NOT_CUSTOMIZED) { + $reader = new PoFileReader(); + $reader->setLangcode($langcode); + $reader->setURI($file->uri); + + try { + // When opening header is parsed immediately + $reader->open(); + } + catch (Exception $exc) { + throw new $exc; + } + + $header = $reader->getHeader(); + if (!$header) { + throw new Exception('missing or malformed header.'); + } + + $writer = new PoDatabaseWriter(); + $writer->setLangcode($langcode); + $options = array( + 'overwrite_options' => $overwrite_options, + 'customized' => $customized, + ); + // It's vital options are set first. + // @TODO: this has to be fixed in https://drupal.org/node/1637334 + $writer->setOptions($options); + $writer->setHeader($header); + + try { + $writer->writeItems($reader, -1); + } + catch (Exception $exc) { + throw new $exc; + } + + return $writer->getReport(); + } +} diff --git a/core/modules/locale/lib/Drupal/locale/PoDatabaseReader.php b/core/modules/locale/lib/Drupal/locale/PoDatabaseReader.php new file mode 100644 index 0000000..e2cfbe2 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/PoDatabaseReader.php @@ -0,0 +1,185 @@ +setOptions(array()); + } + + public function getLangcode() { + return $this->_langcode; + } + + public function setLangcode($langcode) { + $this->_langcode = $langcode; + } + + function getOptions() { + return $this->_options; + } + + function setOptions(array $options) { + if (!isset($options['override_options'])) { + $options['override_options'] = array(); + } + if (!isset($options['customized'])) { + $options['customized'] = LOCALE_NOT_CUSTOMIZED; + } + $this->_options = array( + 'override_options' => $options['override_options'], + ); + $this->_options += $options; + } + + function setState(array $state) { + $this->_lid = $state['lid']; + $this->setOptions($state['options']); + $this->buildQuery(); + } + + function getState() { + return array( + '__CLASS__' => __CLASS__, + 'lid' => $this->_lid, + 'options' => $this->_options, + ); + } + + function getHeader() { + return new PoHeader($this->getLangcode()); + } + + public function setHeader(PoHeader $header) { + // empty on purpose + } + + /** + * Generates a structured array of all translated strings for the language. + * + * @param $language + * Language object to generate the output for, or NULL if generating + * translation template. + * @param $options + * (optional) An associative array specifying what to include in the output: + * - customized: include customized strings (if TRUE) + * - uncustomized: include non-customized string (if TRUE) + * - untranslated: include untranslated source strings (if TRUE) + * Ignored if $language is NULL. + * + * @return + * An array of translated strings that can be used to generate an export. + */ + private function buildQuery() { + $langcode = $this->_langcode; + $options = $this->_options; + + // Assume FALSE for all options if not provided by the API. + $options += array( + 'customized' => FALSE, + 'not_customized' => FALSE, + 'not_translated' => FALSE, + ); + if (array_sum($options) == 0) { + // If user asked to not include anything in the translation files, + // that would not make sense, so just fall back on providing a template. + $langcode = NULL; + } + + // Build and execute query to collect source strings and translations. + $query = db_select('locales_source', 's'); + if (!empty($langcode)) { + if ($options['not_translated']) { + // Left join to keep untranslated strings in. + $query->leftJoin('locales_target', 't', 's.lid = t.lid AND t.language = :language', array(':language' => $langcode)); + } + else { + // Inner join to filter for only translations. + $query->innerJoin('locales_target', 't', 's.lid = t.lid AND t.language = :language', array(':language' => $langcode)); + } + if ($options['customized']) { + if (!$options['not_customized']) { + // Filter for customized strings only. + $query->condition('t.customized', LOCALE_CUSTOMIZED); + } + // Else no filtering needed in this case. + } + else { + if ($options['not_customized']) { + // Filter for non-customized strings only. + $query->condition('t.customized', LOCALE_NOT_CUSTOMIZED); + } + else { + // Filter for strings without translation. + $query->isNull('t.translation'); + } + } + $query->fields('t', array('translation')); + } + else { + $query->leftJoin('locales_target', 't', 's.lid = t.lid'); + } + $query->fields('s', array('lid', 'source', 'context', 'location')); + + // TODO: we need to order by lid + // This does not seem to work + $query->orderBy('s.lid'); + $query->condition('s.lid', $this->_lid, '>'); + + $this->_result = $query->execute(); + //echo "Executing: (lid = $this->_lid) : \n" . $this->_result->getQueryString() . "\n"; + } + + private function getResult() { + if (!isset($this->_result)) { + $this->buildQuery(); + } + return $this->_result; + } + + function readItem() { + $result = $this->getResult(); + $values = $result->fetchAssoc(); + if ($values) { + $poItem = new PoItem(); + $poItem->fromArray($values); + // Manage state + $this->_lid = $values['lid']; + return $poItem; + } + } + +} diff --git a/core/modules/locale/lib/Drupal/locale/PoDatabaseWriter.php b/core/modules/locale/lib/Drupal/locale/PoDatabaseWriter.php new file mode 100644 index 0000000..6386e46 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/PoDatabaseWriter.php @@ -0,0 +1,302 @@ + NULL, + 'report' => array( + 'additions' => 0, + 'updates' => 0, + 'deletes' => 0, + 'skips' => 0, + 'ignored' => 0, + ), + 'options' => array( + 'overwrite_options' => array( + 'not_customized' => FALSE, + 'customized' => FALSE, + ), + 'customized' => LOCALE_NOT_CUSTOMIZED, + ), + ); + } + + /** + * Report array summarizing the number of changes done in the form: + * array(additions, updates, deletes, skips, ignored). + * + * @var array + */ + private $_report; + + /** + * @see BatchStateInterface + */ + function __construct() { + $this->setState(array()); + } + + public function getLangcode() { + return $this->_langcode; + } + + public function setLangcode($langcode) { + $this->_langcode = $langcode; + } + + public function getReport() { + return $this->_report; + } + + function setReport($report) { + $report += array( + 'additions' => 0, + 'updates' => 0, + 'deletes' => 0, + 'skips' => 0, + 'ignored' => 0, + ); + $this->_report = $report; + } + + function getOptions() { + return $this->_options; + } + + function setOptions(array $options) { + $options['overwrite_options'] += array( + 'not_customized' => FALSE, + 'customized' => FALSE, + ); + $options += array( + 'customized' => LOCALE_NOT_CUSTOMIZED, + ); + $this->_options = $options; + } + + /** + * Implementation of BatchInterface::setState + * + * @param array $state + */ + public function setState(array $state) { + $state += self::getDefaultState(); + $this->_report = $state['report']; + $this->setLangcode($state['langcode']); + $this->setOptions($state['options']); + } + + public function getState() { + return array( + 'class' => __CLASS__, + 'report' => $this->getReport(), + 'langcode' => $this->getLangcode(), + 'options' => $this->getOptions(), + ); + } + + function getHeader() { + return $this->_header; + } + + /** + * Sets the header and configure drupal accordingly. + * + * Before being able to process the given header we need to know in what + * context this database write is done. For this the options must be set. + * + * A langcode is required to set the current headers PluralForm. + * + * @param PoHeader $header + * @throws Exception + */ + function setHeader(PoHeader $header) { + $this->_header = $header; + $locale_plurals = variable_get('locale_translation_plurals', array()); + // Check for options + $options = $this->getOptions(); + if (empty($options)) { + throw new Exception("Options should be set before assigning a PoHeader"); + } + $overwrite_options = $options['overwrite_options']; + // Check for langcode + $lang = $this->_langcode; + if (empty($lang)) { + throw new Exception("Langcode should be set before assigning a PoHeader"); + } + if (array_sum($overwrite_options) || empty($locale_plurals[$lang]['plurals'])) { + // Get and store the plural formula if available. + $plural = $header->getPlural(); + // TODO: this is a sloppy way to create a file name + // but parsePluralForms is also weird to me still + $filepath = __CLASS__ . "::" . __METHOD__; + if (isset($plural) && $p = $header->parsePluralForms($plural, $filepath)) { + list($nplurals, $formula) = $p; + $locale_plurals[$lang] = array( + 'plurals' => $nplurals, + 'formula' => $formula, + ); + variable_set('locale_translation_plurals', $locale_plurals); + } + } + } + + /** + * Write an item to the database. + * + * @param PoItem $item + */ + function writeItem(PoItem $item) { + if ($item->plural) { + $item->source = join(LOCALE_PLURAL_DELIMITER, $item->source); + $item->translation = join(LOCALE_PLURAL_DELIMITER, $item->translation); + } + $this->importString($item, 'location'); + } + + /** + * Write next items from a reader to the database. + * If no number of items is specified, all of theme are being written. + * + * @param PoReaderInterface $reader + * @param $count + * The number of items that will be wrote. + */ + public function writeItems(PoReaderInterface $reader, $count = -1) { + $forever = $count == -1; + while (($count-- > 0 || $forever) && ($item = $reader->readItem())) { + $this->writeItem($item); + } + } + + /** + * Imports one string into the database. + * + * @param PoItem $item + * The item being imported. + * @param $location + * Location value to save with source string. + * + * @return + * The string ID of the existing string modified or the new string added. + */ + private function importString($item, $location) { + // Initialize overwrite options if not set. + $this->_options['overwrite_options'] += array( + 'not_customized' => FALSE, + 'customized' => FALSE, + ); + $overwrite_options = $this->_options['overwrite_options']; + $customized = $this->_options['customized']; + + // Look up the source string and any existing translation. + $string = db_query("SELECT s.lid, t.customized FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.source = :source AND s.context = :context", array( + ':source' => $item->source, + ':context' => $item->context, + ':language' => $this->_langcode, + )) + ->fetchObject(); + + if (!empty($item->translation)) { + // Skip this string unless it passes a check for dangerous code. + if (!locale_string_is_safe($item->translation)) { + watchdog('locale', 'Import of string "%string" was skipped because of disallowed or malformed HTML.', array('%string' => $item->translation), WATCHDOG_ERROR); + $this->_report['skips']++; + return 0; + } + elseif (isset($string->lid)) { + // We have this source string saved already. + db_update('locales_source') + ->fields(array( + 'location' => $location, + )) + ->condition('lid', $string->lid) + ->execute(); + + if (!isset($string->customized)) { + // No translation in this language. + db_insert('locales_target') + ->fields(array( + 'lid' => $string->lid, + 'language' => $this->_langcode, + 'translation' => $item->translation, + 'customized' => $customized, + )) + ->execute(); + + $this->_report['additions']++; + } + elseif ($overwrite_options[$string->customized ? 'customized' : 'not_customized']) { + // Translation exists, only overwrite if instructed. + db_update('locales_target') + ->fields(array( + 'translation' => $item->translation, + 'customized' => $customized, + )) + ->condition('language', $this->_langcode) + ->condition('lid', $string->lid) + ->execute(); + + $this->_report['updates']++; + } + return $string->lid; + } + else { + // No such source string in the database yet. + $lid = db_insert('locales_source') + ->fields(array( + 'location' => $location, + 'source' => $item->source, + 'context' => (string) $item->context, + )) + ->execute(); + + db_insert('locales_target') + ->fields(array( + 'lid' => $lid, + 'language' => $this->_langcode, + 'translation' => $item->translation, + 'customized' => $customized, + )) + ->execute(); + + $this->_report['additions']++; + return $lid; + } + } + elseif (isset($string->lid) && isset($string->customized) && $overwrite_options[$string->customized ? 'customized' : 'not_customized']) { + // Empty translation, remove existing if instructed. + db_delete('locales_target') + ->condition('language', $this->_langcode) + ->condition('lid', $string->lid) + ->execute(); + + $this->_report['deletes']++; + return $string->lid; + } + } + +} diff --git a/core/modules/locale/lib/Drupal/locale/Tests/LocaleImportFunctionalTest.php b/core/modules/locale/lib/Drupal/locale/Tests/LocaleImportFunctionalTest.php index 6fe77c2..e55f356 100644 --- a/core/modules/locale/lib/Drupal/locale/Tests/LocaleImportFunctionalTest.php +++ b/core/modules/locale/lib/Drupal/locale/Tests/LocaleImportFunctionalTest.php @@ -196,7 +196,8 @@ class LocaleImportFunctionalTest extends WebTestBase { // Ensure the translation file was automatically imported when language was // added. - $this->assertText(t('One translation file imported.'), t('Language file automatically imported.')); + $this->assertText(t('3 translation files imported.'), t('Language file automatically imported.')); + $this->assertText(t('A translation string was skipped because of disallowed or malformed HTML'), t('Language file automatically imported.')); // Ensure strings were successfully imported. $search = array( @@ -206,6 +207,25 @@ class LocaleImportFunctionalTest extends WebTestBase { ); $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter')); $this->assertNoText(t('No strings available.'), t('String successfully imported.')); + + // Ensure multiline string was imported. + $search = array( + 'string' => 'HTTP Result Code: !status', + 'langcode' => $langcode, + 'translation' => 'all', + ); + $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter')); + $this->assertNoText(t('No strings available.'), t('String successfully imported.')); + + // Ensure xyzzy was imported and xyzzy not. + $search = array( + 'string' => 'xyzzy', + 'langcode' => $langcode, + 'translation' => 'all', + ); + $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter')); + $this->assertNoText(t('No strings available.'), t('String successfully imported.')); + $this->assertNoText('xyzzy2', t('String successfully imported.')); } /** diff --git a/core/modules/locale/locale.bulk.inc b/core/modules/locale/locale.bulk.inc index 7a8a977..d96b40c 100644 --- a/core/modules/locale/locale.bulk.inc +++ b/core/modules/locale/locale.bulk.inc @@ -5,7 +5,10 @@ * Mass import-export and batch import functionality for Gettext .po files. */ -include_once DRUPAL_ROOT . '/core/includes/gettext.inc'; +use Drupal\Component\Gettext\PoFileWriter; +use Drupal\locale\Gettext; +use Drupal\locale\PoDatabaseReader; + /** * User interface for the translation import screen. @@ -107,7 +110,39 @@ function locale_translate_import_form_submit($form, &$form_state) { $customized = $form_state['values']['customized'] ? LOCALE_CUSTOMIZED : LOCALE_NOT_CUSTOMIZED; // Now import strings into the language - if ($return = _locale_import_po($file, $language->langcode, $form_state['values']['overwrite_options'], $customized) == FALSE) { + try { + // Try to allocate enough time to parse and import the data. + drupal_set_time_limit(240); + + $report = GetText::fileToDatabase($file, $language->langcode, $form_state['values']['overwrite_options'], $customized); + $additions = $report['additions']; + $updates = $report['updates']; + $deletes = $report['deletes']; + $skips = $report['skips']; + + menu_router_rebuild(); + // Clear cache and force refresh of JavaScript translations. + _locale_invalidate_js($language->langcode); + cache()->deletePrefix('locale:'); + + 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' => $language->langcode, '%number' => $additions, '%update' => $updates, '%delete' => $deletes)); + if ($skips) { + if (module_exists('dblog')) { + $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. See the log for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.', array('@url' => url('admin/reports/dblog'))); + } + else { + $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. See the log for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.'); + } + drupal_set_message($skip_message, 'error'); + watchdog('locale', '@count disallowed HTML string(s) in %file', array('@count' => $skips, '%file' => $file->uri), WATCHDOG_WARNING); + } + $variables = array('%filename' => $file->filename); + drupal_set_message(t('The translation import of %filename is done.', $variables)); + watchdog('locale', 'The translation import of %filename is done.', $variables); + + } catch (Exception $exc) { + drupal_set_message(print_r($exc, TRUE)); $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); @@ -207,7 +242,42 @@ function locale_translate_export_form_submit($form, &$form_state) { $language = NULL; } $content_options = isset($form_state['values']['content_options']) ? $form_state['values']['content_options'] : array(); - _locale_export_po($language, _locale_export_po_generate($language, _locale_export_get_strings($language, $content_options))); + $reader = new PoDatabaseReader(); + $languageName = ''; + if ($language != NULL) { + $reader->setLangcode($language->langcode); + $reader->setOptions($content_options); + $languages = language_list(); + $languageName = isset($languages[$language->langcode]) ? $languages[$language->langcode]->name : ''; + $filename = $language->langcode .'.po'; + } else { + // Template required. + $filename = 'drupal.pot'; + } + $item = $reader->readItem(); + if (!empty($item)) { + $uri = tempnam('temporary://', 'po_'); + $header = $reader->getHeader(); + $header->setProjectName(variable_get('site_name', 'Drupal')); + $header->setLanguageName($languageName); + + $writer = new PoFileWriter; + $writer->setUri($uri); + $writer->setHeader($header); + + $writer->open(); + $writer->writeItem($item); + $writer->writeItems($reader); + $writer->close(); + + header("Content-Disposition: attachment; filename=$filename"); + header("Content-Type: text/plain; charset=utf-8"); + print file_get_contents($uri); + drupal_exit(); + } + else { + drupal_set_message('Nothing to export.'); + } } /** @@ -288,9 +358,12 @@ function locale_translate_batch_build($files, $finish_feedback = FALSE) { /** * Perform interface translation import as a batch step. * + * The given filepath is matched against ending with '{langcode}.po'. When + * matched the filepath is added to batch context. + * * @param $filepath * Path to a file to import. - * @param $results + * @param $context * Contains a list of files imported. */ function locale_translate_batch_import($filepath, &$context) { @@ -298,8 +371,15 @@ function locale_translate_batch_import($filepath, &$context) { // we can extract the language code to use for the import from the end. if (preg_match('!(/|\.)([^\./]+)\.po$!', $filepath, $langcode)) { $file = entity_create('file', array('filename' => drupal_basename($filepath), 'uri' => $filepath)); - _locale_import_read_po('db-store', $file, array(), $langcode[2]); - $context['results'][] = $filepath; + // We need only the last match + $langcode = array_pop($langcode); + try { + $report = GetText::fileToDatabase($file, $langcode, array(), LOCALE_NOT_CUSTOMIZED); + $context['results']['files'][$filepath] = $filepath; + $context['results']['stats'][$filepath] = $report; + } catch (Exception $exc) { + drupal_set_message(print_r($exc, TRUE)); + } } } @@ -308,6 +388,29 @@ function locale_translate_batch_import($filepath, &$context) { */ function locale_translate_batch_finished($success, $results) { if ($success) { - drupal_set_message(format_plural(count($results), 'One translation file imported.', '@count translation files imported.')); + $additions = $updates = $deletes = $skips = 0; + drupal_set_message(format_plural(count($results['files']), 'One translation file imported.', '@count translation files imported.')); + $skipped_files = array(); + foreach ($results['stats'] as $filepath => $report) { + $additions += $report['additions']; + $updates += $report['updates']; + $deletes += $report['deletes']; + $skips += $report['skips']; + if ($report['skips'] > 0) { + $skipped_files[] = $filepath; + } + } + 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', 'The translation was succesfully imported. %number new strings added, %update updated and %delete removed.', array('%number' => $additions, '%update' => $updates, '%delete' => $deletes)); + if ($skips) { + if (module_exists('dblog')) { + $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. See the log for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.', array('@url' => url('admin/reports/dblog'))); + } + else { + $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. See the log for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.'); + } + drupal_set_message($skip_message, 'error'); + watchdog('locale', '@count disallowed HTML string(s) in files: @files.', array('@count' => $skips, '@files' => implode(',', $skipped_files)), WATCHDOG_WARNING); + } } } diff --git a/core/modules/locale/tests/test2.xx.po b/core/modules/locale/tests/test2.xx.po new file mode 100755 index 0000000..1733f69 --- /dev/null +++ b/core/modules/locale/tests/test2.xx.po @@ -0,0 +1,15 @@ +msgid "" +msgstr "" +"Project-Id-Version: Drupal 7\\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=UTF-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\\n" + +msgid "xyzzy" +msgstr "html" + +msgid "xyzzy2" +msgstr "" + + diff --git a/core/modules/locale/tests/test3.xx.po b/core/modules/locale/tests/test3.xx.po new file mode 100755 index 0000000..3840a0a --- /dev/null +++ b/core/modules/locale/tests/test3.xx.po @@ -0,0 +1,16 @@ +msgid "" +msgstr "" +"Project-Id-Version: Drupal 7\\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=UTF-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\\n" + +msgid "HTTP Result Code: !status" +msgstr "" +"a multiline test" +"second\r\n" +"third" + + +