annotate core/modules/content_translation/src/FieldTranslationSynchronizer.php @ 19:fa3358dc1485 tip

Add ndrum files
author Chris Cannam
date Wed, 28 Aug 2019 13:14:47 +0100
parents af1871eacc83
children
rev   line source
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 }