Chris@0: 'entity.manager']; Chris@18: Chris@18: /** Chris@18: * The entity type manager. Chris@0: * Chris@18: * @var \Drupal\Core\Entity\EntityTypeManagerInterface Chris@0: */ Chris@18: protected $entityTypeManager; Chris@0: Chris@0: /** Chris@14: * The field type plugin manager. Chris@14: * Chris@14: * @var \Drupal\Core\Field\FieldTypePluginManagerInterface Chris@14: */ Chris@14: protected $fieldTypeManager; Chris@14: Chris@14: /** Chris@0: * Constructs a FieldTranslationSynchronizer object. Chris@0: * Chris@18: * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager Chris@18: * The entity type manager. Chris@14: * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_manager Chris@14: * The field type plugin manager. Chris@0: */ Chris@18: public function __construct(EntityTypeManagerInterface $entity_type_manager, FieldTypePluginManagerInterface $field_type_manager) { Chris@18: $this->entityTypeManager = $entity_type_manager; Chris@14: $this->fieldTypeManager = $field_type_manager; Chris@14: } Chris@14: Chris@14: /** Chris@14: * {@inheritdoc} Chris@14: */ Chris@14: public function getFieldSynchronizedProperties(FieldDefinitionInterface $field_definition) { Chris@14: $properties = []; Chris@14: $settings = $this->getFieldSynchronizationSettings($field_definition); Chris@14: foreach ($settings as $group => $translatable) { Chris@14: if (!$translatable) { Chris@14: $field_type_definition = $this->fieldTypeManager->getDefinition($field_definition->getType()); Chris@14: if (!empty($field_type_definition['column_groups'][$group]['columns'])) { Chris@14: $properties = array_merge($properties, $field_type_definition['column_groups'][$group]['columns']); Chris@14: } Chris@14: } Chris@14: } Chris@14: return $properties; Chris@14: } Chris@14: Chris@14: /** Chris@14: * Returns the synchronization settings for the specified field. Chris@14: * Chris@14: * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition Chris@14: * A field definition. Chris@14: * Chris@14: * @return string[] Chris@14: * An array of synchronized field property names. Chris@14: */ Chris@14: protected function getFieldSynchronizationSettings(FieldDefinitionInterface $field_definition) { Chris@14: if ($field_definition instanceof ThirdPartySettingsInterface && $field_definition->isTranslatable()) { Chris@14: return $field_definition->getThirdPartySetting('content_translation', 'translation_sync', []); Chris@14: } Chris@14: return []; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function synchronizeFields(ContentEntityInterface $entity, $sync_langcode, $original_langcode = NULL) { Chris@0: $translations = $entity->getTranslationLanguages(); Chris@0: Chris@0: // If we have no information about what to sync to, if we are creating a new Chris@0: // entity, if we have no translations for the current entity and we are not Chris@0: // creating one, then there is nothing to synchronize. Chris@0: if (empty($sync_langcode) || $entity->isNew() || count($translations) < 2) { Chris@0: return; Chris@0: } Chris@0: Chris@0: // If the entity language is being changed there is nothing to synchronize. Chris@14: $entity_unchanged = $this->getOriginalEntity($entity); Chris@0: if ($entity->getUntranslated()->language()->getId() != $entity_unchanged->getUntranslated()->language()->getId()) { Chris@0: return; Chris@0: } Chris@0: Chris@14: if ($entity->isNewRevision()) { Chris@14: if ($entity->isDefaultTranslationAffectedOnly()) { Chris@14: // If changes to untranslatable fields are configured to affect only the Chris@14: // default translation, we need to skip synchronization in pending Chris@14: // revisions, otherwise multiple translations would be affected. Chris@14: if (!$entity->isDefaultRevision()) { Chris@14: return; Chris@14: } Chris@14: // When this mode is enabled, changes to synchronized properties are Chris@14: // allowed only in the default translation, thus we need to make sure this Chris@14: // is always used as source for the synchronization process. Chris@14: else { Chris@14: $sync_langcode = $entity->getUntranslated()->language()->getId(); Chris@14: } Chris@14: } Chris@14: elseif ($entity->isDefaultRevision()) { Chris@14: // If a new default revision is being saved, but a newer default Chris@14: // revision was created meanwhile, use any other translation as source Chris@14: // for synchronization, since that will have been merged from the Chris@14: // default revision. In this case the actual language does not matter as Chris@14: // synchronized properties are the same for all the translations in the Chris@14: // default revision. Chris@14: /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */ Chris@18: $default_revision = $this->entityTypeManager Chris@14: ->getStorage($entity->getEntityTypeId()) Chris@14: ->load($entity->id()); Chris@14: if ($default_revision->getLoadedRevisionId() !== $entity->getLoadedRevisionId()) { Chris@14: $other_langcodes = array_diff_key($default_revision->getTranslationLanguages(), [$sync_langcode => FALSE]); Chris@14: if ($other_langcodes) { Chris@14: $sync_langcode = key($other_langcodes); Chris@14: } Chris@14: } Chris@14: } Chris@14: } Chris@14: Chris@0: /** @var \Drupal\Core\Field\FieldItemListInterface $items */ Chris@0: foreach ($entity as $field_name => $items) { Chris@0: $field_definition = $items->getFieldDefinition(); Chris@14: $field_type_definition = $this->fieldTypeManager->getDefinition($field_definition->getType()); Chris@0: $column_groups = $field_type_definition['column_groups']; Chris@0: Chris@0: // Sync if the field is translatable, not empty, and the synchronization Chris@0: // setting is enabled. Chris@14: if (($translation_sync = $this->getFieldSynchronizationSettings($field_definition)) && !$items->isEmpty()) { Chris@0: // Retrieve all the untranslatable column groups and merge them into Chris@0: // single list. Chris@0: $groups = array_keys(array_diff($translation_sync, array_filter($translation_sync))); Chris@0: Chris@0: // If a group was selected has the require_all_groups_for_translation Chris@0: // flag set, there are no untranslatable columns. This is done because Chris@0: // the UI adds Javascript that disables the other checkboxes, so their Chris@0: // values are not saved. Chris@0: foreach (array_filter($translation_sync) as $group) { Chris@0: if (!empty($column_groups[$group]['require_all_groups_for_translation'])) { Chris@0: $groups = []; Chris@0: break; Chris@0: } Chris@0: } Chris@0: if (!empty($groups)) { Chris@0: $columns = []; Chris@0: foreach ($groups as $group) { Chris@0: $info = $column_groups[$group]; Chris@0: // A missing 'columns' key indicates we have a single-column group. Chris@0: $columns = array_merge($columns, isset($info['columns']) ? $info['columns'] : [$group]); Chris@0: } Chris@0: if (!empty($columns)) { Chris@0: $values = []; Chris@0: foreach ($translations as $langcode => $language) { Chris@0: $values[$langcode] = $entity->getTranslation($langcode)->get($field_name)->getValue(); Chris@0: } Chris@0: Chris@0: // If a translation is being created, the original values should be Chris@0: // used as the unchanged items. In fact there are no unchanged items Chris@0: // to check against. Chris@0: $langcode = $original_langcode ?: $sync_langcode; Chris@0: $unchanged_items = $entity_unchanged->getTranslation($langcode)->get($field_name)->getValue(); Chris@0: $this->synchronizeItems($values, $unchanged_items, $sync_langcode, array_keys($translations), $columns); Chris@0: Chris@0: foreach ($translations as $langcode => $language) { Chris@0: $entity->getTranslation($langcode)->get($field_name)->setValue($values[$langcode]); Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@14: * Returns the original unchanged entity to be used to detect changes. Chris@14: * Chris@14: * @param \Drupal\Core\Entity\ContentEntityInterface $entity Chris@14: * The entity being changed. Chris@14: * Chris@14: * @return \Drupal\Core\Entity\ContentEntityInterface Chris@14: * The unchanged entity. Chris@14: */ Chris@14: protected function getOriginalEntity(ContentEntityInterface $entity) { Chris@14: if (!isset($entity->original)) { Chris@18: $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); Chris@14: $original = $entity->isDefaultRevision() ? $storage->loadUnchanged($entity->id()) : $storage->loadRevision($entity->getLoadedRevisionId()); Chris@14: } Chris@14: else { Chris@14: $original = $entity->original; Chris@14: } Chris@14: return $original; Chris@14: } Chris@14: Chris@14: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@17: public function synchronizeItems(array &$values, array $unchanged_items, $sync_langcode, array $translations, array $properties) { Chris@0: $source_items = $values[$sync_langcode]; Chris@0: Chris@0: // Make sure we can detect any change in the source items. Chris@0: $change_map = []; Chris@0: Chris@0: // By picking the maximum size between updated and unchanged items, we make Chris@0: // sure to process also removed items. Chris@0: $total = max([count($source_items), count($unchanged_items)]); Chris@0: Chris@0: // As a first step we build a map of the deltas corresponding to the column Chris@0: // values to be synchronized. Recording both the old values and the new Chris@0: // values will allow us to detect any change in the order of the new items Chris@0: // for each column. Chris@0: for ($delta = 0; $delta < $total; $delta++) { Chris@0: foreach (['old' => $unchanged_items, 'new' => $source_items] as $key => $items) { Chris@17: if ($item_id = $this->itemHash($items, $delta, $properties)) { Chris@0: $change_map[$item_id][$key][] = $delta; Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: // Backup field values and the change map. Chris@0: $original_field_values = $values; Chris@0: $original_change_map = $change_map; Chris@0: Chris@0: // Reset field values so that no spurious one is stored. Source values must Chris@0: // be preserved in any case. Chris@0: $values = [$sync_langcode => $source_items]; Chris@0: Chris@0: // Update field translations. Chris@0: foreach ($translations as $langcode) { Chris@0: Chris@0: // We need to synchronize only values different from the source ones. Chris@0: if ($langcode != $sync_langcode) { Chris@0: // Reinitialize the change map as it is emptied while processing each Chris@0: // language. Chris@0: $change_map = $original_change_map; Chris@0: Chris@0: // By using the maximum cardinality we ensure to process removed items. Chris@0: for ($delta = 0; $delta < $total; $delta++) { Chris@0: // By inspecting the map we built before we can tell whether a value Chris@0: // has been created or removed. A changed value will be interpreted as Chris@0: // a new value, in fact it did not exist before. Chris@0: $created = TRUE; Chris@0: $removed = TRUE; Chris@0: $old_delta = NULL; Chris@0: $new_delta = NULL; Chris@0: Chris@17: if ($item_id = $this->itemHash($source_items, $delta, $properties)) { Chris@0: if (!empty($change_map[$item_id]['old'])) { Chris@0: $old_delta = array_shift($change_map[$item_id]['old']); Chris@0: } Chris@0: if (!empty($change_map[$item_id]['new'])) { Chris@0: $new_delta = array_shift($change_map[$item_id]['new']); Chris@0: } Chris@0: $created = $created && !isset($old_delta); Chris@0: $removed = $removed && !isset($new_delta); Chris@0: } Chris@0: Chris@0: // If an item has been removed we do not store its translations. Chris@0: if ($removed) { Chris@0: continue; Chris@0: } Chris@0: // If a synchronized column has changed or has been created from Chris@0: // scratch we need to replace the values for this language as a Chris@0: // combination of the values that need to be synced from the source Chris@0: // items and the other columns from the existing values. This only Chris@0: // works if the delta exists in the language. Chris@0: elseif ($created && !empty($original_field_values[$langcode][$delta])) { Chris@17: $values[$langcode][$delta] = $this->createMergedItem($source_items[$delta], $original_field_values[$langcode][$delta], $properties); Chris@0: } Chris@0: // If the delta doesn't exist, copy from the source language. Chris@0: elseif ($created) { Chris@0: $values[$langcode][$delta] = $source_items[$delta]; Chris@0: } Chris@0: // Otherwise the current item might have been reordered. Chris@0: elseif (isset($old_delta) && isset($new_delta)) { Chris@0: // If for any reason the old value is not defined for the current Chris@0: // language we fall back to the new source value, this way we ensure Chris@0: // the new values are at least propagated to all the translations. Chris@0: // If the value has only been reordered we just move the old one in Chris@0: // the new position. Chris@0: $item = isset($original_field_values[$langcode][$old_delta]) ? $original_field_values[$langcode][$old_delta] : $source_items[$new_delta]; Chris@14: // When saving a default revision starting from a pending revision, Chris@14: // we may have desynchronized field values, so we make sure that Chris@14: // untranslatable properties are synchronized, even if in any other Chris@14: // situation this would not be necessary. Chris@17: $values[$langcode][$new_delta] = $this->createMergedItem($source_items[$new_delta], $item, $properties); Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@14: * Creates a merged item. Chris@14: * Chris@14: * @param array $source_item Chris@14: * An item containing the untranslatable properties to be synchronized. Chris@14: * @param array $target_item Chris@14: * An item containing the translatable properties to be kept. Chris@14: * @param string[] $properties Chris@14: * An array of properties to be synchronized. Chris@14: * Chris@14: * @return array Chris@14: * A merged item array. Chris@14: */ Chris@14: protected function createMergedItem(array $source_item, array $target_item, array $properties) { Chris@14: $property_keys = array_flip($properties); Chris@14: $item_properties_to_sync = array_intersect_key($source_item, $property_keys); Chris@14: $item_properties_to_keep = array_diff_key($target_item, $property_keys); Chris@14: return $item_properties_to_sync + $item_properties_to_keep; Chris@14: } Chris@14: Chris@14: /** Chris@0: * Computes a hash code for the specified item. Chris@0: * Chris@0: * @param array $items Chris@0: * An array of field items. Chris@0: * @param int $delta Chris@0: * The delta identifying the item to be processed. Chris@17: * @param array $properties Chris@0: * An array of column names to be synchronized. Chris@0: * Chris@0: * @returns string Chris@0: * A hash code that can be used to identify the item. Chris@0: */ Chris@17: protected function itemHash(array $items, $delta, array $properties) { Chris@0: $values = []; Chris@0: Chris@0: if (isset($items[$delta])) { Chris@17: foreach ($properties as $property) { Chris@17: if (!empty($items[$delta][$property])) { Chris@17: $value = $items[$delta][$property]; Chris@0: // String and integer values are by far the most common item values, Chris@0: // thus we special-case them to improve performance. Chris@0: $values[] = is_string($value) || is_int($value) ? $value : hash('sha256', serialize($value)); Chris@0: } Chris@0: else { Chris@0: // Explicitly track also empty values. Chris@0: $values[] = ''; Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: return implode('.', $values); Chris@0: } Chris@0: Chris@0: }