Index: modules/locale/locale.module
===================================================================
RCS file: /cvs/drupal/drupal/modules/locale/locale.module,v
retrieving revision 1.237
diff -u -r1.237 locale.module
--- modules/locale/locale.module	5 Feb 2009 00:32:46 -0000	1.237
+++ modules/locale/locale.module	9 Feb 2009 17:01:09 -0000
@@ -196,7 +196,20 @@
 function locale_locale($op = 'groups') {
   switch ($op) {
     case 'groups':
-      return array('default' => t('Built-in interface'));
+      return array(
+        'default' => array(
+          'name' => t('Built-in interface'),
+          'index' => 'source', // This group is indexed by source string
+          'cache' => 'LENGTH (s.source) < 75', // Condition to fill in the cache
+          'version' => VERSION, // This group's string may change with Drupal version
+          'update' => TRUE, // Update sources as we are translating
+        ),
+        'text' => array(
+          'name' => t('Long texts from modules'),
+          'index' => 'location',
+          'cache' => FALSE,
+        ),
+      );
   }
 }
 
@@ -325,8 +338,9 @@
 
 /**
  * Provides interface translation services.
- *
- * This function is called from t() to translate a string if needed.
+ * 
+ * This function is string based (will use the source string for indexing)
+ * It is called from t() to translate a hardcoded string if needed.
  *
  * @param $string
  *   A string to look up translation for. If omitted, all the
@@ -334,10 +348,64 @@
  *   used on the page.
  * @param $langcode
  *   Language code to use for the lookup.
+ * @param $textgroup
+ *   The text group this string belongs too
  * @param $reset
  *   Set to TRUE to reset the in-memory cache.
  */
