Commit b1575f34 authored by webchick's avatar webchick

Issue #1807692 by plach, YesCT, yched: Added Introduce a column...

Issue #1807692 by plach, YesCT, yched: Added Introduce a column synchronization capability and use it to translate alt and titles through the image field widget.
parent 89686bf4
......@@ -19,6 +19,20 @@ function image_field_info() {
'settings' => array(
'uri_scheme' => file_default_scheme(),
'default_image' => 0,
'column_groups' => array(
'file' => array(
'label' => t('File'),
'columns' => array('fid', 'width', 'height'),
),
'alt' => array(
'label' => t('Alt'),
'translatable' => TRUE,
),
'title' => array(
'label' => t('Title'),
'translatable' => TRUE,
),
),
),
'instance_settings' => array(
'file_extensions' => 'png gif jpg jpeg',
......
......@@ -1021,6 +1021,7 @@ function language_content_settings_form(array $form, array $form_state, array $s
$form['settings'][$entity_type] = array(
'#title' => $labels[$entity_type],
'#type' => 'container',
'#entity_type' => $entity_type,
'#theme' => 'language_content_settings_table',
'#bundle_label' => isset($info['bundle_label']) ? $info['bundle_label'] : $labels[$entity_type],
'#states' => array(
......
......@@ -430,6 +430,13 @@ public function entityFormEntityBuild($entity_type, EntityInterface $entity, arr
if (!empty($values['retranslate'])) {
$this->retranslate($entity, $form_langcode);
}
// Set contextual information that can be reused during the storage phase.
// @todo Remove this once we have an EntityLanguageDecorator to deal with
// the active language.
$attributes = drupal_container()->get('request')->attributes;
$attributes->set('working_langcode', $form_langcode);
$attributes->set('source_langcode', $source_langcode);
}
/**
......
<?php
/**
* @file
* Contains \Drupal\translation_entity\FieldTranslationSynchronizer.
*/
namespace Drupal\translation_entity;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides field translation synchronization capabilities.
*/
class FieldTranslationSynchronizer implements FieldTranslationSynchronizerInterface {
/**
* Implements \Drupal\translation_entity\FieldTranslationSynchronizerInterface::synchronizeFields().
*/
public function synchronizeFields(EntityInterface $entity, $sync_langcode, $original_langcode = NULL) {
$translations = $entity->getTranslationLanguages();
// If we have no information about what to sync to, if we are creating a new
// entity, if we have no translations for the current entity and we are not
// creating one, then there is nothing to synchronize.
if (empty($sync_langcode) || $entity->isNew() || (count($translations) < 2 && !$original_langcode)) {
return;
}
// If the entity language is being changed there is nothing to synchronize.
$entity_type = $entity->entityType();
// @todo Use the entity storage controller directly to avoid accessing the
// global scope.
$entity_unchanged = isset($entity->original) ? $entity->original : entity_load_unchanged($entity_type, $entity->id());
if ($entity->language()->langcode != $entity_unchanged->language()->langcode) {
return;
}
// Enable compatibility mode for NG entities.
$entity_unchanged = $entity_unchanged->getBCEntity();
// @todo Use Entity Field API to retrieve field definitions.
$instances = field_info_instances($entity_type, $entity->bundle());
foreach ($instances as $field_name => $instance) {
$field = field_info_field($field_name);
// Sync when the field is not empty, when the synchronization translations
// setting is set, and the field is translatable.
if (!empty($entity->{$field_name}) && !empty($instance['settings']['translation_sync']) && field_is_translatable($entity_type, $field)) {
// Retrieve all the untranslatable column groups and merge them into
// single list.
$groups = array_keys(array_diff($instance['settings']['translation_sync'], array_filter($instance['settings']['translation_sync'])));
if (!empty($groups)) {
$columns = array();
foreach ($groups as $group) {
$info = $field['settings']['column_groups'][$group];
// A missing 'columns' key indicates we have a single-column group.
$columns = array_merge($columns, isset($info['columns']) ? $info['columns'] : array($group));
}
if (!empty($columns)) {
// If a translation is being created, the original values should be
// used as the unchanged items. In fact there are no unchanged items
// to check against.
$langcode = $original_langcode ?: $sync_langcode;
$unchanged_items = !empty($entity_unchanged->{$field_name}[$langcode]) ? $entity_unchanged->{$field_name}[$langcode] : array();
$this->synchronizeItems($entity->{$field_name}, $unchanged_items, $sync_langcode, array_keys($translations), $columns);
}
}
}
}
}
/**
* Implements \Drupal\translation_entity\FieldTranslationSynchronizerInterface::synchronizeItems().
*/
public function synchronizeItems(array &$field_values, array $unchanged_items, $sync_langcode, array $translations, array $columns) {
$source_items = $field_values[$sync_langcode];
// Make sure we can detect any change in the source items.
$change_map = array();
// By picking the maximum size between updated and unchanged items, we make
// sure to process also removed items.
$total = max(array(count($source_items), count($unchanged_items)));
// As a first step we build a map of the deltas corresponding to the column
// values to be synchronized. Recording both the old values and the new
// values will allow us to detect any change in the order of the new items
// for each column.
for ($delta = 0; $delta < $total; $delta++) {
foreach (array('old' => $unchanged_items, 'new' => $source_items) as $key => $items) {
if ($item_id = $this->itemHash($items, $delta, $columns)) {
$change_map[$item_id][$key][] = $delta;
}
}
}
// Backup field values and the change map.
$original_field_values = $field_values;
$original_change_map = $change_map;
// Reset field values so that no spurious one is stored. Source values must
// be preserved in any case.
$field_values = array($sync_langcode => $source_items);
// Update field translations.
foreach ($translations as $langcode) {
// We need to synchronize only values different from the source ones.
if ($langcode != $sync_langcode) {
// Reinitialize the change map as it is emptied while processing each
// language.
$change_map = $original_change_map;
// By using the maximum cardinality we ensure to process removed items.
for ($delta = 0; $delta < $total; $delta++) {
// By inspecting the map we built before we can tell whether a value
// has been created or removed. A changed value will be interpreted as
// a new value, in fact it did not exist before.
$created = TRUE;
$removed = TRUE;
$old_delta = NULL;
$new_delta = NULL;
if ($item_id = $this->itemHash($source_items, $delta, $columns)) {
if (!empty($change_map[$item_id]['old'])) {
$old_delta = array_shift($change_map[$item_id]['old']);
}
if (!empty($change_map[$item_id]['new'])) {
$new_delta = array_shift($change_map[$item_id]['new']);
}
$created = $created && !isset($old_delta);
$removed = $removed && !isset($new_delta);
}
// If an item has been removed we do not store its translations.
if ($removed) {
continue;
}
// If a synchronized column has changed or has been created from
// scratch we need to override the full items array for all languages.
elseif ($created) {
$field_values[$langcode][$delta] = $source_items[$delta];
}
// Otherwise the current item might have been reordered.
elseif (isset($old_delta) && isset($new_delta)) {
// If for any reason the old value is not defined for the current
// language we fall back to the new source value, this way we ensure
// the new values are at least propagated to all the translations.
// If the value has only been reordered we just move the old one in
// the new position.
$item = isset($original_field_values[$langcode][$old_delta]) ? $original_field_values[$langcode][$old_delta] : $source_items[$new_delta];
$field_values[$langcode][$new_delta] = $item;
}
}
}
}
}
/**
* Computes a hash code for the specified item.
*
* @param array $items
* An array of field items.
* @param integer $delta
* The delta identifying the item to be processed.
* @param array $columns
* An array of column names to be synchronized.
*
* @returns string
* A hash code that can be used to identify the item.
*/
protected function itemHash(array $items, $delta, array $columns) {
$values = array();
if (isset($items[$delta])) {
foreach ($columns as $column) {
if (!empty($items[$delta][$column])) {
$value = $items[$delta][$column];
// String and integer values are by far the most common item values,
// thus we special-case them to improve performance.
$values[] = is_string($value) || is_int($value) ? $value : hash('sha256', serialize($value));
}
else {
// Explicitly track also empty values.
$values[] = '';
}
}
}
return implode('.', $values);
}
}
<?php
/**
* @file
* Contains \Drupal\translation_entity\FieldTranslationSynchronizerInterface.
*/
namespace Drupal\translation_entity;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides field translation synchronization capabilities.
*/
interface FieldTranslationSynchronizerInterface {
/**
* Performs field column synchronization on the given entity.
*
* Field column synchronization takes care of propagating any change in the
* field items order and in the column values themselves to all the available
* translations. This functionality is provided by defining a
* 'translation_sync' key in the field instance settings, holding an array of
* column names to be synchronized. The synchronized column values are shared
* across translations, while the rest varies per-language. This is useful for
* instance to translate the "alt" and "title" textual elements of an image
* field, while keeping the same image on every translation.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose values should be synchronized.
* @param string $sync_langcode
* The language of the translation whose values should be used as source for
* synchronization.
* @param string $original_langcode
* (optional) If a new translation is being created, this should be the
* language code of the original values. Defaults to NULL.
*/
public function synchronizeFields(EntityInterface $entity, $sync_langcode, $original_langcode = NULL);
/**
* Synchronize the items of a single field.
*
* All the column values of the "active" language are compared to the
* unchanged values to detect any addition, removal or change in the items
* order. Subsequently the detected changes are performed on the field items
* in other available languages.
*
* @param array $field_values
* The field values to be synchronized.
* @param array $unchanged_items
* The unchanged items to be used to detect changes.
* @param string $sync_langcode
* The language code of the items to use as source values.
* @param array $translations
* An array of all the available language codes for the given field.
* @param array $columns
* An array of column names to be synchronized.
*/
public function synchronizeItems(array &$field_values, array $unchanged_items, $sync_langcode, array $translations, array $columns);
}
<?php
/**
* @file
* Contains \Drupal\entity\Tests\EntityTranslationSyncImageTest.
*/
namespace Drupal\translation_entity\Tests;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Language\Language;
/**
* Tests the Entity Translation image field synchronization capability.
*/
class EntityTranslationSyncImageTest extends EntityTranslationTestBase {
/**
* The cardinality of the image field.
*
* @var int
*/
protected $cardinality;
/**
* The test image files.
*
* @var array
*/
protected $files;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('language', 'translation_entity', 'entity_test', 'image');
public static function getInfo() {
return array(
'name' => 'Image field synchronization',
'description' => 'Tests the field synchronization behavior for the image field.',
'group' => 'Entity Translation UI',
);
}
function setUp() {
parent::setUp();
$this->files = $this->drupalGetTestFiles('image');
}
/**
* Creates the test image field.
*/
protected function setupTestFields() {
$this->fieldName = 'field_test_et_ui_image';
$this->cardinality = 3;
$field = array(
'field_name' => $this->fieldName,
'type' => 'image',
'cardinality' => $this->cardinality,
'translatable' => TRUE,
);
field_create_field($field);
$instance = array(
'entity_type' => $this->entityType,
'field_name' => $this->fieldName,
'bundle' => $this->entityType,
'label' => 'Test translatable image field',
'widget' => array(
'type' => 'image_image',
'weight' => 0,
),
'settings' => array(
'translation_sync' => array(
'file' => FALSE,
'alt' => 'alt',
'title' => 'title',
),
),
);
field_create_instance($instance);
}
/**
* Tests image field field synchronization.
*/
function testImageFieldSync() {
$default_langcode = $this->langcodes[0];
$langcode = $this->langcodes[1];
// Populate the required contextual values.
$attributes = drupal_container()->get('request')->attributes;
$attributes->set('working_langcode', $langcode);
$attributes->set('source_langcode', $default_langcode);
// Populate the test entity with some random initial values.
$values = array(
'name' => $this->randomName(),
'user_id' => mt_rand(1, 128),
'langcode' => $default_langcode,
);
$entity = entity_create($this->entityType, $values)->getBCEntity();
// Create some file entities from the generated test files and store them.
$values = array();
for ($delta = 0; $delta < $this->cardinality; $delta++) {
// For the default language use the same order for files and field items.
$index = $delta;
// Create the file entity for the image being processed and record its
// identifier.
$field_values = array(
'uri' => $this->files[$index]->uri,
'uid' => $GLOBALS['user']->uid,
'status' => FILE_STATUS_PERMANENT,
);
$file = entity_create('file', $field_values);
$file->save();
$fid = $file->id();
$this->files[$index]->fid = $fid;
// Generate the item for the current image file entity and attach it to
// the entity.
$item = array(
'fid' => $fid,
'alt' => $this->randomName(),
'title' => $this->randomName(),
);
$entity->{$this->fieldName}[$default_langcode][$delta] = $item;
// Store the generated values keying them by fid for easier lookup.
$values[$default_langcode][$fid] = $item;
}
$entity = $this->saveEntity($entity);
// Create some field translations for the test image field. The translated
// items will be one less than the original values to check that only the
// translated ones will be preserved. In fact we want the same fids and
// items order for both languages.
for ($delta = 0; $delta < $this->cardinality - 1; $delta++) {
// Simulate a field reordering: items are shifted of one position ahead.
// The modulo operator ensures we start from the beginning after reaching
// the maximum allowed delta.
$index = ($delta + 1) % $this->cardinality;
// Generate the item for the current image file entity and attach it to
// the entity.
$fid = $this->files[$index]->fid;
$item = array(
'fid' => $fid,
'alt' => $this->randomName(),
'title' => $this->randomName(),
);
$entity->{$this->fieldName}[$langcode][$delta] = $item;
// Again store the generated values keying them by fid for easier lookup.
$values[$langcode][$fid] = $item;
}
// Perform synchronization: the translation language is used as source,
// while the default langauge is used as target.
$entity = $this->saveEntity($entity);
// Check that one value has been dropped from the original values.
$assert = count($entity->{$this->fieldName}[$default_langcode]) == 2;
$this->assertTrue($assert, 'One item correctly removed from the synchronized field values.');
// Check that fids have been synchronized and translatable column values
// have been retained.
$fids = array();
foreach ($entity->{$this->fieldName}[$default_langcode] as $delta => $item) {
$value = $values[$default_langcode][$item['fid']];
$source_item = $entity->{$this->fieldName}[$langcode][$delta];
$assert = $item['fid'] == $source_item['fid'] && $item['alt'] == $value['alt'] && $item['title'] == $value['title'];
$this->assertTrue($assert, format_string('Field item @fid has been successfully synchronized.', array('@fid' => $item['fid'])));
$fids[$item['fid']] = TRUE;
}
// Check that the dropped value is the right one.
$removed_fid = $this->files[0]->fid;
$this->assertTrue(!isset($fids[$removed_fid]), format_string('Field item @fid has been correctly removed.', array('@fid' => $removed_fid)));
// Add back an item for the dropped value and perform synchronization again.
// @todo Actually we would need to reset the contextual information to test
// an update, but there is no entity field class for image fields yet,
// hence field translation update does not work properly for those.
$values[$langcode][$removed_fid] = array(
'fid' => $removed_fid,
'alt' => $this->randomName(),
'title' => $this->randomName(),
);
$entity->{$this->fieldName}[$langcode] = array_values($values[$langcode]);
$entity = $this->saveEntity($entity);
// Check that the value has been added to the default language.
$assert = count($entity->{$this->fieldName}[$default_langcode]) == 3;
$this->assertTrue($assert, 'One item correctly added to the synchronized field values.');
foreach ($entity->{$this->fieldName}[$default_langcode] as $delta => $item) {
// When adding an item its value is copied over all the target languages,
// thus in this case the source language needs to be used to check the
// values instead of the target one.
$fid_langcode = $item['fid'] != $removed_fid ? $default_langcode : $langcode;
$value = $values[$fid_langcode][$item['fid']];
$source_item = $entity->{$this->fieldName}[$langcode][$delta];
$assert = $item['fid'] == $source_item['fid'] && $item['alt'] == $value['alt'] && $item['title'] == $value['title'];
$this->assertTrue($assert, format_string('Field item @fid has been successfully synchronized.', array('@fid' => $item['fid'])));
}
}
/**
* Saves the passed entity and reloads it, enabling compatibility mode.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to be saved.
*
* @return \Drupal\Core\Entity\EntityInterface
* The saved entity.
*/
protected function saveEntity(EntityInterface $entity) {
$entity->save();
$entity = entity_test_mul_load($entity->id(), TRUE);
return $entity->getBCEntity();
}
}
<?php
/**
* @file
* Contains \Drupal\entity\Tests\EntityTranslationSyncUnitTest.
*/
namespace Drupal\translation_entity\Tests;
use Drupal\Core\Language\Language;
use Drupal\simpletest\DrupalUnitTestBase;
use Drupal\translation_entity\FieldTranslationSynchronizer;
/**
* Tests the Entity Translation field synchronization algorithm.
*/
class EntityTranslationSyncUnitTest extends DrupalUnitTestBase {
/**
* The synchronizer class to be tested.
*
* @var \Drupal\translation_entity\FieldTranslationSynchronizer
*/
protected $synchronizer;
/**
* The colums to be synchronized.
*
* @var array
*/
protected $synchronized;
/**
* All the field colums.
*
* @var array
*/
protected $columns;
/**
* The available language codes.
*
* @var array
*/
protected $langcodes;
/**
* The field cardinality.
*
* @var integer
*/
protected $cardinality;
/**
* The unchanged field values.
*
* @var array
*/
protected $unchangedFieldValues;
public static $modules = array('language', 'translation_entity');
public static function getInfo() {
return array(
'name' => 'Field synchronization',
'description' => 'Tests the field synchronization logic.',
'group' => 'Entity Translation UI',
);
}
protected function setUp() {
parent::setUp();
$this->synchronizer = new FieldTranslationSynchronizer();
$this->synchronized = array('sync1', 'sync2');
$this->columns = array_merge($this->synchronized, array('var1', 'var2'));
$this->langcodes = array('en', 'it', 'fr', 'de', 'es');
$this->cardinality = 4;
$this->unchangedFieldValues = array();
// Set up an initial set of values in the correct state, that is with
// "synchronized" values being equal.
foreach ($this->langcodes as $langcode) {
for ($delta = 0; $delta < $this->cardinality; $delta++) {
foreach ($this->columns as $column) {
$sync = in_array($column, $this->synchronized) && $langcode != $this->langcodes[0];
$value = $sync ? $this->unchangedFieldValues[$this->langcodes[0]][$delta][$column] : $langcode . '-' . $delta . '-' . $column;
$this->unchangedFieldValues[$langcode][$delta][$column] = $value;
}
}
}
}
/**
* Tests the field synchronization algorithm.
*/
public function testFieldSync() {
// Add a new item to the source items and check that its added to all the
// translations.
$sync_langcode = $this->langcodes[2];
$unchanged_items = $this->unchangedFieldValues[$sync_langcode];
$field_values = $this->unchangedFieldValues;
$item = array();
foreach ($this->columns as $column) {
$item[$column] = $this->randomName();
}
$field_values[$sync_langcode][] = $item;
$this->synchronizer->synchronizeItems($field_values, $unchanged_items, $sync_langcode, $this->langcodes, $this->synchronized);
$result = TRUE;
foreach ($this->unchangedFieldValues as $langcode => $items) {
// Check that the old values are still in place.
for ($delta = 0; $delta < $this->cardinality; $delta++) {
foreach ($this->columns as $column) {
$result = $result && ($this->unchangedFieldValues[$langcode][$delta][$column] == $field_values[$langcode][$delta][$column]);
}
}
// Check that the new item is available in all languages.
foreach ($this->columns as $column) {
$result = $result && ($field_values[$langcode][$delta][$column] == $field_values[$sync_langcode][$delta][$column]);
}
}
$this->assertTrue($result, 'A new item has been correctly synchronized.');
// Remove an item from the source items and check that its removed from all
// the translations.
$sync_langcode = $this->langcodes[1];
$unchanged_items = $this->unchangedFieldValues[$sync_langcode];
$field_values = $this->unchangedFieldValues;
$sync_delta = mt_rand(0, count($field_values[$sync_langcode]) - 1);
unset($field_values[$sync_langcode][$sync_delta]);
// Renumber deltas to start from 0.