Index: modules/field/field.api.php =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.api.php,v retrieving revision 1.27 diff -u -p -r1.27 field.api.php --- modules/field/field.api.php 19 Aug 2009 13:31:12 -0000 1.27 +++ modules/field/field.api.php 19 Aug 2009 20:01:59 -0000 @@ -350,6 +350,8 @@ function hook_field_schema($field) { * The field structure for the operation. * @param $instances * Array of instance structures for $field for each object, keyed by object id. + * @param $langcode + * The language associated to $items. * @param $items * Array of field values already loaded for the objects, keyed by object id. * @param $age @@ -359,9 +361,7 @@ function hook_field_schema($field) { * Changes or additions to field values are done by altering the $items * parameter by reference. */ -function hook_field_load($obj_type, $objects, $field, $instances, &$items, $age) { - global $language; - +function hook_field_load($obj_type, $objects, $field, $instances, $langcode, &$items, $age) { foreach ($objects as $id => $object) { foreach ($items[$id] as $delta => $item) { if (!empty($instances[$id]['settings']['text_processing'])) { @@ -369,10 +369,9 @@ function hook_field_load($obj_type, $obj // handled by hook_field_sanitize(). $format = $item['format']; if (filter_format_allowcache($format)) { - $lang = isset($object->language) ? $object->language : $language->language; - $items[$id][$delta]['safe'] = isset($item['value']) ? check_markup($item['value'], $format, $lang, FALSE, FALSE) : ''; + $items[$id][$delta]['safe'] = isset($item['value']) ? check_markup($item['value'], $format, $langcode, FALSE, FALSE) : ''; if ($field['type'] == 'text_with_summary') { - $items[$id][$delta]['safe_summary'] = isset($item['summary']) ? check_markup($item['summary'], $format, $lang, FALSE, FALSE) : ''; + $items[$id][$delta]['safe_summary'] = isset($item['summary']) ? check_markup($item['summary'], $format, $langcode, FALSE, FALSE) : ''; } } } @@ -401,11 +400,12 @@ function hook_field_load($obj_type, $obj * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items * $object->{$field['field_name']}, or an empty array if unset. */ -function hook_field_sanitize($obj_type, $object, $field, $instance, $items) { - global $language; +function hook_field_sanitize($obj_type, $object, $field, $instance, $langcode, &$items) { foreach ($items as $delta => $item) { // Only sanitize items which were not already processed inside // hook_field_load(), i.e. items with uncacheable text formats, or coming @@ -413,10 +413,9 @@ function hook_field_sanitize($obj_type, if (!isset($items[$delta]['safe'])) { if (!empty($instance['settings']['text_processing'])) { $format = $item['format']; - $lang = isset($object->language) ? $object->language : $language->language; - $items[$delta]['safe'] = isset($item['value']) ? check_markup($item['value'], $format, $lang, FALSE) : ''; + $items[$delta]['safe'] = isset($item['value']) ? check_markup($item['value'], $format, $langcode, FALSE) : ''; if ($field['type'] == 'text_with_summary') { - $items[$delta]['safe_summary'] = isset($item['summary']) ? check_markup($item['summary'], $format, $lang, FALSE) : ''; + $items[$delta]['safe_summary'] = isset($item['summary']) ? check_markup($item['summary'], $format, $langcode, FALSE) : ''; } } else { @@ -444,8 +443,10 @@ function hook_field_sanitize($obj_type, * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items - * $object->{$field['field_name']}, or an empty array if unset. + * $object->{$field['field_name']}[$langcode], or an empty array if unset. * @param $errors * The array of errors, keyed by field name and by value delta, that have * already been reported for the object. The function should add its errors @@ -454,7 +455,7 @@ function hook_field_sanitize($obj_type, * - 'error': an error code (should be a string, prefixed with the module name) * - 'message': the human readable message to be displayed. */ -function hook_field_validate($obj_type, $object, $field, $instance, $items, &$errors) { +function hook_field_validate($obj_type, $object, $field, $instance, $langcode, &$items, &$errors) { foreach ($items as $delta => $item) { if (!empty($item['value'])) { if (!empty($field['settings']['max_length']) && drupal_strlen($item['value']) > $field['settings']['max_length']) { @@ -478,10 +479,12 @@ function hook_field_validate($obj_type, * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items - * $object->{$field['field_name']}, or an empty array if unset. + * $object->{$field['field_name']}[$langcode], or an empty array if unset. */ -function hook_field_presave($obj_type, $object, $field, $instance, $items) { +function hook_field_presave($obj_type, $object, $field, $instance, $langcode, &$items) { } /** @@ -495,10 +498,12 @@ function hook_field_presave($obj_type, $ * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items - * $object->{$field['field_name']}, or an empty array if unset. + * $object->{$field['field_name']}[$langcode], or an empty array if unset. */ -function hook_field_insert($obj_type, $object, $field, $instance, $items) { +function hook_field_insert($obj_type, $object, $field, $instance, $langcode, &$items) { } /** @@ -512,10 +517,12 @@ function hook_field_insert($obj_type, $o * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items - * $object->{$field['field_name']}, or an empty array if unset. + * $object->{$field['field_name']}[$langcode], or an empty array if unset. */ -function hook_field_update($obj_type, $object, $field, $instance, $items) { +function hook_field_update($obj_type, $object, $field, $instance, $langcode, &$items) { } /** @@ -531,10 +538,12 @@ function hook_field_update($obj_type, $o * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items - * $object->{$field['field_name']}, or an empty array if unset. + * $object->{$field['field_name']}[$langcode], or an empty array if unset. */ -function hook_field_delete($obj_type, $object, $field, $instance, $items) { +function hook_field_delete($obj_type, $object, $field, $instance, $langcode, &$items) { } /** @@ -551,10 +560,12 @@ function hook_field_delete($obj_type, $o * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items - * $object->{$field['field_name']}, or an empty array if unset. + * $object->{$field['field_name']}[$langcode], or an empty array if unset. */ -function hook_field_delete_revision($obj_type, $object, $field, $instance, $items) { +function hook_field_delete_revision($obj_type, $object, $field, $instance, $langcode, &$items) { } /** @@ -570,10 +581,12 @@ function hook_field_delete_revision($obj * The field structure for the operation. * @param $instance * The instance structure for $field on $object's bundle. + * @param $langcode + * The language associated to $items. * @param $items - * $object->{$field['field_name']}, or an empty array if unset. + * $object->{$field['field_name']}[$langcode], or an empty array if unset. */ -function hook_field_prepare_translation($obj_type, $object, $field, $instance, $items) { +function hook_field_prepare_translation($obj_type, $object, $field, $instance, $langcode, &$items) { } /** @@ -902,7 +915,7 @@ function theme_field_formatter_FORMATTER * * See field_attach_form() for details and arguments. */ -function hook_field_attach_form($obj_type, $object, &$form, &$form_state) { +function hook_field_attach_form($obj_type, $object, &$form, &$form_state, $langcode) { } /** @@ -988,6 +1001,23 @@ function hook_field_attach_presave($obj_ } /** + * Act on field_attach_preprocess. + * + * This hook is invoked while preprocessing the field.tpl.php template file. + * + * @param $variables + * The variables array is passed by reference and will be populated with field values. + * @param $obj_type + * The type of $object; e.g. 'node' or 'user'. + * @param $object + * The object with fields to render. + * @param $element + * The structured array containing the values ready for rendering. + */ +function hook_field_attach_preprocess_alter(&$variables, $obj_type, $object, $element) { +} + +/** * Act on field_attach_insert. * * This hook allows modules to store data before the Field Storage @@ -1094,8 +1124,10 @@ function hook_field_attach_delete_revisi * The object with fields to render. * @param $build_mode * Build mode, e.g. 'full', 'teaser'... + * @param $langcode + * The language in which the field values will be displayed. */ -function hook_field_attach_view_alter($output, $obj_type, $object, $build_mode) { +function hook_field_attach_view_alter($output, $obj_type, $object, $build_mode, $langcode) { } /** Index: modules/field/field.attach.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.attach.inc,v retrieving revision 1.37 diff -u -p -r1.37 field.attach.inc --- modules/field/field.attach.inc 19 Aug 2009 13:31:12 -0000 1.37 +++ modules/field/field.attach.inc 19 Aug 2009 20:04:01 -0000 @@ -176,6 +176,7 @@ function _field_invoke($op, $obj_type, $ $default_options = array( 'default' => FALSE, 'deleted' => FALSE, + 'language' => NULL, ); $options += $default_options; @@ -196,32 +197,38 @@ function _field_invoke($op, $obj_type, $ // When in 'single field' mode, only act on the specified field. if ((!isset($options['field_id']) || $options['field_id'] == $instance['field_id']) && (!isset($options['field_name']) || $options['field_name'] == $field_name)) { $field = field_info_field($field_name); + $field_translations = array(); + $suggested_languages = empty($options['language']) ? NULL : array($options['language']); - // Extract the field values into a separate variable, easily accessed by - // hook implementations. - $items = isset($object->$field_name) ? $object->$field_name : array(); + // Initialize field translations according to the available languages. + foreach (field_multilingual_available_languages($obj_type, $field, $suggested_languages) as $langcode) { + $field_translations[$langcode] = isset($object->{$field_name}[$langcode]) ? $object->{$field_name}[$langcode] : array(); + } // Invoke the field hook and collect results. $function = $options['default'] ? 'field_default_' . $op : $field['module'] . '_field_' . $op; if (drupal_function_exists($function)) { - $result = $function($obj_type, $object, $field, $instance, $items, $a, $b); - if (isset($result)) { - // For hooks with array results, we merge results together. - // For hooks with scalar results, we collect results in an array. - if (is_array($result)) { - $return = array_merge($return, $result); + // Iterate over all the field translations. + foreach ($field_translations as $langcode => $items) { + $result = $function($obj_type, $object, $field, $instance, $langcode, $items, $a, $b); + if (isset($result)) { + // For hooks with array results, we merge results together. + // For hooks with scalar results, we collect results in an array. + if (is_array($result)) { + $return = array_merge($return, $result); + } + else { + $return[] = $result; + } } - else { - $return[] = $result; + + // Populate $items back in the field values, but avoid replacing missing + // fields with an empty array (those are not equivalent on update). + if ($items !== array() || isset($object->{$field_name}[$langcode])) { + $object->{$field_name}[$langcode] = $items; } } } - - // Populate field values back in the object, but avoid replacing missing - // fields with an empty array (those are not equivalent on update). - if ($items !== array() || property_exists($object, $field_name)) { - $object->$field_name = $items; - } } } @@ -273,6 +280,7 @@ function _field_invoke_multiple($op, $ob $default_options = array( 'default' => FALSE, 'deleted' => FALSE, + 'language' => NULL, ); $options += $default_options; @@ -314,7 +322,10 @@ function _field_invoke_multiple($op, $ob $grouped_objects[$field_id][$id] = $objects[$id]; // Extract the field values into a separate variable, easily accessed // by hook implementations. - $grouped_items[$field_id][$id] = isset($object->$field_name) ? $object->$field_name : array(); + $suggested_languages = empty($options['language']) ? NULL : array($options['language']); + foreach (field_multilingual_available_languages($obj_type, $fields[$field_id], $suggested_languages) as $langcode) { + $grouped_items[$field_id][$langcode][$id] = isset($object->{$field_name}[$langcode]) ? $object->{$field_name}[$langcode] : array(); + } } } // Initialize the return value for each object. @@ -326,17 +337,20 @@ function _field_invoke_multiple($op, $ob $field_name = $field['field_name']; $function = $options['default'] ? 'field_default_' . $op : $field['module'] . '_field_' . $op; if (drupal_function_exists($function)) { - $results = $function($obj_type, $grouped_objects[$field_id], $field, $grouped_instances[$field_id], $grouped_items[$field_id], $options, $a, $b); - if (isset($results)) { - // Collect results by object. - // For hooks with array results, we merge results together. - // For hooks with scalar results, we collect results in an array. - foreach ($results as $id => $result) { - if (is_array($result)) { - $return[$id] = array_merge($return[$id], $result); - } - else { - $return[$id][] = $result; + // Iterate over all the field translations. + foreach ($grouped_items[$field_id] as $langcode => $items) { + $results = $function($obj_type, $grouped_objects[$field_id], $field, $grouped_instances[$field_id], $langcode, $grouped_items[$field_id][$langcode], $options, $a, $b); + if (isset($results)) { + // Collect results by object. + // For hooks with array results, we merge results together. + // For hooks with scalar results, we collect results in an array. + foreach ($results as $id => $result) { + if (is_array($result)) { + $return[$id] = array_merge($return[$id], $result); + } + else { + $return[$id][] = $result; + } } } } @@ -345,8 +359,10 @@ function _field_invoke_multiple($op, $ob // Populate field values back in the objects, but avoid replacing missing // fields with an empty array (those are not equivalent on update). foreach ($grouped_objects[$field_id] as $id => $object) { - if ($grouped_items[$field_id][$id] !== array() || property_exists($object, $field_name)) { - $object->$field_name = $grouped_items[$field_id][$id]; + foreach ($grouped_items[$field_id] as $langcode => $items) { + if ($grouped_items[$field_id][$langcode][$id] !== array() || isset($object->{$field_name}[$langcode])) { + $object->{$field_name}[$langcode] = $grouped_items[$field_id][$langcode][$id]; + } } } } @@ -394,6 +410,9 @@ function _field_invoke_multiple_default( * The form structure to fill in. * @param $form_state * An associative array containing the current state of the form. + * @param $langcode + * The language the field values are going to be entered, if no language + * is provided the default site language will be used. * @return * The form elements are added by reference at the top level of the $form * parameter. Sample structure: @@ -417,45 +436,57 @@ function _field_invoke_multiple_default( * // most common case), and will therefore be repeated as many times as * // needed, or 'multiple-values' (one single widget allows the input of * // several values, e.g checkboxes, select box...). + * // The sub-array is nested into a $langcode key where $langcode has the + * // same value of the $langcode parameter above. This allow us to match + * // the field data structure ($field_name[$langcode][$delta][$column]). + * // The '#language' key holds the same value of $langcode and it is used + * // to access the field sub-array when $langcode is unknown. * 'field_foo' => array( - * '#field_name' => the name of the field, * '#tree' => TRUE, - * '#required' => whether or not the field is required, - * '#title' => the label of the field instance, - * '#description' => the description text for the field instance, - * - * // Only for 'single' widgets: - * '#theme' => 'field_multiple_value_form', - * '#multiple' => the field cardinality, - * // One sub-array per copy of the widget, keyed by delta. - * 0 => array( - * '#title' => the title to be displayed by the widget, - * '#default_value' => the field value for delta 0, - * '#required' => whether the widget should be marked required, - * '#delta' => 0, + * '#language' => $langcode, + * $langcode => array( * '#field_name' => the name of the field, - * '#bundle' => the name of the bundle, - * '#columns' => the array of field columns, + * '#tree' => TRUE, + * '#required' => whether or not the field is required, + * '#title' => the label of the field instance, + * '#description' => the description text for the field instance, + * + * // Only for 'single' widgets: + * '#theme' => 'field_multiple_value_form', + * '#multiple' => the field cardinality, + * // One sub-array per copy of the widget, keyed by delta. + * 0 => array( + * '#title' => the title to be displayed by the widget, + * '#default_value' => the field value for delta 0, + * '#required' => whether the widget should be marked required, + * '#delta' => 0, + * '#field_name' => the name of the field, + * '#bundle' => the name of the bundle, + * '#columns' => the array of field columns, + * // The remaining elements in the sub-array depend on the widget. + * '#type' => the type of the widget, + * ... + * ), + * 1 => array( + * ... + * ), + * + * // Only for multiple widgets: + * '#bundle' => $instance['bundle'], + * '#columns' => array_keys($field['columns']), * // The remaining elements in the sub-array depend on the widget. * '#type' => the type of the widget, * ... * ), - * 1 => array( - * ... - * ), - * - * // Only for multiple widgets: - * '#bundle' => $instance['bundle'], - * '#columns' => array_keys($field['columns']), - * // The remaining elements in the sub-array depend on the widget. - * '#type' => the type of the widget, * ... * ), * ) * @endcode */ -function field_attach_form($obj_type, $object, &$form, &$form_state) { - $form += (array) _field_invoke_default('form', $obj_type, $object, $form, $form_state); +function field_attach_form($obj_type, $object, &$form, &$form_state, $langcode = NULL) { + // If no language is provided use the default site language. + $options = array('language' => field_multilingual_valid_language($langcode)); + $form += (array) _field_invoke_default('form', $obj_type, $object, $form, $form_state, $options); // Add custom weight handling. list($id, $vid, $bundle) = field_attach_extract_ids($obj_type, $object); @@ -991,6 +1022,9 @@ function field_attach_query_revisions($f * The object with fields to render. * @param $build_mode * Build mode, e.g. 'full', 'teaser'... + * @param $langcode + * The language the field values are to be shown in. If no language is + * provided the current language is used. * @return * A structured content array tree for drupal_render(). * Sample structure: @@ -1042,11 +1076,15 @@ function field_attach_query_revisions($f * ); * @endcode */ -function field_attach_view($obj_type, $object, $build_mode = 'full') { +function field_attach_view($obj_type, $object, $build_mode = 'full', $langcode = NULL) { + // If no language is provided use the current UI language. + $options = array('language' => field_multilingual_valid_language($langcode, FALSE)); + // Let field modules sanitize their data for output. - _field_invoke('sanitize', $obj_type, $object); + $null = NULL; + _field_invoke('sanitize', $obj_type, $object, $null, $null, $options); - $output = _field_invoke_default('view', $obj_type, $object, $build_mode); + $output = _field_invoke_default('view', $obj_type, $object, $build_mode, $null, $options); // Add custom weight handling. list($id, $vid, $bundle) = field_attach_extract_ids($obj_type, $object); @@ -1057,7 +1095,37 @@ function field_attach_view($obj_type, $o drupal_alter('field_attach_view', $output, $obj_type, $object, $build_mode); return $output; +} + +/** + * Populate the template variables with the field values available for rendering. + * + * The $variables array will be populated with all the field instance values associated + * with the given entity type, keyed by field name; in case of translatable fields the + * language currently chosen for display will be selected. + * + * @param $obj_type + * The type of $object; e.g. 'node' or 'user'. + * @param $object + * The object with fields to render. + * @param $element + * The structured array containing the values ready for rendering. + * @param $variables + * The variables array is passed by reference and will be populated with field values. + */ +function field_attach_preprocess($obj_type, $object, $element, &$variables) { + list(, , $bundle) = field_attach_extract_ids($obj_type, $object); + + foreach (field_info_instances($bundle) as $instance) { + $field_name = $instance['field_name']; + if (isset($element[$field_name]['#language'])) { + $langcode = $element[$field_name]['#language']; + $variables[$field_name] = isset($object->{$field_name}[$langcode]) ? $object->{$field_name}[$langcode] : NULL; + } + } + // Let other modules make changes to the $variables array. + drupal_alter('field_attach_preprocess', $variables, $obj_type, $object, $element); } /** Index: modules/field/field.crud.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.crud.inc,v retrieving revision 1.26 diff -u -p -r1.26 field.crud.inc --- modules/field/field.crud.inc 19 Aug 2009 13:31:12 -0000 1.26 +++ modules/field/field.crud.inc 19 Aug 2009 20:01:24 -0000 @@ -46,6 +46,8 @@ * - cardinality (integer) * The number of values the field can hold. Legal values are any * positive integer or FIELD_CARDINALITY_UNLIMITED. + * - translatable (integer) + * Whether the field is translatable. * - locked (integer) * TODO: undefined. * - module (string, read-only) @@ -237,6 +239,7 @@ function field_create_field($field) { $field += array( 'cardinality' => 1, + 'translatable' => FALSE, 'locked' => FALSE, 'settings' => array(), ); Index: modules/field/field.default.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.default.inc,v retrieving revision 1.15 diff -u -p -r1.15 field.default.inc --- modules/field/field.default.inc 19 Aug 2009 13:31:12 -0000 1.15 +++ modules/field/field.default.inc 19 Aug 2009 20:04:47 -0000 @@ -11,17 +11,17 @@ * the corresponding field_attach_[operation]() function. */ -function field_default_extract_form_values($obj_type, $object, $field, $instance, &$items, $form, &$form_state) { +function field_default_extract_form_values($obj_type, $object, $field, $instance, $langcode, &$items, $form, &$form_state) { $field_name = $field['field_name']; - if (isset($form_state['values'][$field_name])) { - $items = $form_state['values'][$field_name]; + if (isset($form_state['values'][$field_name][$langcode])) { + $items = $form_state['values'][$field_name][$langcode]; // Remove the 'value' of the 'add more' button. unset($items[$field_name . '_add_more']); } } -function field_default_submit($obj_type, $object, $field, $instance, &$items, $form, &$form_state) { +function field_default_submit($obj_type, $object, $field, $instance, $langcode, &$items, $form, &$form_state) { $field_name = $field['field_name']; // Reorder items to account for drag-n-drop reordering. @@ -40,19 +40,25 @@ function field_default_submit($obj_type, * This can happen with programmatic saves, or on form-based creation where * the current user doesn't have 'edit' permission for the field. */ -function field_default_insert($obj_type, $object, $field, $instance, &$items) { +function field_default_insert($obj_type, $object, $field, $instance, $langcode, &$items) { // _field_invoke() populates $items with an empty array if the $object has no // entry for the field, so we check on the $object itself. - if (empty($object) || !property_exists($object, $field['field_name'])) { - $items = field_get_default_value($obj_type, $object, $field, $instance); + // We also check that the current field translation is actually defined before + // assigning it a default value. This way we ensure that only the intended + // languages get a default value. Otherwise we could have default values for + // not yet open languages. + if (empty($object) || !property_exists($object, $field['field_name']) || + (isset($object->{$field['field_name']}[$langcode]) && count($object->{$field['field_name']}[$langcode]) == 0)) { + $items = field_get_default_value($obj_type, $object, $field, $instance, $langcode); } } + /** * Default field 'view' operation. * * @see field_attach_view() */ -function field_default_view($obj_type, $object, $field, $instance, $items, $build_mode) { +function field_default_view($obj_type, $object, $field, $instance, $langcode, $items, $build_mode) { list($id, $vid, $bundle) = field_attach_extract_ids($obj_type, $object); $addition = array(); @@ -82,6 +88,7 @@ function field_default_view($obj_type, $ '#label_display' => $label_display, '#build_mode' => $build_mode, '#single' => $single, + '#language' => $langcode, 'items' => array(), ); @@ -117,7 +124,7 @@ function field_default_view($obj_type, $ return $addition; } -function field_default_prepare_translation($obj_type, $object, $field, $instance, &$items) { +function field_default_prepare_translation($obj_type, $object, $field, $instance, $langcode, &$items) { $addition = array(); if (isset($object->translation_source->$field['field_name'])) { $addition[$field['field_name']] = $object->translation_source->$field['field_name']; Index: modules/field/field.form.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.form.inc,v retrieving revision 1.15 diff -u -p -r1.15 field.form.inc --- modules/field/field.form.inc 19 Aug 2009 13:31:12 -0000 1.15 +++ modules/field/field.form.inc 19 Aug 2009 20:01:25 -0000 @@ -9,7 +9,7 @@ /** * Create a separate form element for each field. */ -function field_default_form($obj_type, $object, $field, $instance, $items, &$form, &$form_state, $get_delta = NULL) { +function field_default_form($obj_type, $object, $field, $instance, $langcode, $items, &$form, &$form_state, $get_delta = NULL) { // This could be called with no object, as when a UI module creates a // dummy form to set default values. if ($object) { @@ -48,7 +48,7 @@ function field_default_form($obj_type, $ // and we are displaying an individual element, process the multiple value // form. if (!isset($get_delta) && field_behaviors_widget('multiple values', $instance) == FIELD_BEHAVIOR_DEFAULT) { - $form_element = field_multiple_value_form($field, $instance, $items, $form, $form_state); + $form_element = field_multiple_value_form($field, $instance, $langcode, $items, $form, $form_state); } // If the widget is handling multiple values (e.g Options), // or if we are displaying an individual element, just get a single form @@ -89,7 +89,21 @@ function field_default_form($obj_type, $ '#weight' => $instance['widget']['weight'], ); - $addition[$field['field_name']] = array_merge($form_element, $defaults); + $form_element = array_merge($form_element, $defaults); + + // Add the field form element as a child keyed by language code. + // This allow us to match the field data structure: + // $object->{$field_name}[$langcode][$delta][$column]. + // The '#language' key can be used to access the field form element + // when $langcode is unknown. + // The #weight property is inherited from the form field element. + $addition[$field['field_name']] = array( + '#tree' => TRUE, + '#weight' => $form_element['#weight'], + '#language' => $langcode, + $langcode => $form_element, + ); + $form['#fields'][$field['field_name']]['form_path'] = array($field['field_name']); } @@ -104,7 +118,7 @@ function field_default_form($obj_type, $ * - AHAH-'add more' button * - drag-n-drop value reordering */ -function field_multiple_value_form($field, $instance, $items, &$form, &$form_state) { +function field_multiple_value_form($field, $instance, $langcode, $items, &$form, &$form_state) { $field = field_info_field($instance['field_name']); $field_name = $field['field_name']; @@ -197,9 +211,11 @@ function field_multiple_value_form($fiel '#field_name' => $field_name, '#bundle' => $instance['bundle'], '#attributes' => array('class' => 'field-add-more-submit'), + '#language' => $langcode, ); } } + return $form_element; } @@ -276,9 +292,9 @@ function theme_field_multiple_value_form /** * Transfer field-level validation errors to widgets. */ -function field_default_form_errors($obj_type, $object, $field, $instance, $items, $form, $errors) { +function field_default_form_errors($obj_type, $object, $field, $instance, $langcode, $items, $form, $errors) { $field_name = $field['field_name']; - if (!empty($errors[$field_name])) { + if (!empty($errors[$field_name][$langcode])) { $function = $instance['widget']['module'] . '_field_widget_error'; $function_exists = drupal_function_exists($function); @@ -290,10 +306,10 @@ function field_default_form_errors($obj_ } $multiple_widget = field_behaviors_widget('multiple values', $instance) != FIELD_BEHAVIOR_DEFAULT; - foreach ($errors[$field_name] as $delta => $delta_errors) { + foreach ($errors[$field_name][$langcode] as $delta => $delta_errors) { // For multiple single-value widgets, pass errors by delta. // For a multiple-value widget, all errors are passed to the main widget. - $error_element = $multiple_widget ? $element : $element[$delta]; + $error_element = $multiple_widget ? $element[$langcode] : $element[$langcode][$delta]; foreach ($delta_errors as $error) { if ($function_exists) { $function($error_element, $error); @@ -320,8 +336,9 @@ function field_add_more_submit($form, &$ // Make the changes we want to the form state. $field_name = $form_state['clicked_button']['#field_name']; + $langcode = $form_state['clicked_button']['#language']; if ($form_state['values'][$field_name . '_add_more']) { - $form_state['field_item_count'][$field_name] = count($form_state['values'][$field_name]); + $form_state['field_item_count'][$field_name] = count($form_state['values'][$field_name][$langcode]); } } } @@ -353,7 +370,7 @@ function field_add_more_js($bundle_name, $instance = $form['#fields'][$field_name]['instance']; $form_path = $form['#fields'][$field_name]['form_path']; if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED) { - // Ivnalid + // Invalid $invalid = TRUE; } @@ -383,19 +400,25 @@ function field_add_more_js($bundle_name, // Reset cached ids, so that they don't affect the actual form we output. drupal_static_reset('form_clean_id'); + // Ensure that a valid language is provided. + $langcode = key($_POST[$field_name]); + if ($langcode != FIELD_LANGUAGE_NONE) { + $langcode = field_multilingual_valid_language($langcode); + } + // Sort the $form_state['values'] we just built *and* the incoming $_POST data // according to d-n-d reordering. - unset($form_state['values'][$field_name][$field['field_name'] . '_add_more']); - foreach ($_POST[$field_name] as $delta => $item) { - $form_state['values'][$field_name][$delta]['_weight'] = $item['_weight']; + unset($form_state['values'][$field_name][$langcode][$field['field_name'] . '_add_more']); + foreach ($_POST[$field_name][$langcode] as $delta => $item) { + $form_state['values'][$field_name][$langcode][$delta]['_weight'] = $item['_weight']; } - $form_state['values'][$field_name] = _field_sort_items($field, $form_state['values'][$field_name]); - $_POST[$field_name] = _field_sort_items($field, $_POST[$field_name]); + $form_state['values'][$field_name][$langcode] = _field_sort_items($field, $form_state['values'][$field_name][$langcode]); + $_POST[$field_name][$langcode] = _field_sort_items($field, $_POST[$field_name][$langcode]); // Build our new form element for the whole field, asking for one more element. - $form_state['field_item_count'] = array($field_name => count($_POST[$field_name]) + 1); - $items = $form_state['values'][$field_name]; - $form_element = field_default_form(NULL, NULL, $field, $instance, $items, $form, $form_state); + $form_state['field_item_count'] = array($field_name => count($_POST[$field_name][$langcode]) + 1); + $items = $form_state['values'][$field_name][$langcode]; + $form_element = field_default_form(NULL, NULL, $field, $instance, $langcode, $items, $form, $form_state); // Let other modules alter it. drupal_alter('form', $form_element, array(), 'field_add_more_js'); @@ -412,8 +435,8 @@ function field_add_more_js($bundle_name, // Build the new form against the incoming $_POST values so that we can // render the new element. - $delta = max(array_keys($_POST[$field_name])) + 1; - $_POST[$field_name][$delta]['_weight'] = $delta; + $delta = max(array_keys($_POST[$field_name][$langcode])) + 1; + $_POST[$field_name][$langcode][$delta]['_weight'] = $delta; $form_state = form_state_defaults(); $form_state['input'] = $_POST; $form = form_builder($_POST['form_id'], $form, $form_state); Index: modules/field/field.info =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.info,v retrieving revision 1.4 diff -u -p -r1.4 field.info --- modules/field/field.info 8 Jun 2009 09:23:51 -0000 1.4 +++ modules/field/field.info 19 Aug 2009 20:01:25 -0000 @@ -9,6 +9,7 @@ files[] = field.install files[] = field.crud.inc files[] = field.info.inc files[] = field.default.inc +files[] = field.multilingual.inc files[] = field.attach.inc files[] = field.form.inc files[] = field.test Index: modules/field/field.info.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.info.inc,v retrieving revision 1.13 diff -u -p -r1.13 field.info.inc --- modules/field/field.info.inc 19 Aug 2009 13:31:12 -0000 1.13 +++ modules/field/field.info.inc 19 Aug 2009 20:01:25 -0000 @@ -130,6 +130,7 @@ function _field_info_collate_types($rese // Provide defaults. $fieldable_info += array( 'cacheable' => TRUE, + 'translation_handlers' => array(), 'bundles' => array(), ); $fieldable_info['object keys'] += array( Index: modules/field/field.install =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.install,v retrieving revision 1.11 diff -u -p -r1.11 field.install --- modules/field/field.install 13 Aug 2009 01:50:00 -0000 1.11 +++ modules/field/field.install 19 Aug 2009 20:01:25 -0000 @@ -63,6 +63,12 @@ function field_schema() { 'not null' => TRUE, 'default' => 0, ), + 'translatable' => array( + 'type' => 'int', + 'size' => 'tiny', + 'not null' => TRUE, + 'default' => 0, + ), 'active' => array( 'type' => 'int', 'size' => 'tiny', Index: modules/field/field.module =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.module,v retrieving revision 1.23 diff -u -p -r1.23 field.module --- modules/field/field.module 19 Aug 2009 13:31:12 -0000 1.23 +++ modules/field/field.module 19 Aug 2009 20:06:14 -0000 @@ -12,6 +12,7 @@ */ require(DRUPAL_ROOT . '/modules/field/field.crud.inc'); require(DRUPAL_ROOT . '/modules/field/field.info.inc'); +require(DRUPAL_ROOT . '/modules/field/field.multilingual.inc'); require(DRUPAL_ROOT . '/modules/field/field.attach.inc'); /** @@ -68,6 +69,13 @@ require(DRUPAL_ROOT . '/modules/field/fi define('FIELD_CARDINALITY_UNLIMITED', -1); /** + * The language code assigned to untranslatable fields. + * + * Defined by ISO639-2 for "No linguistic content / Not applicable". + */ +define('FIELD_LANGUAGE_NONE', 'zxx'); + +/** * TODO */ define('FIELD_BEHAVIOR_NONE', 0x0001); @@ -276,13 +284,15 @@ function field_associate_fields($module) * The field structure. * @param $instance * The instance structure. + * @param $langcode + * The field language to fill-in with the default value. */ -function field_get_default_value($obj_type, $object, $field, $instance) { +function field_get_default_value($obj_type, $object, $field, $instance, $langcode = NULL) { $items = array(); if (!empty($instance['default_value_function'])) { $function = $instance['default_value_function']; if (drupal_function_exists($function)) { - $items = $function($obj_type, $object, $field, $instance); + $items = $function($obj_type, $object, $field, $instance, $langcode); } } elseif (!empty($instance['default_value'])) { @@ -701,6 +711,8 @@ function template_preprocess_field(&$var 'label' => check_plain(t($instance['label'])), 'label_display' => $element['#label_display'], 'field_empty' => $field_empty, + 'field_language' => $element['#language'], + 'field_translatable' => $field['translatable'], 'template_files' => array( 'field', 'field-' . $element['#field_name'], Index: modules/field/field.multilingual.inc =================================================================== RCS file: modules/field/field.multilingual.inc diff -N modules/field/field.multilingual.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/field/field.multilingual.inc 19 Aug 2009 21:07:01 -0000 @@ -0,0 +1,123 @@ + NULL)); +} + + +/** + * Check if a module is registered as a translation handler for a given entity. + * + * @param $obj_type + * The type of the entity whose fields are to be translated. + * @param $handler + * The name of the handler to be checked. + * @return + * TRUE, if the handler is allowed to manage field translations. + */ +function field_multilingual_check_translation_handler($obj_type, $handler) { + $obj_info = field_info_fieldable_types($obj_type); + return isset($obj_info['translation_handlers'][$handler]); +} + +/** + * Helper function to ensure that a given language code is valid. + * + * Checks whether the given language is one of the enabled languages. Otherwise, + * it returns the current, global language; or the site's default language, if + * the additional parameter $default is TRUE. + * + * @param $langcode + * The language code to validate. + * @param $default + * Whether to return the default language code or the current language code in + * case $langcode is invalid. + * @return + * A valid language code. + */ +function field_multilingual_valid_language($langcode, $default = TRUE) { + $enabled_languages = field_multilingual_content_languages(); + if (in_array($langcode, $enabled_languages)) { + return $langcode; + } + // @todo Currently, node language neutral code is an empty string. Node passes + // $node->language as language parameter to field_attach_form(). We might + // want to unify the two "language neutral" language codes. + if ($langcode === '') { + return FIELD_LANGUAGE_NONE; + } + global $language; + $langcode = $default ? language_default('language') : $language->language; + if (in_array($langcode, $enabled_languages)) { + return $langcode; + } + // @todo Throw a more specific exception. + throw new FieldException('No valid content language could be found.'); +} Index: modules/field/field.test =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.test,v retrieving revision 1.41 diff -u -p -r1.41 field.test --- modules/field/field.test 17 Aug 2009 07:12:16 -0000 1.41 +++ modules/field/field.test 19 Aug 2009 20:30:07 -0000 @@ -525,32 +532,45 @@ class FieldAttachTestCase extends Drupal // TODO: // - check display order with several fields + + // Preprocess template. + $variables = array(); + field_attach_preprocess($entity_type, $entity, $entity->content, $variables); + $result = TRUE; + foreach ($values as $delta => $item) { + if ($variables[$this->field_name][$delta]['value'] !== $item['value']) { + $result = FALSE; + break; + } + } + $this->assertTrue($result, t('Variable $@field_name correctly populated.', array('@field_name' => $this->field_name))); } function testFieldAttachDelete() { $entity_type = 'test_entity'; + $langcode = FIELD_LANGUAGE_NONE; $rev[0] = field_test_create_stub_entity(0, 0, $this->instance['bundle']); // Create revision 0 $values = $this->_generateTestFieldValues($this->field['cardinality']); - $rev[0]->{$this->field_name} = $values; + $rev[0]->{$this->field_name}[$langcode] = $values; field_attach_insert($entity_type, $rev[0]); // Create revision 1 $rev[1] = field_test_create_stub_entity(0, 1, $this->instance['bundle']); - $rev[1]->{$this->field_name} = $values; + $rev[1]->{$this->field_name}[$langcode] = $values; field_attach_update($entity_type, $rev[1]); // Create revision 2 $rev[2] = field_test_create_stub_entity(0, 2, $this->instance['bundle']); - $rev[2]->{$this->field_name} = $values; + $rev[2]->{$this->field_name}[$langcode] = $values; field_attach_update($entity_type, $rev[2]); // Confirm each revision loads foreach (array_keys($rev) as $vid) { $read = field_test_create_stub_entity(0, $vid, $this->instance['bundle']); field_attach_load_revision($entity_type, array(0 => $read)); - $this->assertEqual(count($read->{$this->field_name}), $this->field['cardinality'], "The test object revision $vid has {$this->field['cardinality']} values."); + $this->assertEqual(count($read->{$this->field_name}[$langcode]), $this->field['cardinality'], "The test object revision $vid has {$this->field['cardinality']} values."); } // Delete revision 1, confirm the other two still load. @@ -1800,6 +1831,232 @@ class FieldInstanceCrudTestCase extends } /** + * Unit test class for the multilanguage fields logic. + * + * The following tests will check the multilanguage logic of _field_invoke() and + * that only the correct values are returned by + * field_multilingual_available_languages(). + */ +class FieldTranslationsTestCase extends DrupalWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Field translations tests', + 'description' => 'Test multilanguage fields logic.', + 'group' => 'Field', + ); + } + + function setUp() { + parent::setUp('locale', 'field_test'); + + $this->field_name = drupal_strtolower($this->randomName() . '_field_name'); + + $this->obj_type = 'test_entity'; + + $this->field = array( + 'field_name' => $this->field_name, + 'type' => 'test_field', + 'cardinality' => 4, + 'translatable' => TRUE, + 'settings' => array( + 'test_hook_in' => FALSE, + ), + ); + field_create_field($this->field); + + $this->instance = array( + 'field_name' => $this->field_name, + 'bundle' => 'test_bundle', + 'label' => $this->randomName() . '_label', + 'description' => $this->randomName() . '_description', + 'weight' => mt_rand(0, 127), + 'settings' => array( + 'test_instance_setting' => $this->randomName(), + ), + 'widget' => array( + 'type' => 'test_field_widget', + 'label' => 'Test Field', + 'settings' => array( + 'test_widget_setting' => $this->randomName(), + ), + ), + ); + field_create_instance($this->instance); + + for ($i = 0; $i < 3; ++$i) { + locale_inc_callback('locale_add_language', 'l' . $i, $this->randomString(), $this->randomString()); + } + } + + /** + * Check that that only the correct values are returned by field_multilingual_available_languages. + */ + function testFieldAvailableLanguages() { + // Test translatable fieldable info. + $field = $this->field; + $field['field_name'] .= '_untranslatable'; + $langcode = language_default(); + $suggested_languages = array($langcode->language); + $available_languages = field_multilingual_available_languages($this->obj_type, $field); + $this->assertTrue(count($available_languages) == 1 && $available_languages[0] === FIELD_LANGUAGE_NONE, t('Untranslatable entity: suggested language ignored.')); + + // Enable field translations for the entity. + field_test_fieldable_info_translatable('test_entity', TRUE); + + // Test hook_field_languages invocation on a translatable field. + $this->field['settings']['test_hook_in'] = TRUE; + $enabled_languages = array_keys(language_list()); + $available_languages = field_multilingual_available_languages($this->obj_type, $this->field); + $this->assertTrue(in_array(FIELD_LANGUAGE_NONE, $available_languages), t('%language is an available language.', array('%language' => FIELD_LANGUAGE_NONE))); + foreach ($available_languages as $delta => $langcode) { + if ($langcode != FIELD_LANGUAGE_NONE) { + $this->assertTrue(in_array($langcode, $enabled_languages), t('%language is an enabled language.', array('%language' => $langcode))); + } + } + $this->assertFalse(in_array('xx', $available_languages), t('No invalid language was made available.')); + $this->assertTrue(count($available_languages) == count($enabled_languages), t('An enabled language was successfully made unavailable.')); + + // Test field_multilingual_available_languages behavior for untranslatable fields. + $this->field['translatable'] = FALSE; + $this->field_name = $this->field['field_name'] = $this->instance['field_name'] = drupal_strtolower($this->randomName() . '_field_name'); + $available_languages = field_multilingual_available_languages($this->obj_type, $this->field); + $this->assertTrue(count($available_languages) == 1 && $available_languages[0] === FIELD_LANGUAGE_NONE, t('For untranslatable fields only neutral language is available.')); + + // Test language suggestions. + $this->field['settings']['test_hook_in'] = FALSE; + $this->field['translatable'] = TRUE; + $this->field_name = $this->field['field_name'] = $this->instance['field_name'] = drupal_strtolower($this->randomName() . '_field_name'); + $suggested_languages = array(); + $lang_count = mt_rand(1, count($enabled_languages) - 1); + for ($i = 0; $i < $lang_count; ++$i) { + do { + $langcode = $enabled_languages[mt_rand(0, $lang_count)]; + } + while (in_array($langcode, $suggested_languages)); + $suggested_languages[] = $langcode; + } + $available_languages = field_multilingual_available_languages($this->obj_type, $this->field, $suggested_languages); + $this->assertEqual(count($available_languages), count($suggested_languages), t('Suggested languages were successfully made available.')); + foreach ($available_languages as $langcode) { + $this->assertTrue(in_array($langcode, $available_languages), t('Suggested language %language is available.', array('%language' => $langcode))); + } + $this->field_name = $this->field['field_name'] = $this->instance['field_name'] = drupal_strtolower($this->randomName() . '_field_name'); + $suggested_languages = array('xx'); + $available_languages = field_multilingual_available_languages($this->obj_type, $this->field, $suggested_languages); + $this->assertTrue(empty($available_languages), t('An invalid suggested language was not made available.')); + } + + /** + * Test the multilanguage logic of _field_invoke. + */ + function testFieldInvoke() { + $entity_type = 'test_entity'; + $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']); + + // Populate some extra languages to check if _field_invoke correctly uses + // the result of field_multilingual_available_languages. + $values = array(); + $extra_languages = mt_rand(1, 4); + $languages = $available_languages = field_multilingual_available_languages($this->obj_type, $this->field); + for ($i = 0; $i < $extra_languages; ++$i) { + $languages[] = $this->randomString(2); + } + + // For each given language provide some random values. + foreach ($languages as $langcode) { + for ($delta = 0; $delta < $this->field['cardinality']; $delta++) { + $values[$langcode][$delta]['value'] = mt_rand(1, 127); + } + } + $entity->{$this->field_name} = $values; + + $results = _field_invoke('test_op', $entity_type, $entity); + foreach ($results as $langcode => $result) { + $hash = md5(serialize(array($entity_type, $entity, $this->field_name, $langcode, $values[$langcode]))); + // Check if the parameters passed to _field_invoke were correctly forwarded to the callback function. + $this->assertEqual($hash, $result, t('The result for %language is correctly stored.', array('%language' => $langcode))); + } + $this->assertEqual(count($results), count($available_languages), t('No unavailable language has been processed.')); + } + + /** + * Test the multilanguage logic of _field_invoke_multiple. + */ + function testFieldInvokeMultiple() { + $values = array(); + $entities = array(); + $entity_type = 'test_entity'; + $entity_count = mt_rand(1, 5); + $available_languages = field_multilingual_available_languages($this->obj_type, $this->field); + + for ($id = 1; $id <= $entity_count; ++$id) { + $entity = field_test_create_stub_entity($id, $id, $this->instance['bundle']); + $languages = $available_languages; + + // Populate some extra languages to check if _field_invoke correctly uses + // the result of field_multilingual_available_languages. + $extra_languages = mt_rand(1, 4); + for ($i = 0; $i < $extra_languages; ++$i) { + $languages[] = $this->randomString(2); + } + + // For each given language provide some random values. + foreach ($languages as $langcode) { + for ($delta = 0; $delta < $this->field['cardinality']; $delta++) { + $values[$id][$langcode][$delta]['value'] = mt_rand(1, 127); + } + } + $entity->{$this->field_name} = $values[$id]; + $entities[$id] = $entity; + } + + $grouped_results = _field_invoke_multiple('test_op_multiple', $entity_type, $entities); + foreach ($grouped_results as $id => $results) { + foreach ($results as $langcode => $result) { + $hash = md5(serialize(array($entity_type, $entities[$id], $this->field_name, $langcode, $values[$id][$langcode]))); + // Check if the parameters passed to _field_invoke were correctly forwarded to the callback function. + $this->assertEqual($hash, $result, t('The result for object %id/%language is correctly stored.', array('%id' => $id, '%language' => $langcode))); + } + $this->assertEqual(count($results), count($available_languages), t('No unavailable language has been processed for object %id.', array('%id' => $id))); + } + } + + /** + * Test translatable fields storage/retrieval. + */ + function testTranslatableFieldSaveLoad() { + // Enable field translations for nodes. + field_test_fieldable_info_translatable('node', TRUE); + $obj_info = field_info_fieldable_types('node'); + $this->assertTrue(count($obj_info['translation_handlers']), t('Nodes are translatable.')); + + // Prepare the field translations. + $eid = $evid = 1; + $obj_type = 'test_entity'; + $object = field_test_create_stub_entity($eid, $evid, $this->instance['bundle']); + $field_translations = array(); + foreach (field_multilingual_available_languages($obj_type, $this->field) as $langcode) { + $field_translations[$langcode] = FieldAttachTestCase::_generateTestFieldValues($this->field['cardinality']); + } + + // Save and reload the field translations. + $object->{$this->field_name} = $field_translations; + field_attach_insert($obj_type, $object); + unset($object->{$this->field_name}); + field_attach_load($obj_type, array($eid => $object)); + + // Check if the correct values were saved/loaded. + foreach ($field_translations as $langcode => $items) { + $result = TRUE; + foreach ($items as $delta => $item) { + $result = $result && $item['value'] == $object->{$this->field_name}[$langcode][$delta]['value']; + } + $this->assertTrue($result, t('%language translation correctly handled.', array('%language' => $langcode))); + } + } +} + +/** * Unit test class for field bulk delete and batch purge functionality. */ class FieldBulkDeleteTestCase extends DrupalWebTestCase { Index: modules/field/modules/field_sql_storage/field_sql_storage.module =================================================================== RCS file: /cvs/drupal/drupal/modules/field/modules/field_sql_storage/field_sql_storage.module,v retrieving revision 1.18 diff -u -p -r1.18 field_sql_storage.module --- modules/field/modules/field_sql_storage/field_sql_storage.module 11 Aug 2009 14:59:40 -0000 1.18 +++ modules/field/modules/field_sql_storage/field_sql_storage.module 19 Aug 2009 20:37:07 -0000 @@ -144,8 +144,16 @@ function _field_sql_storage_schema($fiel 'not null' => TRUE, 'description' => 'The sequence number for this data item, used for multi-value fields', ), + // @todo Consider to store language as integer. + 'language' => array( + 'type' => 'varchar', + 'length' => 32, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The language for this data item.', + ), ), - 'primary key' => array('etid', 'entity_id', 'deleted', 'delta'), + 'primary key' => array('etid', 'entity_id', 'deleted', 'delta', 'language'), // TODO : index on 'bundle' ); @@ -169,7 +177,7 @@ function _field_sql_storage_schema($fiel $revision = $current; $revision['description'] = "Revision archive storage for {$deleted}field {$field['id']} ({$field['field_name']})"; $revision['revision_id']['description'] = 'The entity revision id this data is attached to'; - $revision['primary key'] = array('etid', 'revision_id', 'deleted', 'delta'); + $revision['primary key'] = array('etid', 'revision_id', 'deleted', 'delta', 'language'); return array( _field_sql_storage_tablename($field) => $current, @@ -224,7 +232,7 @@ function field_sql_storage_field_storage if (!isset($skip_fields[$instance['field_id']]) && (!isset($options['field_id']) || $options['field_id'] == $instance['field_id'])) { $objects[$id]->{$field_name} = array(); $field_ids[$instance['field_id']][] = $load_current ? $id : $vid; - $delta_count[$id][$field_name] = 0; + $delta_count[$id][$field_name] = array(); } } } @@ -238,6 +246,7 @@ function field_sql_storage_field_storage ->fields('t') ->condition('etid', $etid) ->condition($load_current ? 'entity_id' : 'revision_id', $ids, 'IN') + ->condition('language', field_multilingual_available_languages($obj_type, $field), 'IN') ->orderBy('delta'); if (empty($options['deleted'])) { @@ -247,7 +256,11 @@ function field_sql_storage_field_storage $results = $query->execute(); foreach ($results as $row) { - if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta_count[$row->entity_id][$field_name] < $field['cardinality']) { + if (!isset($delta_count[$row->entity_id][$field_name][$row->language])) { + $delta_count[$row->entity_id][$field_name][$row->language] = 0; + } + + if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta_count[$row->entity_id][$field_name][$row->language] < $field['cardinality']) { $item = array(); // For each column declared by the field, populate the item // from the prefixed database column. @@ -257,8 +270,8 @@ function field_sql_storage_field_storage } // Add the item to the field values for the entity. - $objects[$row->entity_id]->{$field_name}[] = $item; - $delta_count[$row->entity_id][$field_name]++; + $objects[$row->entity_id]->{$field_name}[$row->language][] = $item; + $delta_count[$row->entity_id][$field_name][$row->language]++; } } } @@ -288,17 +301,33 @@ function field_sql_storage_field_storage // Function property_exists() is slower, so we catch the more frequent cases // where it's an empty array with the faster isset(). if (isset($object->$field_name) || property_exists($object, $field_name)) { + $available_languages = field_multilingual_available_languages($obj_type, $field); + $available_translations = is_array($object->$field_name) ? array_intersect($available_languages, array_keys($object->$field_name)) : FALSE; + // Delete and insert, rather than update, in case a value was added. - if ($op == FIELD_STORAGE_UPDATE) { - db_delete($table_name)->condition('etid', $etid)->condition('entity_id', $id)->execute(); + // If no translation is available, empty the field for all the available languages. + if ($op == FIELD_STORAGE_UPDATE && count($available_translations)) { + $languages = empty($object->$field_name) ? $available_languages : $available_translations; + + db_delete($table_name) + ->condition('etid', $etid) + ->condition('entity_id', $id) + ->condition('language', $languages, 'IN') + ->execute(); + if (isset($vid)) { - db_delete($revision_name)->condition('etid', $etid)->condition('entity_id', $id)->condition('revision_id', $vid)->execute(); + db_delete($revision_name) + ->condition('etid', $etid) + ->condition('entity_id', $id) + ->condition('revision_id', $vid) + ->condition('language', $languages, 'IN') + ->execute(); } } - if ($object->$field_name) { + if (!empty($available_translations)) { // Prepare the multi-insert query. - $columns = array('etid', 'entity_id', 'revision_id', 'bundle', 'delta'); + $columns = array('etid', 'entity_id', 'revision_id', 'bundle', 'delta', 'language'); foreach ($field['columns'] as $column => $attributes) { $columns[] = _field_sql_storage_columnname($field_name, $column); } @@ -307,25 +336,30 @@ function field_sql_storage_field_storage $revision_query = db_insert($revision_name)->fields($columns); } - $delta_count = 0; - foreach ($object->$field_name as $delta => $item) { - $record = array( - 'etid' => $etid, - 'entity_id' => $id, - 'revision_id' => $vid, - 'bundle' => $bundle, - 'delta' => $delta, - ); - foreach ($field['columns'] as $column => $attributes) { - $record[_field_sql_storage_columnname($field_name, $column)] = isset($item[$column]) ? $item[$column] : NULL; - } - $query->values($record); - if (isset($vid)) { - $revision_query->values($record); - } - - if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED && ++$delta_count == $field['cardinality']) { - break; + foreach ($available_translations as $langcode) { + if ($items = $object->{$field_name}[$langcode]) { + $delta_count = 0; + foreach ($items as $delta => $item) { + $record = array( + 'etid' => $etid, + 'entity_id' => $id, + 'revision_id' => $vid, + 'bundle' => $bundle, + 'delta' => $delta, + 'language' => $langcode, + ); + foreach ($field['columns'] as $column => $attributes) { + $record[_field_sql_storage_columnname($field_name, $column)] = isset($item[$column]) ? $item[$column] : NULL; + } + $query->values($record); + if (isset($vid)) { + $revision_query->values($record); + } + + if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED && ++$delta_count == $field['cardinality']) { + break; + } + } } } Index: modules/field/modules/field_sql_storage/field_sql_storage.test =================================================================== RCS file: /cvs/drupal/drupal/modules/field/modules/field_sql_storage/field_sql_storage.test,v retrieving revision 1.6 diff -u -p -r1.6 field_sql_storage.test --- modules/field/modules/field_sql_storage/field_sql_storage.test 28 Jul 2009 19:18:06 -0000 1.6 +++ modules/field/modules/field_sql_storage/field_sql_storage.test 19 Aug 2009 20:44:04 -0000 @@ -98,13 +99,23 @@ class FieldSqlStorageTestCase extends Dr field_attach_load_revision($entity_type, array($eid => $entity)); foreach ($values[$evid] as $delta => $value) { if ($delta < $this->field['cardinality']) { - $this->assertEqual($entity->{$this->field_name}[$delta]['value'], $value, "Value $delta for revision $evid is loaded correctly"); + $this->assertEqual($entity->{$this->field_name}[$langcode][$delta]['value'], $value, "Value $delta for revision $evid is loaded correctly"); } else { - $this->assertFalse(array_key_exists($delta, $entity->{$this->field_name}), "No extraneous value gets loaded for revision $evid."); + $this->assertFalse(array_key_exists($delta, $entity->{$this->field_name}[$langcode]), "No extraneous value gets loaded for revision $evid."); } } } + + // Add a translation in an unavailable language and verify it is not loaded. + $eid = $evid = 1; + $unavailable_language = 'xx'; + $entity = field_test_create_stub_entity($eid, $evid, $this->instance['bundle']); + $values = array($etid, $eid, $evid, 0, $unavailable_language, mt_rand(1, 127)); + db_insert($this->table)->fields($columns)->values($values)->execute(); + db_insert($this->revision_table)->fields($columns)->values($values)->execute(); + field_attach_load($entity_type, array($eid => $entity)); + $this->assertFalse(array_key_exists($unavailable_language, $entity->{$this->field_name}), 'Field translation in an unavailable language ignored'); } /** @@ -238,5 +251,47 @@ class FieldSqlStorageTestCase extends Dr ->execute() ->fetchField(); $this->assertEqual($count, 0, 'NULL field leaves no data in table'); + + // Add a translation in an unavailable language. + $unavailable_language = 'xx'; + db_insert($this->table) + ->fields(array('etid', 'bundle', 'deleted', 'entity_id', 'revision_id', 'delta', 'language')) + ->values(array(_field_sql_storage_etid($entity_type), $this->instance['bundle'], 0, 0, 0, 0, $unavailable_language)) + ->execute(); + $count = db_select($this->table) + ->countQuery() + ->execute() + ->fetchField(); + $this->assertEqual($count, 1, 'Field translation in an unavailable language saved.'); + + // Again add some real data. + $entity->{$this->field_name}[$langcode] = array(0 => array('value' => 1)); + field_attach_insert($entity_type, $entity); + $count = db_select($this->table) + ->countQuery() + ->execute() + ->fetchField(); + $this->assertEqual($count, 2, 'Field data saved.'); + + // Update: Field translation is missing but field is not empty. Translation + // data should survive. + $entity->{$this->field_name}[$unavailable_language] = array(mt_rand(1, 127)); + unset($entity->{$this->field_name}[$langcode]); + field_attach_update($entity_type, $entity); + $count = db_select($this->table) + ->countQuery() + ->execute() + ->fetchField(); + $this->assertEqual($count, 2, 'Missing field translation leaves data in table.'); + + // Update: Field translation is NULL but field is not empty. Translation + // data should be wiped. + $entity->{$this->field_name}[$langcode] = NULL; + field_attach_update($entity_type, $entity); + $count = db_select($this->table) + ->countQuery() + ->execute() + ->fetchField(); + $this->assertEqual($count, 1, 'NULL field translation is wiped.'); } } Index: modules/field/theme/field.tpl.php =================================================================== RCS file: /cvs/drupal/drupal/modules/field/theme/field.tpl.php,v retrieving revision 1.3 diff -u -p -r1.3 field.tpl.php --- modules/field/theme/field.tpl.php 22 Jun 2009 09:10:04 -0000 1.3 +++ modules/field/theme/field.tpl.php 19 Aug 2009 20:01:25 -0000 @@ -18,6 +18,8 @@ * - $label: The item label. * - $label_display: Position of label display, inline, above, or hidden. * - $field_empty: Whether the field has any valid value. + * - $field_language: The field language. + * - $field_translatable: Whether the field is translatable or not. * * Each $item in $items contains: * - 'view' - the themed view for that item Index: modules/node/node.tpl.php =================================================================== RCS file: /cvs/drupal/drupal/modules/node/node.tpl.php,v retrieving revision 1.19 diff -u -p -r1.19 node.tpl.php --- modules/node/node.tpl.php 6 Aug 2009 05:05:59 -0000 1.19 +++ modules/node/node.tpl.php 19 Aug 2009 20:49:29 -0000 @@ -58,6 +58,13 @@ * - $is_front: Flags true when presented in the front page. * - $logged_in: Flags true when the current user is a logged-in member. * - $is_admin: Flags true when the current user is an administrator. + * + * Field variables: for each field instance attached to the node a corresponding + * variable is defined, e.g. $node->body becomes $body. When needing to access + * a field's raw values, developers/themers are strongly encouraged to use these + * variables. Otherwise they will have to explicitly specify the desired field + * language, e.g. $node->body['en'], thus overriding any language negotiation + * rule that was previously applied. * * @see template_preprocess() * @see template_preprocess_node() Index: modules/simpletest/tests/field_test.module =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/field_test.module,v retrieving revision 1.14 diff -u -p -r1.14 field_test.module --- modules/simpletest/tests/field_test.module 11 Aug 2009 14:59:40 -0000 1.14 +++ modules/simpletest/tests/field_test.module 19 Aug 2009 20:01:25 -0000 @@ -88,6 +88,15 @@ function field_test_fieldable_info() { } /** + * Implement hook_fieldable_info_alter(). + */ +function field_test_fieldable_info_alter(&$info) { + foreach (field_test_fieldable_info_translatable() as $obj_type => $translatable) { + $info[$obj_type]['translation_handlers']['field_test'] = TRUE; + } +} + +/** * Create a new bundle for test_entity objects. * * @param $bundle_name @@ -581,6 +590,48 @@ function field_test_default_value($obj_t } /** + * Generic op to test _field_invoke behavior. + */ +function field_test_field_test_op($obj_type, $object, $field, $instance, $langcode, &$items) { + return array($langcode => md5(serialize(array($obj_type, $object, $field['field_name'], $langcode, $items)))); +} + +/** + * Generic op to test _field_invoke_multiple behavior. + */ +function field_test_field_test_op_multiple($obj_type, $objects, $field, $instances, $langcode, &$items) { + $result = array(); + foreach ($objects as $id => $object) { + $result[$id] = array($langcode => md5(serialize(array($obj_type, $object, $field['field_name'], $langcode, $items[$id])))); + } + return $result; +} + +/** + * Implement hook_field_languages(). + */ +function field_test_field_languages($obj_type, $field, &$languages) { + if ($field['settings']['test_hook_in']) { + // Add an unavailable language. + $languages[] = 'xx'; + // Remove an available language. + unset($languages[0]); + } +} + +/** + * Helper function to enable entity translations. + */ +function field_test_fieldable_info_translatable($obj_type = NULL, $translatable = NULL) { + $stored_value = &drupal_static(__FUNCTION__, array()); + if (isset($obj_type) && isset($translatable)) { + $stored_value[$obj_type] = $translatable; + _field_info_collate_types(TRUE); + } + return $stored_value; +} + +/** * Store and retrieve keyed data for later verification by unit tests. * * This function is a simple in-memory key-value store with the Index: modules/user/user-profile.tpl.php =================================================================== RCS file: /cvs/drupal/drupal/modules/user/user-profile.tpl.php,v retrieving revision 1.10 diff -u -p -r1.10 user-profile.tpl.php --- modules/user/user-profile.tpl.php 13 Jul 2009 21:09:54 -0000 1.10 +++ modules/user/user-profile.tpl.php 19 Aug 2009 20:54:09 -0000 @@ -15,6 +15,13 @@ * is provided which contains data on the user's history. Other data can be * included by modules. $user_profile['user_picture'] is available * for showing the account picture. + * + * Field variables: for each field instance attached to the user a corresponding + * variable is defined, e.g. $user->field_example becomes $field_example. When + * needing to access a field's raw values, developers/themers are strongly + * encouraged to use these variables. Otherwise they will have to explicitly + * specify the desired field language, e.g. $user->field_example['en'], thus + * overriding any language negotiation rule that was previously applied. * * @see user-profile-category.tpl.php * Where the html is handled for the group.