-function locale($string = NULL, $langcode = NULL, $reset = FALSE) {
+function locale_string($string = NULL, $langcode = NULL, $textgroup = 'default', $reset = FALSE) {
+  return locale_translate($textgroup, $string, 'source', $langcode, $string, $reset);
+}
+
+/**
+ * Provides interface translation services.
+ * 
+ * This function is location based (will use the string location for indexing)
+ * It is called from t() to translate a hardcoded string if needed.
+ *
+ * @param $textgroup
+ *    The text group this string belongs too
+ * @param $location
+ *   Source string location, will be used to find the translation
+ * @param $langcode
+ *   Language code to use for the lookup.
+ * @param $textgroup
+ *   The text group this string belongs too
+ * @param $string
+ *   Optional source string to be used for update operations
+ * @param $reset
+ *   Set to TRUE to reset the in-memory cache.
+ * 
+ * @return
+ *   Translated string if found. Passed $string if not.
+ */
+function locale_location($textgroup = NULL, $location = NULL, $langcode = NULL, $string = NULL, $reset = FALSE) {
+  return locale_translate($textgroup, $string, 'location', $langcode, $string, $reset);
+}
+
+/**
+ * Provides interface translation services.
+ * 
+ * This function will find translations for any of the text groups
+ * searching by any field (source, location)
+ *
+ * @param $textgroup
+ *   Text group to search for
+ * @param $value
+ *   Unique key for this string inside the text group
+ * @param $index
+ *   Field to use as index that will be matched against $value
+ * @param $string
+ *   The source string, that may be used for updating
+ * @param $langcode
+ *   Language code to use for the lookup.
+ * @param $reset
+ *   Set to TRUE to reset the in-memory cache.
+ * 
+ * @return string
+ *   Translated string if found, source string otherwise
+ */
+function locale_translate($textgroup = NULL, $value = NULL, $index = NULL, $langcode = NULL, $string = NULL, $reset = FALSE) {
   global $language;
   static $locale_t;
 
@@ -345,66 +413,54 @@
     // Reset in-memory cache.
     $locale_t = NULL;
   }
-
-  if (!isset($string)) {
-    // Return all cached strings if no string was specified
+  // Return all cached strings or all the text group if parameters missing
+  if (!isset($textgroup)) {
     return $locale_t;
   }
+  elseif (!isset($value)) {
+    return isset($locale_t[$textgroup]) ? $locale_t[$textgroup] : array();
+  }
 
   $langcode = isset($langcode) ? $langcode : $language->language;
 
+  // If not passed index, get the default for this text group
+  if (!$index) {
+    $index = locale_textgroups($textgroup, $index);
+  }
+  if (!$string && $index == 'source') {
+    $string = $value;
+  }
   // Store database cached translations in a static var.
-  if (!isset($locale_t[$langcode])) {
-    $locale_t[$langcode] = array();
-    // Disabling the usage of string caching allows a module to watch for
-    // the exact list of strings used on a page. From a performance
-    // perspective that is a really bad idea, so we have no user
-    // interface for this. Be careful when turning this option off!
-    if (variable_get('locale_cache_strings', 1) == 1) {
-      if ($cache = cache_get('locale:' . $langcode, 'cache')) {
-        $locale_t[$langcode] = $cache->data;
-      }
-      else {
-        // Refresh database stored cache of translations for given language.
-        // We only store short strings used in current version, to improve
-        // performance and consume less memory.
-        $result = db_query("SELECT s.source, t.translation, t.language FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = '%s' WHERE s.textgroup = 'default' AND s.version = '%s' AND LENGTH(s.source) < 75", $langcode, VERSION);
-        while ($data = db_fetch_object($result)) {
-          $locale_t[$langcode][$data->source] = (empty($data->translation) ? TRUE : $data->translation);
-        }
-        cache_set('locale:' . $langcode, $locale_t[$langcode]);
-      }
-    }
+  if (!isset($locale_t[$textgroup][$index][$langcode])) {
+    $locale_t[$textgroup][$index][$langcode] = _locale_get_cache($textgroup, $index, $langcode);
   }
 
   // If we have the translation cached, skip checking the database
-  if (!isset($locale_t[$langcode][$string])) {
-
-    // We do not have this translation cached, so get it from the DB.
-    $translation = db_fetch_object(db_query("SELECT s.lid, t.translation, s.version FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = '%s' WHERE s.source = '%s' AND s.textgroup = 'default'", $langcode, $string));
-    if ($translation) {
-      // We have the source string at least.
-      // Cache translation string or TRUE if no translation exists.
-      $locale_t[$langcode][$string] = (empty($translation->translation) ? TRUE : $translation->translation);
-
-      if ($translation->version != VERSION) {
-        // This is the first use of this string under current Drupal version. Save version
+  if (!isset($locale_t[$textgroup][$index][$langcode][$value])) {
+    // If the group has auto update mode, retrieve full translation and check some more stuff 
+    $update = locale_textgroup($textgroup, 'update');
+    $translation = locale_get_translation(array('textgroup' => $textgroup, $index => $value, 'language' => $langcode), $update);
+    // Cache translation string or TRUE if no translation exists.
+    $locale_t[$textgroup][$index][$langcode][$value] = empty($translation->translation) ? TRUE : $translation->translation;
+    if ($string && $update) {
+      // Some groups use version, some groups dosn't
+      $version = locale_textgroups($textgroup, 'version');      
+      if (!$translation && $string) {
+        $source = array('textgroup' => $textgroup, 'source' => $string, 'version' => $version, 'location' => request_uri());
+        $source += array($index => $value);
+        locale_save_source($source);
+      }
+      elseif ($translation && $version && $translation->version != $version) {
+        // This is the first use of this string under current version. Save version
         // and clear cache, to include the string into caching next time. Saved version is
         // also a string-history information for later pruning of the tables.
-        db_query("UPDATE {locales_source} SET version = '%s' WHERE lid = %d", VERSION, $translation->lid);
-        cache_clear_all('locale:', 'cache', TRUE);
+        db_query("UPDATE {locales_source} SET version = '%s' WHERE lid = %d", $version, $translation->lid);
+        variable_set('locale_rebuild_' . $textgroup, 1);             
       }
     }
-    else {
-      // We don't have the source string, cache this as untranslated.
-      db_query("INSERT INTO {locales_source} (location, source, textgroup, version) VALUES ('%s', '%s', 'default', '%s')", request_uri(), $string, VERSION);
-      $locale_t[$langcode][$string] = TRUE;
-      // Clear locale cache so this string can be added in a later request.
-      cache_clear_all('locale:', 'cache', TRUE);
-    }
   }
 
-  return ($locale_t[$langcode][$string] === TRUE ? $string : $locale_t[$langcode][$string]);
+  return $locale_t[$textgroup][$index][$langcode][$value] === TRUE ? $string : $locale_t[$textgroup][$index][$langcode][$value];
 }
 
 /**
@@ -557,6 +613,190 @@
   }
 }
 
+/**
+ * @defgroup locale-api Locale API functions for source and target strings
+ * @{
+ */
+
+/**
+ * Get information about text groups.
+ *
+ * This will return information for all text groups if not group specified, information for that
+ * group if specified, or the value of a group's property if $property is passed.
+ * 
+ * @param $group
+ *   Optional group name
+ * @param $property
+ *   Optional value to query
+ */
+function locale_textgroup($group = NULL, $property = NULL) {
+  static $textgroups;
+  
+  if (!isset($textgroups)) {
+    $textgroups = module_invoke_all('locale', 'groups');
+  }
+  
+  if ($group && $property) {
+    return isset($textgroups[$group][$property]) ? $textgroups[$group][$property] : NULL;
+  }
+  elseif ($group) {
+    return isset($textgroups[$group]) ? $textgroups[$group] : NULL;
+  }
+  elseif ($property) {
+    $list = array();
+    foreach ($textgroups as $key => $data) {
+      isset($data[$property]) ? ($list[$key] = $data[$property]) : NULL;
+    }
+    return $list;
+  }
+  else {
+    return $textgroups;
+  }
+}
+
+/**
+ * Get source for a string, given a key and a value
+ * 
+ * @param $conditions
+ *   Either a source lid or an array of key => value pairs
+ *
+ * @return object
+ *   Source string
+ */
+function locale_get_source($conditions) {
+  static $_cache;
+  
+  if (is_numeric($conditions)) {
+    $conditions = array('lid' => $conditions);
+  }
+  
+  $query = db_select('locales_source', 's');
+  $query->fields('s', array('lid', 'textgroup', 'source', 'location', 'version'));
+  foreach ($conditions as $field => $value) {
+    $query->condition($field, $value);
+  }
+  return $query->execute()->fetchObject();
+}
+
+/**
+ * Save / update a source string
+ * 
+ * @param $string
+ *   Object representing a source string
+ */
+function locale_save_source(&$string) {
+  // Convert always into object
+  $string = (object)$string;
+  // Invalidate cache if this text group is cacheable
+  if (locale_textgroup($string->textgroup, 'cache')) {
+    variable_set('locale_rebuild_' . $string->textgroup, 1);
+  }  
+  if (!empty($string->lid)) {
+    return drupal_write_record('locale_source', $string, 'lid');
+  } else {
+    return drupal_write_record('locale_source', $string);
+  }
+}
+
+/**
+ * Get localized text for any textgroup search by location. 
+ * 
+ * This will search by location field instead of source string, and is used for long module texts
+ * 
+ * @param $conditions
+ *   Array of search conditions
+ * 
+ * @param $getsource
+ *   Whether to get source data only in case there's no translation
+ * @return Object
+ *   The full translation object
+ */
+function locale_get_translation($conditions, $getsource = FALSE) {
+  $query = db_select('locales_source', 's');
+  // If we want source data too, add fields and LEFT JOIN, otherwise no fields and INNER JOIN
+  if ($getsource) {
+    $query->fields('s', array('lid', 'version'));
+    $join = 'leftJoin';
+  }
+  else {
+    $join = 'join';
+  }
+  $query->$join('locales_target', 't', 's.lid = t.lid AND t.language = :langcode', array(':langcode' => $conditions['language']));
+  unset($conditions['language']);
+  $query->fields('t', array('translation', 'plural'));
+  foreach ($conditions as $field => $value) {
+    $query->condition('s.' . $field, $value);
+  }
+  return $query->execute()->fetchObject();
+}
+
+/**
+ * Save / update a translation
+ * 
+ * If the string has enough data and it doesn't have a string id, it will check and create the
+ * source too if not existing
+ * 
+ * @param $string
+ *   Object representing a string translation
+ * @return
+ *   SAVED_NEW, SAVED_UPDATED, or FALSE on failure.
+ */
+function locale_save_translation(&$string) {
+  // If a current translation exists, just update the record, find it if not given 
+  if (empty($string->lid)) {
+    if (!empty($string->textgroup)) {
+      $key = locale_textgroup($string->textgroup, 'key');
+      if (!empty($string->$key)) {
+        if ($source = locale_get_source(array('textgroup' => $string->textgroup, $key => $string->$key))) { 
+          $string->lid = $source->lid;
+        }
+        elseif (!empty($string->source)) {
+          // Create source if it doesn't exist and we have enough data
+          locale_save_source($string);
+        }
+      }
+    }
+  }
+  
+  // Check again for existing source and translation
+  if (!empty($string->lid)) {
+    // Invalidate cache if this text group is cacheable
+    if (!empty($string->textgroup) && locale_textgroup($string->textgroup, 'cache')) {
+      variable_set('locale_rebuild_' . $string->textgroup, 1);
+    }    
+    $existing = locale_get_translation(array('lid' => $string->lid, 'language' => $string->language));
+    if (!empty($existing)) {
+      return drupal_write_record('locales_target', $string, array('lid', 'language'));
+    } else {
+      return drupal_write_record('locales_target', $string);
+    }
+  }
+  else {
+    return FALSE;
+  }
+}
+
+/**
+ * Delete a source string and all of its translations
+ * 
+ * @param $lid
+ *   String id
+ */
+function locale_delete_source($lid) {
+  
+}
+
+/**
+ * Delete a translation
+ * 
+ * @param $lid
+ *   String id
+ */
+/**
+ * @} End of "locale-api"
+ */
+
 // ---------------------------------------------------------------------------------
 // Language switcher block
 
@@ -601,6 +841,121 @@
 }
 
 /**
+ * Translate custom strings with context information
+ * 
+ * @param $textgroup
+ *   The locale text group this string belongs to
+ * @param $key
+ *   String id, unique inside the text group
+ * @param $string
+ *   A string containing the string in the default language to translate
+ * @param $args
+ *   An associative array of replacements to make after translation.
+ * @param $langcode
+ *   Optional language code to translate to a language other than what is used
+ *   to display the page.
+ * @return
+ *   The translated string.
+ */
+function locale_tt($textgroup, $location, $string, $langcode) {
+  static $_tt;
+  
+  // If not loaded, loads the whole text group from cache or rebuilds it
+  if (!isset($_tt[$textgroup][$langcode])) {
+    $_tt[$textgroup][$langcode] = _locale_get_cache($textgroup, $langcode);
+  }
+  // If we have the translation cached, skip checking the database
+  if (!isset($_tt[$textgroup][$langcode][$location])) {
+    // We do not have this translation cached, so get it from the DB.
+    $translation = db_query("SELECT s.lid, s.source, t.translation, s.version FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.location = :location AND s.textgroup = :textgroup", 
+    array(
+      ':language' => $langcode,
+      ':textgroup'  => $textgroup,
+      ':location' => $location,    
+    ))->fetchObject();
+    if ($translation) {
+      // We have the source string at least.
+      // Cache translation string or TRUE if no translation exists.
+      $_tt[$textgroup][$langcode][$location] = (empty($translation->translation) ? TRUE : $translation->translation);
+
+      if ($translation->version != VERSION && $translation->source != $string) {
+        // This is the first use of this string under current Drupal version. Save version
+        // and clear cache, to include the string into caching next time. Saved version is
+        // also a string-history information for later pruning of the tables.
+        db_query("UPDATE {locales_source} SET version = '%s', source = '%s' WHERE lid = %d", VERSION, $string, $translation->lid);
+        variable_set('locale_rebuild_' . $textgroup, 1);
+      }
+    }
+    else {
+      // We don't have the source string, cache this as untranslated.
+      db_query("INSERT INTO {locales_source} (location, textgroup, source, version) VALUES ('%s', '%s', '%s', '%s')", $location, $textgroup, $string, VERSION);
+       $_tt[$textgroup][$langcode][$location] = TRUE;
+      // Clear locale cache so this string can be added in a later request.
+      variable_set('locale_rebuild_' . $textgroup, 1);
+    }
+  }
+
+  return ($_tt[$textgroup][$langcode][$location] === TRUE ? $string : $_tt[$textgroup][$langcode][$location]);  
+}
+
+/**
+ * Helper function to load locale cache
+ * 
+ * @param $textgroup
+ *   Text group we want to get the cache for
+ * @param $langcode
+ *   Target language to load
+ * @param $maxlength
+ *   Max length of the strings to load in the cache
+ * @param $rebuild
+ *   Whether to rebuild the whole cache for this text group
+ */
+function _locale_get_cache($textgroup, $index, $langcode, $rebuild = FALSE) {
+  $locale = array();
+
+  // See whether we need to rebuild and reset the variable if so
+  if (variable_get('locale_rebuild_' . $textgroup, 0)) {
+    $rebuild = TRUE;
+    variable_del('locale_rebuild_' . $textgroup);
+  }
+
+  // Disabling the usage of string caching allows a module to watch for
+  // the exact list of strings used on a page. From a performance
+  // perspective that is a really bad idea, so we have no user
+  // interface for this. Be careful when turning this option off!
+  if (variable_get('locale_cache_strings', 1) == 1) {
+    $cache_key = 'locale:' . $textgroup . ':' . $langcode;
+    $cache_condition = cache_get($cache_key, 'cache');
+    if (!$rebuild && $cache_condition) {
+      $locale = $cache->data;
+    }
+    else {
+      // Refresh database stored cache of translations for given language.
+      // We only store short strings used in current version, to improve
+      // performance and consume less memory.
+      $query = db_select('locales_source', 's');
+      $query->leftJoin('locales_target', 't', 's.lid = t.lid AND t.language = :langcode', array(':langcode' => $langcode));
+      $query->addField('s', $index);
+      $query->addField('t', 'translation');
+      $query->condition('textgroup', $textgroup);
+      // If this text group is versioned, add such condition
+      if ($version = locale_textgroup($textgroup, 'version')) {
+        $query->condition('version', $version);
+      }
+      // Check limit for caching
+      if (is_string($cache_condition)) {
+        $query->where($cache_condition);
+      }
+      $result = $query->execute();
+      foreach ($result as $data) {
+        $locale[$data->$index] = empty($data->translation) ? TRUE : $data->translation;
+      }
+      cache_set($cache_key, $locale);
+    }
+  }
+  return $locale;
+}
+/**
  * Theme locale translation filter selector.
  *
  * @ingroup themeable
Index: modules/locale/locale.test
===================================================================
RCS file: /cvs/drupal/drupal/modules/locale/locale.test,v
retrieving revision 1.16
diff -u -r1.16 locale.test
--- modules/locale/locale.test	5 Feb 2009 00:32:47 -0000	1.16
+++ modules/locale/locale.test	9 Feb 2009 17:01:09 -0000
@@ -48,7 +48,7 @@
     // Add string.
     t($name, array(), $langcode);
     // Reset locale cache.
-    locale(NULL, NULL, TRUE);
+    locale_string(NULL, NULL, TRUE);
     $this->assertText($langcode, 'Language code found');
     $this->assertText($name, 'Name found');
     $this->assertText($native, 'Native found');
@@ -381,3 +381,92 @@
     $this->assertIdentical($anchors, array('active' => array('en'), 'inactive' => array('fr')), t('Only the current language anchor is marked as active on the language switcher block'));
   }
 }
+
+/**
+ * Tests for locale CRUD API functions.
+ */
+class LocaleApiTest extends DrupalWebTestCase {
+  function getInfo() {
+    return array(
+      'name' => t('Locale API functions'),
+      'description' => t('Tests the performance of locale CRUD APIs.'),
+      'group' => t('Locale'),
+    );
+  }
+
+  function setUp() {
+    parent::setUp('locale');
+    include_once DRUPAL_ROOT . '/includes/locale.inc';
+    // Create and login user.
+    $admin_user = $this->drupalCreateUser(array('administer blocks', 'administer languages', 'translate interface', 'access administration pages'));
+    $this->drupalLogin($admin_user);
+  }
+
+  /**
+   * Test locale source APIs.
+   */
+  function testApis() {
+    // Insert an initial source string.
+    $source = new StdClass();
+    $source->location = 'test';
+    $source->textgroup = 'text';
+    $source->source = 'test';
+    $insert_result = locale_save_source($source);
+    $this->assertTrue($insert_result == SAVED_NEW, t('Correct value returned when new locale source string saved'));
+
+    // Update the initial source string after changing a property.
+    $source->source = 'changed';
+    $update_result = locale_save_source($source);
+    $this->assertTrue($update_result == SAVED_UPDATED, t('Correct value returned when new locale source string updated'));
+
+    // Load the source string.
+    $id_loaded_source = locale_get_source($source->lid);
+    $this->assertTrue(isset($id_loaded_source->lid) && $id_loaded_source->lid == $source->lid, t('Source loaded by ID'));
+    $this->assertTrue(isset($id_loaded_source->source) && $id_loaded_source->source == 'changed', t('Source updated'));
+
+    // Save a second source string.
+    $lids = array($source->lid);
+    unset($source->lid);
+    locale_save_source($source);
+    $lids[] = $source->lid;
+    // Load multiple strings.
+    $multiple = locale_load_source(array('lid' =>$lids));
+    $this->assertTrue(count($multiple) == 2, t('Multiple locale source strings loaded by ID'));
+
+    // Add language.
+    $edit = array(
+      'langcode' => 'fr',
+    );
+    $this->drupalPost('admin/settings/language/add', $edit, t('Add language'));
+
+    // Create a locale target string.
+    $target = new StdClass();
+    $target->lid = current($lids);
+    $target->translation = 'analyse';
+    $target->language = 'fr';
+    $insert_result = locale_save_translation($target);
+    $this->assertTrue($insert_result == SAVED_NEW, t('Correct value returned when new locale target string saved'));
+
+    // Update the initial target string.
+    $target->translation = 'tester';
+    $update_result = locale_save_translation($target);
+    $this->assertTrue($update_result == SAVED_UPDATED, t('Correct value returned when new locale target string updated'));
+
+    // Load the target string.
+    $id_loaded_target = locale_get_translation(array('lid' => $target->lid, 'language' => $target->language));
+    $this->assertTrue(isset($id_loaded_target->lid) && $id_loaded_target->lid == $target->lid, t('Target loaded by ID'));
+    $this->assertTrue(isset($id_loaded_target->translation) && $id_loaded_target->translation == 'tester', t('Target updated'));
+
+    // Load a translation.
+    $translation = locale_get_translation(array('source' => $source->source, 'textgroup' => $source->textgroup, 'location' => $source->location, 'language' => $target->language));
+    $this->assertTrue($translation == 'tester', t('Translation found with locale_get_translation()'));
+    $translation = locale_get_translation($source->source, $source->textgroup, $source->location, 'es');
+    $this->assertTrue($translation == $source->source, t('Original string returned when missing translation requested with locale_get_translation()'));
+
+    // Delete source and target strings.
+    locale_delete_strings(array('lid' =>$lids));
+    $sources = locale_load_source(array('lid' =>$lids));
+    $this->assertTrue(empty($sources), t('Locale source strings deleted'));
+  }
+
+}
Index: includes/locale.inc
===================================================================
RCS file: /cvs/drupal/drupal/includes/locale.inc,v
retrieving revision 1.202
diff -u -r1.202 locale.inc
--- includes/locale.inc	5 Feb 2009 00:32:46 -0000	1.202
+++ includes/locale.inc	9 Feb 2009 17:01:07 -0000
@@ -571,7 +571,7 @@
     'options' => array('all' => t('Both translated and untranslated strings'), 'translated' => t('Only translated strings'), 'untranslated' => t('Only untranslated strings')),
   );
 
-  $groups = module_invoke_all('locale', 'groups');
+  $groups = locale_textgroup(NULL, 'name');
   $filters['group'] = array(
     'title' => t('Limit search to'),
     'options' => array_merge(array('all' => t('All text groups')), $groups),
@@ -855,7 +855,7 @@
  */
 function locale_translate_edit_form(&$form_state, $lid) {
   // Fetch source string, if possible.
-  $source = db_fetch_object(db_query('SELECT source, textgroup, location FROM {locales_source} WHERE lid = %d', $lid));
+  $source = locale_get_source($lid);
   if (!$source) {
     drupal_set_message(t('String not found.'), 'error');
     drupal_goto('admin/build/translate/translate');
@@ -951,19 +951,17 @@
 function locale_translate_edit_form_submit($form, &$form_state) {
   $lid = $form_state['values']['lid'];
   foreach ($form_state['values']['translations'] as $key => $value) {
-    $translation = db_result(db_query("SELECT translation FROM {locales_target} WHERE lid = %d AND language = '%s'", $lid, $key));
+    // Only update or insert if we have a value to use.
     if (!empty($value)) {
-      // Only update or insert if we have a value to use.
-      if (!empty($translation)) {
-        db_query("UPDATE {locales_target} SET translation = '%s' WHERE lid = %d AND language = '%s'", $value, $lid, $key);
-      }
-      else {
-        db_query("INSERT INTO {locales_target} (lid, translation, language) VALUES (%d, '%s', '%s')", $lid, $value, $key);
-      }
+      $translation = new StdClass();
+      $translation->lid = $lid;
+      $translation->translation = $value;
+      $translation->language = $key;
+      locale_save_translation($translation);
     }
-    elseif (!empty($translation)) {
+    else {
       // Empty translation entered: remove existing entry from database.
-      db_query("DELETE FROM {locales_target} WHERE lid = %d AND language = '%s'", $lid, $key);
+      locale_delete_translations(array('lid' =>$lid, 'language' => $key));
     }
 
     // Force JavaScript translation file recreation for this language.
@@ -2127,7 +2125,7 @@
 
   $result = pager_query($sql, 50, 0, NULL, $arguments);
 
-  $groups = module_invoke_all('locale', 'groups');
+  $groups = locale_textgroup(NULL, 'name');
   $header = array(t('Text group'), t('String'), ($limit_language) ? t('Language') : t('Languages'), array('data' => t('Operations'), 'colspan' => '2'));
   $arr = array();
   while ($locale = db_fetch_object($result)) {
@@ -2735,3 +2733,101 @@
 /**
  * @} End of "locale-autoimport"
  */
+
+/**
+ * @defgroup locale-api Locale API functions for source and target strings
+ * @{
+ */
+
+/**
+ * Load multiple source strings as an array
+ * 
+ * To get a single source use locale_get_source()
+ * 
+ * @param $conditions
+ *   An array field-value pairs or a single lid to match. Values may be arrays, in which case
+ *   matching will use the 'IN' operator.
+ */
+function locale_load_source($conditions = array()) {
+  $strings = array();
+  $query = db_select('locales_source', 's');
+  $query->fields('s', array('lid', 'textgroup', 'source', 'location', 'version'));
+  _locale_query_conditions($query, $conditions);
+  $query->execute();
+  return $query->fetchAll();  
+}
+
+/**
+ * Delete one or more locale source records from the database. It will also delete
+ * associated target records.
+ *
+ * @param $conditions
+ *   An array field-value pairs or a single lid to match. Values may be arrays, in which case
+ *   matching will use the 'IN' operator.
+ */
+function locale_delete_strings($conditions = array()) {
+  // Accept one or an array of ID values.
+  if (is_numeric($conditions)) {
+    $conditions = array('lid' => $conditions);
+  }
+  // Remove all associated targets before so we can use IN (SELECT ...) conditions
+  locale_delete_translations($conditions);
+
+  // Build the query and execute
+  $query = db_delete('locales_source');
+  _locale_query_conditions($query, $conditions);
+
+  return $query->execute();
+}
+
+/**
+ * Delete one or more locale target records from the database.
+ *
+ * @param $conditions
+ *   An array field-value pairs to match. Values may be arrays, in which case
+ *   matching will use the 'IN' operator.
+ * @return
+ *   An array of all matching records, or false on failure.
+ */
+function locale_delete_translations($conditions) {
+  // Accept one or an array of ID values.
+  if (is_numeric($conditions)) {
+    $conditions = array('lid' => $conditions);
+  }
+  // Build the query
+  $query = db_delete('locales_target');
+  
+  // Check conditions they may be source or target conditions
+  $source_conditions = _locale_query_conditions($query, $conditions, array('lid', 'language', 'translation', 'plural'));
+
+  // If we have source conditions, build source subquery and add it to the main query
+  if (!empty($source_conditions)) {
+    $subquery = db_select('locales_source', 's');
+    $subquery->addField('s', 'lid');
+    _locale_query_conditions($subquery, $source_conditions);
+    $query->where('lid IN (' . $subquery . ')');
+  }
+
+  return $query->execute();
+}
+
+/**
+ * Add conditions to a query (single table) optionally checking fields
+ * 
+ */
+function _locale_query_conditions(&$query, $conditions, $allowed = array()) {
+  $excluded = array();
+  foreach ($conditions as $field => $value) {
+    if (!$allowed || in_array($field, $allowed)) {
+      is_array($value) ? $query->condition($field, $value, 'IN') : $query->condition($field, $value);
+    }
+    else {
+      $excluded[$field] = $value;
+    }
+  }
+  return $excluded;
+}
+
+/**
+ * @} End of "locale-api"
+ */
Index: includes/common.inc
===================================================================
RCS file: /cvs/drupal/drupal/includes/common.inc,v
retrieving revision 1.865
diff -u -r1.865 common.inc
--- includes/common.inc	9 Feb 2009 03:29:53 -0000	1.865
+++ includes/common.inc	9 Feb 2009 17:01:05 -0000
@@ -1085,17 +1085,14 @@
  * helper function.
  * @see st()
  * @see get_t()
- *
+ * 
+ * After string translation the arguments if any will be replaced
+ * @see drupal_replace_string()
+ * 
  * @param $string
  *   A string containing the English string to translate.
  * @param $args
- *   An associative array of replacements to make after translation. Incidences
- *   of any key in this array are replaced with the corresponding value. Based
- *   on the first character of the key, the value is escaped and/or themed:
- *    - !variable: inserted as is
- *    - @variable: escape plain text to HTML (check_plain)
- *    - %variable: escape text and theme as a placeholder for user-submitted
- *      content (check_plain + theme_placeholder)
+ *   An associative array of replacements to make after translation.
  * @param $langcode
  *   Optional language code to translate to a language other than what is used
  *   to display the page.
@@ -1123,32 +1120,51 @@
   }
   // Translate with locale module if enabled.
   elseif (function_exists('locale') && $langcode != 'en') {
-    $string = locale($string, $langcode);
-  }
-  if (empty($args)) {
-    return $string;
+    $string = locale_string($string, $langcode);
   }
-  else {
-    // Transform arguments before inserting them.
-    foreach ($args as $key => $value) {
-      switch ($key[0]) {
-        case '@':
-          // Escaped only.
-          $args[$key] = check_plain($value);
-          break;
-
-        case '%':
-        default:
-          // Escaped and placeholder.
-          $args[$key] = theme('placeholder', $value);
-          break;
+  
+  return drupal_replace_string($string, $args);
+}
 
-        case '!':
-          // Pass-through.
-      }
-    }
-    return strtr($string, $args);
+/**
+ * This will translate strings using textgroup and location context
+ * 
+ * When translating user defined strings we cannot rely on the source string to do the translation
+ * as it may change, maybe just to fix a typo and it would invalidate the translations.
+ * @see t()
+ * 
+ * The default language for these strings will be the site default language instead of being
+ * always English like for t()
+ * 
+ * After string translation the arguments if any will be replaced
+ * @see drupal_replace_string()
+ * 
+ * @param $textgroup
+ *   The locale text group this string belongs to
+ * @param $key
+ *   String id, unique inside the text group
+ * @param $string
+ *   A string containing the string in the default language to translate
+ * @param $args
+ *   An associative array of replacements to make after translation.
+ * @param $langcode
+ *   Optional language code to translate to a language other than what is used
+ *   to display the page.
+ * @return
+ *   The translated string.
+ */
+function tt($textgroup, $key, $string, $args = array(), $langcode = NULL) {
+  global $language;
+  
+  if (!isset($langcode)) {
+    $langcode = !empty($language->language) ? $language->language : 'en';
   }
+  
+  if (function_exists('locale_tt') && $langcode != language_default('language')) {
+    $string = locale_tt($textgroup, $key, $string, $langcode);
+  }
+  
+  return drupal_replace_string($string, $args); 
 }
 
 /**
@@ -3967,7 +3983,9 @@
       $object->$serial = $last_insert_id;
     }
   }
-  else {
+  // If we have a single-field primary key but got no insert ID, the
+  // query failed.
+  elseif (count($primary_keys) == 1) {
     $return = FALSE;
   }
 
@@ -4218,3 +4236,44 @@
   }
   variable_set('css_js_query_string', $new_character . substr($string_history, 0, 19));
 }
+
+/**
+ * Replace string with arguments
+ * 
+ * @param $string
+ *   A plain string containing to be replaced with args.
+ * @param $args
+ *   An associative array of replacements to make after translation. Incidences
+ *   of any key in this array are replaced with the corresponding value. Based
+ *   on the first character of the key, the value is escaped and/or themed:
+ *    - !variable: inserted as is
+ *    - @variable: escape plain text to HTML (check_plain)
+ *    - %variable: escape text and theme as a placeholder for user-submitted
+ *      content (check_plain + theme_placeholder)
+ */
+function drupal_replace_string($string, $args) {
+  if (empty($args)) {
+    return $string;
+  }
+  else {
+    // Transform arguments before inserting them.
+    foreach ($args as $key => $value) {
+      switch ($key[0]) {
+        case '@':
+          // Escaped only.
+          $args[$key] = check_plain($value);
+          break;
+
+        case '%':
+        default:
+          // Escaped and placeholder.
+          $args[$key] = theme('placeholder', $value);
+          break;
+
+        case '!':
+          // Pass-through.
+      }
+    }
+    return strtr($string, $args);
+  }  
+}
\ No newline at end of file
