Commit 10e58bdd authored by catch's avatar catch

Issue #1777070 by Jose Reyero, Gábor Hojtsy: Refactor and clean up source string location handling.

parent 3b667f26
......@@ -412,6 +412,60 @@ function update_prepare_d8_language() {
);
db_add_field('locales_target', 'customized', $spec);
}
// Add locales_location table to track string locations.
// When updating in a non-English language, this table is used for
// refreshing JavaScript translations.
if (db_table_exists('locales_source') && !db_table_exists('locales_location')) {
$table = array(
'description' => 'Location information for source strings.',
'fields' => array(
'lid' => array(
'type' => 'serial',
'not null' => TRUE,
'description' => 'Unique identifier of this location.',
),
'sid' => array(
'type' => 'int',
'not null' => TRUE,
'description' => 'Unique identifier of this string.',
),
'type' => array(
'type' => 'varchar',
'length' => 50,
'not null' => TRUE,
'default' => '',
'description' => 'The location type (file, config, path, etc).',
),
'name' => array(
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
'default' => '',
'description' => 'Type dependent location information (file name, path, etc).',
),
'version' => array(
'type' => 'varchar',
'length' => 20,
'not null' => TRUE,
'default' => 'none',
'description' => 'Version of Drupal where the location was found.',
),
),
'primary key' => array('lid'),
'foreign keys' => array(
'locales_source' => array(
'table' => 'locales_source',
'columns' => array('sid' => 'lid'),
),
),
'indexes' => array(
'string_id' => array('sid'),
'string_type' => array('sid', 'type'),
),
);
db_create_table('locales_location', $table);
}
}
}
......
......@@ -61,7 +61,6 @@ protected function resolveCacheMiss($offset) {
));
if ($translation) {
$this->stringStorage->checkVersion($translation, VERSION);
$value = !empty($translation->translation) ? $translation->translation : TRUE;
}
else {
......@@ -70,9 +69,8 @@ protected function resolveCacheMiss($offset) {
$this->stringStorage->createString(array(
'source' => $offset,
'context' => $this->context,
'location' => request_uri(),
'version' => VERSION
))->save();
))->addLocation('path', request_uri())->save();
$value = TRUE;
}
$this->storage[$offset] = $value;
......
......@@ -102,6 +102,7 @@ function setReport($report = array()) {
'updates' => 0,
'deletes' => 0,
'skips' => 0,
'strings' => array(),
);
$this->_report = $report;
}
......@@ -226,13 +227,13 @@ private function importString(PoItem $item) {
$source = $item->getSource();
$translation = $item->getTranslation();
// Look up the source string and any existing translation.
$string = locale_storage()->findTranslation(array(
$strings = locale_storage()->getTranslations(array(
'language' => $this->_langcode,
'source' => $source,
'context' => $context
));
$string = reset($strings);
if (!empty($translation)) {
// Skip this string unless it passes a check for dangerous code.
......@@ -258,6 +259,7 @@ private function importString(PoItem $item) {
$string->save();
$this->_report['updates']++;
}
$this->_report['strings'][] = $string->getId();
return $string->lid;
}
else {
......@@ -272,6 +274,7 @@ private function importString(PoItem $item) {
))->save();
$this->_report['additions']++;
$this->_report['strings'][] = $string->getId();
return $string->lid;
}
}
......@@ -279,6 +282,7 @@ private function importString(PoItem $item) {
// Empty translation, remove existing if instructed.
$string->delete();
$this->_report['deletes']++;
$this->_report['strings'][] = $string->lid;
return $string->lid;
}
}
......
......@@ -22,11 +22,11 @@ abstract class StringBase implements StringInterface {
public $lid;
/**
* The string location.
* The string locations indexed by type.
*
* @var string
*/
public $location;
public $locations;
/**
* The source string.
......@@ -151,6 +151,35 @@ public function getValues(array $fields) {
return $values;
}
/**
* Implements Drupal\locale\StringInterface::getLocation().
*/
public function getLocations($check_only = FALSE) {
if (!isset($this->locations) && !$check_only) {
$this->locations = array();
foreach ($this->getStorage()->getLocations(array('sid' => $this->getId())) as $location) {
$this->locations[$location->type][$location->name] = $location->lid;
}
}
return isset($this->locations) ? $this->locations : array();
}
/**
* Implements Drupal\locale\StringInterface::addLocation().
*/
public function addLocation($type, $name) {
$this->locations[$type][$name] = TRUE;
return $this;
}
/**
* Implements Drupal\locale\StringInterface::hasLocation().
*/
public function hasLocation($type, $name) {
$locations = $this->getLocations();
return isset($locations[$type]) ? !empty($locations[$type][$name]) : FALSE;
}
/**
* Implements Drupal\locale\LocaleString::save().
*/
......
......@@ -80,11 +80,24 @@ public function findTranslation(array $conditions) {
->execute()
->fetchObject('Drupal\locale\TranslationString');
if ($string) {
$this->checkVersion($string, VERSION);
$string->setStorage($this);
}
return $string;
}
/**
* Implements Drupal\locale\StringStorageInterface::getLocations().
*/
function getLocations(array $conditions = array()) {
$query = $this->connection->select('locales_location', 'l', $this->options)
->fields('l');
foreach ($conditions as $field => $value) {
$query->condition('l.' . $field, $value);
}
return $query->execute()->fetchAll();
}
/**
* Implements Drupal\locale\StringStorageInterface::countStrings().
*/
......@@ -99,19 +112,6 @@ public function countTranslations() {
return $this->dbExecute("SELECT t.language, COUNT(*) AS translated FROM {locales_source} s INNER JOIN {locales_target} t ON s.lid = t.lid GROUP BY t.language")->fetchAllKeyed();
}
/**
* Implements Drupal\locale\StringStorageInterface::checkVersion().
*/
public function checkVersion($string, $version) {
if ($string->getId() && $string->getVersion() != $version) {
$string->setVersion($version);
$this->connection->update('locales_source', $this->options)
->condition('lid', $string->getId())
->fields(array('version' => $version))
->execute();
}
}
/**
* Implements Drupal\locale\StringStorageInterface::save().
*/
......@@ -127,9 +127,62 @@ public function save($string) {
else {
$this->dbStringUpdate($string);
}
// Update locations if they come with the string.
$this->updateLocation($string);
return $this;
}
/**
* Update locations for string.
*
* @param Drupal\locale\StringInterface $string
* The string object.
*/
protected function updateLocation($string) {
if ($locations = $string->getLocations(TRUE)) {
$created = FALSE;
foreach ($locations as $type => $location) {
foreach ($location as $name => $lid) {
if (!$lid) {
$this->dbDelete('locales_location', array('sid' => $string->getId(), 'type' => $type, 'name' => $name))
->execute();
}
elseif ($lid === TRUE) {
// This is a new location to add, take care not to duplicate.
$this->connection->merge('locales_location', $this->options)
->key(array('sid' => $string->getId(), 'type' => $type, 'name' => $name))
->fields(array('version' => VERSION))
->execute();
$created = TRUE;
}
// Loaded locations have 'lid' integer value, nor FALSE, nor TRUE.
}
}
if ($created) {
// As we've set a new location, check string version too.
$this->checkVersion($string, VERSION);
}
}
}
/**
* Checks whether the string version matches a given version, fix it if not.
*
* @param Drupal\locale\StringInterface $string
* The string object.
* @param string $version
* Drupal version to check against.
*/
protected function checkVersion($string, $version) {
if ($string->getId() && $string->getVersion() != $version) {
$string->setVersion($version);
$this->connection->update('locales_source', $this->options)
->condition('lid', $string->getId())
->fields(array('version' => $version))
->execute();
}
}
/**
* Implements Drupal\locale\StringStorageInterface::delete().
*/
......@@ -138,6 +191,7 @@ public function delete($string) {
$this->dbDelete('locales_target', $keys)->execute();
if ($string->isSource()) {
$this->dbDelete('locales_source', $keys)->execute();
$this->dbDelete('locales_location', $keys)->execute();
$string->setId(NULL);
}
}
......@@ -157,6 +211,7 @@ public function deleteStrings($conditions) {
if ($lids) {
$this->dbDelete('locales_target', array('lid' => $lids))->execute();
$this->dbDelete('locales_source', array('lid' => $lids))->execute();
$this->dbDelete('locales_location', array('sid' => $lids))->execute();
}
}
......@@ -191,11 +246,19 @@ public function createTranslation($values = array()) {
* Field name to find the table alias for.
*
* @return string
* Either 's' or 't' depending on whether the field belongs to source or
* target table.
* Either 's', 't' or 'l' depending on whether the field belongs to source,
* target or location table table.
*/
protected function dbFieldTable($field) {
return in_array($field, array('language', 'translation', 'customized')) ? 't' : 's';
if (in_array($field, array('language', 'translation', 'customized'))) {
return 't';
}
elseif (in_array($field, array('type', 'name'))) {
return 'l';
}
else {
return 's';
}
}
/**
......@@ -367,6 +430,20 @@ protected function dbStringSelect(array $conditions, array $options = array()) {
}
}
// If we have conditions for location's type or name, then we need the
// location table, for which we add a subquery.
if (isset($conditions['type']) || isset($conditions['name'])) {
$subquery = $this->connection->select('locales_location', 'l', $this->options)
->fields('l', array('sid'));
foreach (array('type', 'name') as $field) {
if (isset($conditions[$field])) {
$subquery->condition('l.' . $field, $conditions[$field]);
unset($conditions[$field]);
}
}
$query->condition('s.lid', $subquery, 'IN');
}
// Add conditions for both tables.
foreach ($conditions as $field => $value) {
$table_alias = $this->dbFieldTable($field);
......
......@@ -157,6 +157,54 @@ public function setValues(array $values, $override = TRUE);
*/
public function getValues(array $fields);
/**
* Gets location information for this string.
*
* Locations are arbitrary pairs of type and name strings, used to store
* information about the origins of the string, like the file name it
* was found on, the path on which it was discovered, etc...
*
* A string can have any number of locations since the same string may be
* found on different places of Drupal code and configuration.
*
* @param bool $check_only
* (optional) Set to TRUE to get only new locations added during the
* current page request and not loading all existing locations.
*
* @return array
* Location ids indexed by type and name.
*/
public function getLocations($check_only = FALSE);
/**
* Adds a location for this string.
*
* @param string $type
* Location type that may be any arbitrary string. Types used in Drupal
* core are: 'javascript', 'path', 'code', 'configuration'.
* @param string $name
* Location name. Drupal path in case of online discovered translations,
* file path in case of imported strings, configuration name for strings
* that come from configuration, etc...
*
* @return Drupal\locale\LocaleString
* The called object.
*/
public function addLocation($type, $name);
/**
* Checks whether the string has a given location.
*
* @param string $type.
* Location type.
* @param string $name.
* Location name.
*
* @return bool
* TRUE if the string has a location with this type and name.
*/
public function hasLocation($type, $name);
/**
* Saves string object to storage.
*
......
......@@ -50,6 +50,24 @@ public function getStrings(array $conditions = array(), array $options = array()
* Array of Drupal\locale\StringInterface objects matching the conditions.
*/
public function getTranslations(array $conditions = array(), array $options = array());
/**
* Loads string location information.
*
* @see Drupal\locale\StringStorageInterface::getStrings()
*
* @param array $conditions
* (optional) Array with conditions to filter the locations that may be any
* of the follwing elements:
* - 'sid', The tring identifier.
* - 'type', The location type.
* - 'name', The location name.
*
* @return array
* Array of location objects matching the conditions.
*/
public function getLocations(array $conditions = array());
/**
* Loads a string source object, fast query.
*
......@@ -69,6 +87,10 @@ public function findString(array $conditions);
/**
* Loads a string translation object, fast query.
*
* This function must only be used when actually translating strings as it
* will have the effect of updating the string version. For other purposes
* the getTranslations() method should be used instead.
*
* @param array $conditions
* (optional) Array with conditions that will be used to filter the strings
* returned and may include all of the conditions defined by getStrings().
......@@ -78,16 +100,6 @@ public function findString(array $conditions);
*/
public function findTranslation(array $conditions);
/**
* Checks whether the string version matches a given version, fix it if not.
*
* @param Drupal\locale\StringInterface $string
* The string object.
* @param string $version
* Drupal version to check against.
*/
public function checkVersion($string, $version);
/**
* Save string object to storage.
*
......
......@@ -37,12 +37,9 @@ function testFileParsing() {
_locale_parse_js_file($filename);
// Get all of the source strings that were found.
$source_strings = db_select('locales_source', 's')
->fields('s', array('source', 'context'))
->condition('s.location', $filename)
->execute()
->fetchAllKeyed();
foreach (locale_storage()->getStrings(array('type' => 'javascript', 'name' => $filename)) as $string) {
$source_strings[$string->source] = $string->context;
}
// List of all strings that should be in the file.
$test_strings = array(
"Standard Call t" => '',
......
......@@ -72,8 +72,7 @@ function testStringCRUDAPI() {
// Check version handling and updating.
$this->assertEqual($source->version, 'none', 'String originally created without version.');
$this->storage->checkVersion($source, VERSION);
$string = $this->storage->findString(array('lid' => $source->lid));
$string = $this->storage->findTranslation(array('lid' => $source->lid));
$this->assertEqual($string->version, VERSION, 'Checked and updated string version to Drupal version.');
// Create translation and find it by lid and source.
......
......@@ -70,9 +70,9 @@ function testUninstallProcess() {
$user = $this->drupalCreateUser(array('translate interface', 'access administration pages'));
$this->drupalLogin($user);
$this->drupalGet('admin/config/regional/translate/translate');
$string = db_query('SELECT min(lid) AS lid, source FROM {locales_source} WHERE location LIKE :location', array(
':location' => '%.js%',
))->fetchObject();
// Get any of the javascript strings to translate.
$js_strings = locale_storage()->getStrings(array('type' => 'javascript'));
$string = reset($js_strings);
$edit = array('string' => $string->source);
$this->drupalPost('admin/config/regional/translate', $edit, t('Filter'));
$edit = array('strings[' . $string->lid . '][translations][0]' => 'french translation');
......
......@@ -492,17 +492,20 @@ function locale_translate_batch_import($filepath, $options, &$context) {
$file->timestamp = filemtime($file->uri);
locale_translate_update_file_history($file);
$context['results']['files'][$filepath] = $filepath;
$context['results']['languages'][$filepath] = $file->langcode;
}
// Add the values from the report to the stats for this file.
if (!isset($context['results']['stats']) || !isset($context['results']['stats'][$filepath])) {
$context['results']['stats'][$filepath] = array();
}
foreach ($report as $key => $value) {
if (is_numeric($report[$key])) {
if (!isset($context['results']['stats'][$filepath][$key])) {
$context['results']['stats'][$filepath][$key] = 0;
}
$context['results']['stats'][$filepath][$key] += $report[$key];
if (is_numeric($value)) {
$context['results']['stats'][$filepath] += array($key => 0);
$context['results']['stats'][$filepath][$key] += $value;
}
elseif (is_array($value)) {
$context['results']['stats'][$filepath] += array($key => array());
$context['results']['stats'][$filepath][$key] = array_merge($context['results']['stats'][$filepath][$key], $value);
}
}
}
......@@ -519,6 +522,7 @@ function locale_translate_batch_import($filepath, $options, &$context) {
function locale_translate_batch_finished($success, $results) {
if ($success) {
$additions = $updates = $deletes = $skips = 0;
$strings = $langcodes = array();
drupal_set_message(format_plural(count($results['files']), 'One translation file imported.', '@count translation files imported.'));
$skipped_files = array();
// If there are no results and/or no stats (eg. coping with an empty .po
......@@ -532,7 +536,11 @@ function locale_translate_batch_finished($success, $results) {
if ($report['skips'] > 0) {
$skipped_files[] = $filepath;
}
$strings = array_merge($strings, $report['strings']);
}
// Get list of unique string identifiers and language codes updated.
$strings = array_unique($strings);
$langcodes = array_unique(array_values($results['languages']));
}
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));
......@@ -547,9 +555,10 @@ function locale_translate_batch_finished($success, $results) {
watchdog('locale', '@count disallowed HTML string(s) in files: @files.', array('@count' => $skips, '@files' => implode(',', $skipped_files)), WATCHDOG_WARNING);
}
// Clear cache and force refresh of JavaScript translations.
_locale_invalidate_js();
cache()->invalidateTags(array('locale' => TRUE));
if ($strings) {
// Clear cache and force refresh of JavaScript translations.
_locale_refresh_translations($langcodes, $strings);
}
}
}
......
......@@ -52,12 +52,6 @@ function locale_schema() {
'not null' => TRUE,
'description' => 'Unique identifier of this string.',
),
'location' => array(
'type' => 'text',
'not null' => FALSE,
'size' => 'big',
'description' => 'Drupal path in case of online discovered translations or file path in case of imported strings.',
),
'source' => array(
'type' => 'text',
'mysql_type' => 'blob',
......@@ -76,7 +70,7 @@ function locale_schema() {
'length' => 20,
'not null' => TRUE,
'default' => 'none',
'description' => 'Version of Drupal, where the string was last used (for locales optimization).',
'description' => 'Version of Drupal where the string was last used (for locales optimization).',
),
),
'primary key' => array('lid'),
......@@ -126,6 +120,54 @@ function locale_schema() {
),
);
$schema['locales_location'] = array(
'description' => 'Location information for source strings.',
'fields' => array(
'lid' => array(
'type' => 'serial',
'not null' => TRUE,
'description' => 'Unique identifier of this location.',
),
'sid' => array(
'type' => 'int',
'not null' => TRUE,
'description' => 'Unique identifier of this string.',
),
'type' => array(
'type' => 'varchar',
'length' => 50,
'not null' => TRUE,
'default' => '',
'description' => 'The location type (file, config, path, etc).',
),
'name' => array(
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
'default' => '',
'description' => 'Type dependent location information (file name, path, etc).',
),
'version' => array(
'type' => 'varchar',
'length' => 20,
'not null' => TRUE,
'default' => 'none',
'description' => 'Version of Drupal where the location was found.',
),
),
'primary key' => array('lid'),
'foreign keys' => array(
'locales_source' => array(
'table' => 'locales_source',
'columns' => array('sid' => 'lid'),
),
),
'indexes' => array(
'string_id' => array('sid'),
'string_type' => array('sid', 'type'),
),
);
$schema['locale_file'] = array(
'description' => 'File import status information for interface translation files.',
'fields' => array(
......@@ -740,6 +782,13 @@ function locale_update_8013() {
}
}
/**
* Drop old 'location' field.
*/
function locale_update_8014() {
db_drop_field('locales_source', 'location');
}
/**
* @} End of "addtogroup updates-7.x-to-8.x".
* The next series of updates should start at 9000.
......
......@@ -770,9 +770,38 @@ function locale_string_is_safe($string) {
return decode_entities($string) == decode_entities(filter_xss($string, array('a', 'abbr', 'acronym', 'address', 'b', 'bdo', 'big', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'ul', 'var')));
}
/**
* Refresh related information after string translations have been updated.
*
* The information that will be refreshed includes:
* - JavaScript translations.
* - Locale cache.
*
* @param array $langcodes
* Language codes for updated translations.
* @param array $lids
* List of string identifiers that have been updated / created.
*/
function _locale_refresh_translations($langcodes, $lids) {
if ($lids && $langcodes) {
// Update javascript translations if any of the strings has a javascript location.
if ($strings = locale_storage()->getStrings(array('lid' => $lids, 'type' => 'javascript'))) {
array_map('_locale_invalidate_js', $langcodes);
}
}
// Clear locale cache.
cache()->invalidateTags(array('locale' => TRUE));
}
/**
* Parses a JavaScript file, extracts strings wrapped in Drupal.t() and
* Drupal.formatPlural() and inserts them into the database.
*
* @param string $filepath
* File name to parse.
*
* @return array
* Array of string objects to update indexed by context and source.
*/
function _locale_parse_js_file($filepath) {
// The file path might contain a query string, so make sure we only use the
......@@ -858,28 +887,17 @@ function _locale_parse_js_file($filepath) {
$context = implode('', preg_split('~(?<!\\\\)[\'"]\s*\+\s*[\'"]~s', substr($match['context'], 1, -1)));
$source = locale_storage()->findString(array('source' => $string, 'context' => $context));
if ($source) {
// We already have this source string and now have to add the location
// to the location column, if this file is not yet present in there.
$locations = preg_split('~\s*;\s*~', $source->location);
if (!in_array($filepath, $locations)) {
$locations[] = $filepath;
$locations = implode('; ', $locations);
// Save the new locations string to the database.