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