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