FieldTranslationSynchronizer.php

Same filename and directory in other branches
  1. 9 core/modules/content_translation/src/FieldTranslationSynchronizer.php
  2. 8.9.x core/modules/content_translation/src/FieldTranslationSynchronizer.php
  3. 10 core/modules/content_translation/src/FieldTranslationSynchronizer.php

Namespace

Drupal\content_translation

File

core/modules/content_translation/src/FieldTranslationSynchronizer.php

View source
<?php

namespace Drupal\content_translation;

use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldTypePluginManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;

/**
 * Provides field translation synchronization capabilities.
 */
class FieldTranslationSynchronizer implements FieldTranslationSynchronizerInterface {
    
    /**
     * The entity type manager.
     *
     * @var \Drupal\Core\Entity\EntityTypeManagerInterface
     */
    protected $entityTypeManager;
    
    /**
     * The field type plugin manager.
     *
     * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
     */
    protected $fieldTypeManager;
    
    /**
     * Constructs a FieldTranslationSynchronizer object.
     *
     * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
     *   The entity type manager.
     * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_manager
     *   The field type plugin manager.
     */
    public function __construct(EntityTypeManagerInterface $entity_type_manager, FieldTypePluginManagerInterface $field_type_manager) {
        $this->entityTypeManager = $entity_type_manager;
        $this->fieldTypeManager = $field_type_manager;
    }
    
    /**
     * {@inheritdoc}
     */
    public function getFieldSynchronizedProperties(FieldDefinitionInterface $field_definition) {
        $properties = [];
        $settings = $this->getFieldSynchronizationSettings($field_definition);
        foreach ($settings as $group => $translatable) {
            if (!$translatable) {
                $field_type_definition = $this->fieldTypeManager
                    ->getDefinition($field_definition->getType());
                if (!empty($field_type_definition['column_groups'][$group]['columns'])) {
                    $properties = array_merge($properties, $field_type_definition['column_groups'][$group]['columns']);
                }
            }
        }
        return $properties;
    }
    
    /**
     * Returns the synchronization settings for the specified field.
     *
     * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
     *   A field definition.
     *
     * @return string[]
     *   An array of synchronized field property names.
     */
    protected function getFieldSynchronizationSettings(FieldDefinitionInterface $field_definition) {
        if ($field_definition instanceof ThirdPartySettingsInterface && $field_definition->isTranslatable()) {
            return $field_definition->getThirdPartySetting('content_translation', 'translation_sync', []);
        }
        return [];
    }
    
    /**
     * {@inheritdoc}
     */
    public function synchronizeFields(ContentEntityInterface $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) {
            return;
        }
        // If the entity language is being changed there is nothing to synchronize.
        $entity_unchanged = $this->getOriginalEntity($entity);
        if ($entity->getUntranslated()
            ->language()
            ->getId() != $entity_unchanged->getUntranslated()
            ->language()
            ->getId()) {
            return;
        }
        if ($entity->isNewRevision()) {
            if ($entity->isDefaultTranslationAffectedOnly()) {
                // If changes to untranslatable fields are configured to affect only the
                // default translation, we need to skip synchronization in pending
                // revisions, otherwise multiple translations would be affected.
                if (!$entity->isDefaultRevision()) {
                    return;
                }
                else {
                    $sync_langcode = $entity->getUntranslated()
                        ->language()
                        ->getId();
                }
            }
            elseif ($entity->isDefaultRevision()) {
                // If a new default revision is being saved, but a newer default
                // revision was created meanwhile, use any other translation as source
                // for synchronization, since that will have been merged from the
                // default revision. In this case the actual language does not matter as
                // synchronized properties are the same for all the translations in the
                // default revision.
                
                /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */
                $default_revision = $this->entityTypeManager
                    ->getStorage($entity->getEntityTypeId())
                    ->load($entity->id());
                if ($default_revision->getLoadedRevisionId() !== $entity->getLoadedRevisionId()) {
                    $other_langcodes = array_diff_key($default_revision->getTranslationLanguages(), [
                        $sync_langcode => FALSE,
                    ]);
                    if ($other_langcodes) {
                        $sync_langcode = key($other_langcodes);
                    }
                }
            }
        }
        
