3 namespace Drupal\content_translation;
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;
12 * Provides field translation synchronization capabilities.
14 class FieldTranslationSynchronizer implements FieldTranslationSynchronizerInterface {
17 * The entity manager to use to load unchanged entities.
19 * @var \Drupal\Core\Entity\EntityManagerInterface
21 protected $entityManager;
24 * The field type plugin manager.
26 * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
28 protected $fieldTypeManager;
31 * Constructs a FieldTranslationSynchronizer object.
33 * @param \Drupal\Core\Entity\EntityManagerInterface $entityManager
35 * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_manager
36 * The field type plugin manager.
38 public function __construct(EntityManagerInterface $entityManager, FieldTypePluginManagerInterface $field_type_manager) {
39 $this->entityManager = $entityManager;
40 $this->fieldTypeManager = $field_type_manager;
46 public function getFieldSynchronizedProperties(FieldDefinitionInterface $field_definition) {
48 $settings = $this->getFieldSynchronizationSettings($field_definition);
49 foreach ($settings as $group => $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']);
61 * Returns the synchronization settings for the specified field.
63 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
67 * An array of synchronized field property names.
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', []);
79 public function synchronizeFields(ContentEntityInterface $entity, $sync_langcode, $original_langcode = NULL) {
80 $translations = $entity->getTranslationLanguages();
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) {
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()) {
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()) {
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.
107 $sync_langcode = $entity->getUntranslated()->language()->getId();
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
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);
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'];
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
141 $groups = array_keys(array_diff($translation_sync, array_filter($translation_sync)));
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'])) {
153 if (!empty($groups)) {
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]);
160 if (!empty($columns)) {
162 foreach ($translations as $langcode => $language) {
163 $values[$langcode] = $entity->getTranslation($langcode)->get($field_name)->getValue();
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
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);
173 foreach ($translations as $langcode => $language) {
174 $entity->getTranslation($langcode)->get($field_name)->setValue($values[$langcode]);
183 * Returns the original unchanged entity to be used to detect changes.
185 * @param \Drupal\Core\Entity\ContentEntityInterface $entity
186 * The entity being changed.
188 * @return \Drupal\Core\Entity\ContentEntityInterface
189 * The unchanged entity.
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());
197 $original = $entity->original;
205 public function synchronizeItems(array &$values, array $unchanged_items, $sync_langcode, array $translations, array $properties) {
206 $source_items = $values[$sync_langcode];
208 // Make sure we can detect any change in the source items.
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)]);
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
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;
227 // Backup field values and the change map.
228 $original_field_values = $values;
229 $original_change_map = $change_map;
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];
235 // Update field translations.
236 foreach ($translations as $langcode) {
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
242 $change_map = $original_change_map;
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.
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']);
258 if (!empty($change_map[$item_id]['new'])) {
259 $new_delta = array_shift($change_map[$item_id]['new']);
261 $created = $created && !isset($old_delta);
262 $removed = $removed && !isset($new_delta);
265 // If an item has been removed we do not store its translations.
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);
277 // If the delta doesn't exist, copy from the source language.
279 $values[$langcode][$delta] = $source_items[$delta];
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
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);
301 * Creates a merged item.
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.
311 * A merged item array.
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;
321 * Computes a hash code for the specified item.
323 * @param array $items
324 * An array of field items.
326 * The delta identifying the item to be processed.
327 * @param array $properties
328 * An array of column names to be synchronized.
331 * A hash code that can be used to identify the item.
333 protected function itemHash(array $items, $delta, array $properties) {
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));
345 // Explicitly track also empty values.
351 return implode('.', $values);