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