        /** @var \Drupal\Core\Field\FieldItemListInterface $items */
        foreach ($entity as $field_name => $items) {
            $field_definition = $items->getFieldDefinition();
            $field_type_definition = $this->fieldTypeManager
                ->getDefinition($field_definition->getType());
            $column_groups = $field_type_definition['column_groups'];
            // Sync if the field is translatable, not empty, and the synchronization
            // setting is enabled.
            if (($translation_sync = $this->getFieldSynchronizationSettings($field_definition)) && !$items->isEmpty()) {
                // Retrieve all the untranslatable column groups and merge them into
                // single list.
                $groups = array_keys(array_diff($translation_sync, array_filter($translation_sync)));
                // If a group was selected has the require_all_groups_for_translation
                // flag set, there are no untranslatable columns. This is done because
                // the UI adds JavaScript that disables the other checkboxes, so their
                // values are not saved.
                foreach (array_filter($translation_sync) as $group) {
                    if (!empty($column_groups[$group]['require_all_groups_for_translation'])) {
                        $groups = [];
                        break;
                    }
                }
                if (!empty($groups)) {
                    $columns = [];
                    foreach ($groups as $group) {
                        $info = $column_groups[$group];
                        // A missing 'columns' key indicates we have a single-column group.
                        $columns = array_merge($columns, $info['columns'] ?? [
                            $group,
                        ]);
                    }
                    if (!empty($columns)) {
                        $values = [];
                        foreach ($translations as $langcode => $language) {
                            $values[$langcode] = $entity->getTranslation($langcode)
                                ->get($field_name)
                                ->getValue();
                        }
                        // 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 = $entity_unchanged->getTranslation($langcode)
                            ->get($field_name)
                            ->getValue();
                        $this->synchronizeItems($values, $unchanged_items, $sync_langcode, array_keys($translations), $columns);
                        foreach ($translations as $langcode => $language) {
                            $entity->getTranslation($langcode)
                                ->get($field_name)
                                ->setValue($values[$langcode]);
                        }
                    }
                }
            }
        }
    }
    
    /**
     * Returns the original unchanged entity to be used to detect changes.
     *
     * @param \Drupal\Core\Entity\ContentEntityInterface $entity
     *   The entity being changed.
     *
     * @return \Drupal\Core\Entity\ContentEntityInterface
     *   The unchanged entity.
     */
    protected function getOriginalEntity(ContentEntityInterface $entity) {
        if (!isset($entity->original)) {
            
            /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
            $storage = $this->entityTypeManager
                ->getStorage($entity->getEntityTypeId());
            $original = $entity->isDefaultRevision() ? $storage->loadUnchanged($entity->id()) : $storage->loadRevision($entity->getLoadedRevisionId());
        }
        else {
            $original = $entity->original;
        }
        return $original;
    }
    
    /**
     * {@inheritdoc}
     */
    public function synchronizeItems(array &$values, array $unchanged_items, $sync_langcode, array $translations, array $properties) {
        $source_items = $values[$sync_langcode];
        // Make sure we can detect any change in the source items.
        $change_map = [];
        // By picking the maximum size between updated and unchanged items, we make
        // sure to process also removed items.
        $total = max([
            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 ([
                'old' => $unchanged_items,
                'new' => $source_items,
            ] as $key => $items) {
                if ($item_id = $this->itemHash($items, $delta, $properties)) {
                    $change_map[$item_id][$key][] = $delta;
                }
            }
        }
        // Backup field values and the change map.
        $original_field_values = $values;
        $original_change_map = $change_map;
        // Reset field values so that no spurious one is stored. Source values must
        // be preserved in any case.
        $values = [
            $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, $properties)) {
                        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;
                    }
                    elseif ($created && !empty($original_field_values[$langcode][$delta])) {
                        $values[$langcode][$delta] = $this->createMergedItem($source_items[$delta], $original_field_values[$langcode][$delta], $properties);
                    }
                    elseif ($created) {
                        $values[$langcode][$delta] = $source_items[$delta];
                    }
                    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 = $original_field_values[$langcode][$old_delta] ?? $source_items[$new_delta];
                        // When saving a default revision starting from a pending revision,
                        // we may have desynchronized field values, so we make sure that
                        // untranslatable properties are synchronized, even if in any other
                        // situation this would not be necessary.
                        $values[$langcode][$new_delta] = $this->createMergedItem($source_items[$new_delta], $item, $properties);
                    }
                }
            }
        }
    }
    
    /**
     * Creates a merged item.
     *
     * @param array $source_item
     *   An item containing the untranslatable properties to be synchronized.
     * @param array $target_item
     *   An item containing the translatable properties to be kept.
     * @param string[] $properties
     *   An array of properties to be synchronized.
     *
     * @return array
     *   A merged item array.
     */
    protected function createMergedItem(array $source_item, array $target_item, array $properties) {
        $property_keys = array_flip($properties);
        $item_properties_to_sync = array_intersect_key($source_item, $property_keys);
        $item_properties_to_keep = array_diff_key($target_item, $property_keys);
        return $item_properties_to_sync + $item_properties_to_keep;
    }
    
    /**
     * Computes a hash code for the specified item.
     *
     * @param array $items
     *   An array of field items.
     * @param int $delta
     *   The delta identifying the item to be processed.
     * @param array $properties
     *   An array of column names to be synchronized.
     *
     * @return string
     *   A hash code that can be used to identify the item.
     */
    protected function itemHash(array $items, $delta, array $properties) {
        $values = [];
        if (isset($items[$delta])) {
            foreach ($properties as $property) {
                if (!empty($items[$delta][$property])) {
                    $value = $items[$delta][$property];
                    // 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);
    }

}

Classes

Title Deprecated Summary
FieldTranslationSynchronizer Provides field translation synchronization capabilities.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.