1<?php
2
3namespace Drupal\content_translation;
4
5use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
6use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
7use Drupal\Core\Entity\ContentEntityInterface;
8use Drupal\Core\Field\FieldDefinitionInterface;
9use Drupal\Core\Field\FieldTypePluginManagerInterface;
10use Drupal\Core\Entity\EntityTypeManagerInterface;
11
12/**
13 * Provides field translation synchronization capabilities.
14 */
15class FieldTranslationSynchronizer implements FieldTranslationSynchronizerInterface {
16  use DeprecatedServicePropertyTrait;
17
18  /**
19   * {@inheritdoc}
20   */
21  protected $deprecatedProperties = ['entityManager' => 'entity.manager'];
22
23  /**
24   * The entity type manager.
25   *
26   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
27   */
28  protected $entityTypeManager;
29
30  /**
31   * The field type plugin manager.
32   *
33   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
34   */
35  protected $fieldTypeManager;
36
37  /**
38   * Constructs a FieldTranslationSynchronizer object.
39   *
40   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
41   *   The entity type manager.
42   * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_manager
43   *   The field type plugin manager.
44   */
45  public function __construct(EntityTypeManagerInterface $entity_type_manager, FieldTypePluginManagerInterface $field_type_manager) {
46    $this->entityTypeManager = $entity_type_manager;
47    $this->fieldTypeManager = $field_type_manager;
48  }
49
50  /**
51   * {@inheritdoc}
52   */
53  public function getFieldSynchronizedProperties(FieldDefinitionInterface $field_definition) {
54    $properties = [];
55    $settings = $this->getFieldSynchronizationSettings($field_definition);
56    foreach ($settings as $group => $translatable) {
57      if (!$translatable) {
58        $field_type_definition = $this->fieldTypeManager->getDefinition($field_definition->getType());
59        if (!empty($field_type_definition['column_groups'][$group]['columns'])) {
60          $properties = array_merge($properties, $field_type_definition['column_groups'][$group]['columns']);
61        }
62      }
63    }
64    return $properties;
65  }
66
67  /**
68   * Returns the synchronization settings for the specified field.
69   *
70   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
71   *   A field definition.
72   *
73   * @return string[]
74   *   An array of synchronized field property names.
75   */
76  protected function getFieldSynchronizationSettings(FieldDefinitionInterface $field_definition) {
77    if ($field_definition instanceof ThirdPartySettingsInterface && $field_definition->isTranslatable()) {
78      return $field_definition->getThirdPartySetting('content_translation', 'translation_sync', []);
79    }
80    return [];
81  }
82
83  /**
84   * {@inheritdoc}
85   */
86  public function synchronizeFields(ContentEntityInterface $entity, $sync_langcode, $original_langcode = NULL) {
87    $translations = $entity->getTranslationLanguages();
88
89    // If we have no information about what to sync to, if we are creating a new
90    // entity, if we have no translations for the current entity and we are not
91    // creating one, then there is nothing to synchronize.
92    if (empty($sync_langcode) || $entity->isNew() || count($translations) < 2) {
93      return;
94    }
95
96    // If the entity language is being changed there is nothing to synchronize.
97    $entity_unchanged = $this->getOriginalEntity($entity);
98    if ($entity->getUntranslated()->language()->getId() != $entity_unchanged->getUntranslated()->language()->getId()) {
99      return;
100    }
101
102    if ($entity->isNewRevision()) {
103      if ($entity->isDefaultTranslationAffectedOnly()) {
104        // If changes to untranslatable fields are configured to affect only the
105        // default translation, we need to skip synchronization in pending
106        // revisions, otherwise multiple translations would be affected.
107        if (!$entity->isDefaultRevision()) {
108          return;
109        }
110        // When this mode is enabled, changes to synchronized properties are
111        // allowed only in the default translation, thus we need to make sure this
112        // is always used as source for the synchronization process.
113        else {
114          $sync_langcode = $entity->getUntranslated()->language()->getId();
115        }
116      }
117      elseif ($entity->isDefaultRevision()) {
118        // If a new default revision is being saved, but a newer default
119        // revision was created meanwhile, use any other translation as source
120        // for synchronization, since that will have been merged from the
121        // default revision. In this case the actual language does not matter as
122        // synchronized properties are the same for all the translations in the
123        // default revision.
124        /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */
125        $default_revision = $this->entityTypeManager
126          ->getStorage($entity->getEntityTypeId())
127          ->load($entity->id());
128        if ($default_revision->getLoadedRevisionId() !== $entity->getLoadedRevisionId()) {
129          $other_langcodes = array_diff_key($default_revision->getTranslationLanguages(), [$sync_langcode => FALSE]);
130          if ($other_langcodes) {
131            $sync_langcode = key($other_langcodes);
132          }
133        }
134      }
135    }
136
137    /** @var \Drupal\Core\Field\FieldItemListInterface $items */
138    foreach ($entity as $field_name => $items) {
139      $field_definition = $items->getFieldDefinition();
140      $field_type_definition = $this->fieldTypeManager->getDefinition($field_definition->getType());
141      $column_groups = $field_type_definition['column_groups'];
142
143      // Sync if the field is translatable, not empty, and the synchronization
144      // setting is enabled.
145      if (($translation_sync = $this->getFieldSynchronizationSettings($field_definition)) && !$items->isEmpty()) {
146        // Retrieve all the untranslatable column groups and merge them into
147        // single list.
148        $groups = array_keys(array_diff($translation_sync, array_filter($translation_sync)));
149
150        // If a group was selected has the require_all_groups_for_translation
151        // flag set, there are no untranslatable columns. This is done because
152        // the UI adds Javascript that disables the other checkboxes, so their
153        // values are not saved.
154        foreach (array_filter($translation_sync) as $group) {
155          if (!empty($column_groups[$group]['require_all_groups_for_translation'])) {
156            $groups = [];
157            break;
158          }
159        }
160        if (!empty($groups)) {
161          $columns = [];
162          foreach ($groups as $group) {
163            $info = $column_groups[$group];
164            // A missing 'columns' key indicates we have a single-column group.
165            $columns = array_merge($columns, isset($info['columns']) ? $info['columns'] : [$group]);
166          }
167          if (!empty($columns)) {
168            $values = [];
169            foreach ($translations as $langcode => $language) {
170              $values[$langcode] = $entity->getTranslation($langcode)->get($field_name)->getValue();
171            }
172
173            // If a translation is being created, the original values should be
174            // used as the unchanged items. In fact there are no unchanged items
175            // to check against.
176            $langcode = $original_langcode ?: $sync_langcode;
177            $unchanged_items = $entity_unchanged->getTranslation($langcode)->get($field_name)->getValue();
178            $this->synchronizeItems($values, $unchanged_items, $sync_langcode, array_keys($translations), $columns);
179
180            foreach ($translations as $langcode => $language) {
181              $entity->getTranslation($langcode)->get($field_name)->setValue($values[$langcode]);
182            }
183          }
184        }
185      }
186    }
187  }
188
189  /**
190   * Returns the original unchanged entity to be used to detect changes.
191   *
192   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
193   *   The entity being changed.
194   *
195   * @return \Drupal\Core\Entity\ContentEntityInterface
196   *   The unchanged entity.
197   */
198  protected function getOriginalEntity(ContentEntityInterface $entity) {
199    if (!isset($entity->original)) {
200      $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
201      $original = $entity->isDefaultRevision() ? $storage->loadUnchanged($entity->id()) : $storage->loadRevision($entity->getLoadedRevisionId());
202    }
203    else {
204      $original = $entity->original;
205    }
206    return $original;
207  }
208
209  /**
210   * {@inheritdoc}
211   */
212  public function synchronizeItems(array &$values, array $unchanged_items, $sync_langcode, array $translations, array $properties) {
213    $source_items = $values[$sync_langcode];
214
215    // Make sure we can detect any change in the source items.
216    $change_map = [];
217
218    // By picking the maximum size between updated and unchanged items, we make
219    // sure to process also removed items.
220    $total = max([count($source_items), count($unchanged_items)]);
221
222    // As a first step we build a map of the deltas corresponding to the column
223    // values to be synchronized. Recording both the old values and the new
224    // values will allow us to detect any change in the order of the new items
225    // for each column.
226    for ($delta = 0; $delta < $total; $delta++) {
227      foreach (['old' => $unchanged_items, 'new' => $source_items] as $key => $items) {
228        if ($item_id = $this->itemHash($items, $delta, $properties)) {
229          $change_map[$item_id][$key][] = $delta;
230        }
231      }
232    }
233
234    // Backup field values and the change map.
235    $original_field_values = $values;
236    $original_change_map = $change_map;
237
238    // Reset field values so that no spurious one is stored. Source values must
239    // be preserved in any case.
240    $values = [$sync_langcode => $source_items];
241
242    // Update field translations.
243    foreach ($translations as $langcode) {
244
245      // We need to synchronize only values different from the source ones.
246      if ($langcode != $sync_langcode) {
247        // Reinitialize the change map as it is emptied while processing each
248        // language.
249        $change_map = $original_change_map;
250
251        // By using the maximum cardinality we ensure to process removed items.
252        for ($delta = 0; $delta < $total; $delta++) {
253          // By inspecting the map we built before we can tell whether a value
254          // has been created or removed. A changed value will be interpreted as
255          // a new value, in fact it did not exist before.
256          $created = TRUE;
257          $removed = TRUE;
258          $old_delta = NULL;
259          $new_delta = NULL;
260
261          if ($item_id = $this->itemHash($source_items, $delta, $properties)) {
262            if (!empty($change_map[$item_id]['old'])) {
263              $old_delta = array_shift($change_map[$item_id]['old']);
264            }
265            if (!empty($change_map[$item_id]['new'])) {
266              $new_delta = array_shift($change_map[$item_id]['new']);
267            }
268            $created = $created && !isset($old_delta);
269            $removed = $removed && !isset($new_delta);
270          }
271
272          // If an item has been removed we do not store its translations.
273          if ($removed) {
274            continue;
275          }
276          // If a synchronized column has changed or has been created from
277          // scratch we need to replace the values for this language as a
278          // combination of the values that need to be synced from the source
279          // items and the other columns from the existing values. This only
280          // works if the delta exists in the language.
281          elseif ($created && !empty($original_field_values[$langcode][$delta])) {
282            $values[$langcode][$delta] = $this->createMergedItem($source_items[$delta], $original_field_values[$langcode][$delta], $properties);
283          }
284          // If the delta doesn't exist, copy from the source language.
285          elseif ($created) {
286            $values[$langcode][$delta] = $source_items[$delta];
287          }
288          // Otherwise the current item might have been reordered.
289          elseif (isset($old_delta) && isset($new_delta)) {
290            // If for any reason the old value is not defined for the current
291            // language we fall back to the new source value, this way we ensure
292            // the new values are at least propagated to all the translations.
293            // If the value has only been reordered we just move the old one in
294            // the new position.
295            $item = isset($original_field_values[$langcode][$old_delta]) ? $original_field_values[$langcode][$old_delta] : $source_items[$new_delta];
296            // When saving a default revision starting from a pending revision,
297            // we may have desynchronized field values, so we make sure that
298            // untranslatable properties are synchronized, even if in any other
299            // situation this would not be necessary.
300            $values[$langcode][$new_delta] = $this->createMergedItem($source_items[$new_delta], $item, $properties);
301          }
302        }
303      }
304    }
305  }
306
307  /**
308   * Creates a merged item.
309   *
310   * @param array $source_item
311   *   An item containing the untranslatable properties to be synchronized.
312   * @param array $target_item
313   *   An item containing the translatable properties to be kept.
314   * @param string[] $properties
315   *   An array of properties to be synchronized.
316   *
317   * @return array
318   *   A merged item array.
319   */
320  protected function createMergedItem(array $source_item, array $target_item, array $properties) {
321    $property_keys = array_flip($properties);
322    $item_properties_to_sync = array_intersect_key($source_item, $property_keys);
323    $item_properties_to_keep = array_diff_key($target_item, $property_keys);
324    return $item_properties_to_sync + $item_properties_to_keep;
325  }
326
327  /**
328   * Computes a hash code for the specified item.
329   *
330   * @param array $items
331   *   An array of field items.
332   * @param int $delta
333   *   The delta identifying the item to be processed.
334   * @param array $properties
335   *   An array of column names to be synchronized.
336   *
337   * @returns string
338   *   A hash code that can be used to identify the item.
339   */
340  protected function itemHash(array $items, $delta, array $properties) {
341    $values = [];
342
343    if (isset($items[$delta])) {
344      foreach ($properties as $property) {
345        if (!empty($items[$delta][$property])) {
346          $value = $items[$delta][$property];
347          // String and integer values are by far the most common item values,
348          // thus we special-case them to improve performance.
349          $values[] = is_string($value) || is_int($value) ? $value : hash('sha256', serialize($value));
350        }
351        else {
352          // Explicitly track also empty values.
353          $values[] = '';
354        }
355      }
356    }
357
358    return implode('.', $values);
359  }
360
361}
362