diff --git a/paragraphs_asymmetric_translation_widgets.module b/paragraphs_asymmetric_translation_widgets.module index 77fd943..7a6097c 100644 --- a/paragraphs_asymmetric_translation_widgets.module +++ b/paragraphs_asymmetric_translation_widgets.module @@ -7,6 +7,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\paragraphs\ParagraphInterface; +use Drush\Drush; /** * Implements hook_field_formatter_info_alter(). @@ -118,3 +119,61 @@ function paragraphs_asymmetric_translation_widgets_entity_translation_delete(Ent } } } + +/** + * Automatically migrate paragraphs fields from symmetrical to non-symmetrical. + * + * This hook is supposed to run in the following situations: + * - when importing config through Drush + * - when importing config through the form at + * /admin/config/development/configuration. + * - when updating config through the edit form + * at /admin/structure/types/manage/content/fields/ + * - when updating config through the overview form + * at /admin/config/regional/content-language. + * + * Implements hook_entity_field_config_update(). + */ +function paragraphs_asymmetric_translation_widgets_field_config_update(EntityInterface $entity) { + /** @var \Drupal\field\Entity\FieldConfig $entity */ + if ($entity->getType() !== 'entity_reference_revisions') { + return; + } + + if (!($entity->isTranslatable() && !$entity->original->isTranslatable())) { + return; + } + + $fieldName = $entity->getName(); + $entityTypeId = $entity->getTargetEntityTypeId(); + $bundle = $entity->getTargetBundle(); + + // Build and execute batch. + $batch = \Drupal::service('paragraphs_asymmetric_translation_widgets.migrate') + ->getBatch($entityTypeId, $bundle, $fieldName); + + if (!is_array($batch)) { + return; + } + + batch_set($batch); + + if (Drush::hasContainer()) { + drush_backend_batch_process(); + } +} + +/** + * Implements hook_entity_type_alter(). + */ +function paragraphs_asymmetric_translation_widgets_entity_type_alter(array &$entity_types) { + foreach ($entity_types as $entity_type) { + if ($entity_type->get('id') == 'paragraph') { + // Disable untranslated field validation when editing non-default + // revision, see https://www.drupal.org/node/2938191 for more information. + $constraints = $entity_type->getConstraints(); + unset($constraints['EntityUntranslatableFields']); + $entity_type->setConstraints($constraints); + } + } +} diff --git a/paragraphs_asymmetric_translation_widgets.services.yml b/paragraphs_asymmetric_translation_widgets.services.yml new file mode 100644 index 0000000..4ed9530 --- /dev/null +++ b/paragraphs_asymmetric_translation_widgets.services.yml @@ -0,0 +1,13 @@ +services: + paragraphs_asymmetric_translation_widgets.migrate: + class: Drupal\paragraphs_asymmetric_translation_widgets\MigrateParagraphs + arguments: + - '@entity_type.manager' + - '@messenger' + + paragraphs_asymmetric_translation_widgets.migrate.command: + class: Drupal\paragraphs_asymmetric_translation_widgets\Commands\MigrateParagraphsCommand + tags: + - { name: drush.command } + arguments: + - '@paragraphs_asymmetric_translation_widgets.migrate' diff --git a/src/Commands/MigrateParagraphsCommand.php b/src/Commands/MigrateParagraphsCommand.php new file mode 100644 index 0000000..1485654 --- /dev/null +++ b/src/Commands/MigrateParagraphsCommand.php @@ -0,0 +1,55 @@ +migrateParagraphs = $migrateParagraphs; + } + + /** + * Migrate paragraphs fields from symmetrical to non-symmetrical. + * + * @param string $entityTypeId + * The entity type ID the field is attached to. + * @param string $fieldName + * The name of the field whose paragraphs will be migrated. + * + * @command paragraphs:migrate-to-non-symmetrical + * + * @usage paragraphs:migrate $entity_type $field_name + */ + public function migrateCommand(string $entityTypeId, string $fieldName) { + $batch = $this->migrateParagraphs->getBatch($entityTypeId, NULL, $fieldName); + + if (!is_array($batch)) { + $this->io()->success('No paragraphs to migrate.'); + } + + batch_set($batch); + drush_backend_batch_process(); + } + +} diff --git a/src/MigrateParagraphs.php b/src/MigrateParagraphs.php new file mode 100644 index 0000000..5c731bd --- /dev/null +++ b/src/MigrateParagraphs.php @@ -0,0 +1,242 @@ +entityTypeManager = $entityTypeManager; + $this->messenger = $messenger; + } + + /** + * Create a batch array for migrating paragraphs. + */ + public function getBatch(string $entityTypeId, ?string $bundle, string $fieldName): ?array { + $storage = $this->entityTypeManager + ->getStorage($entityTypeId); + $definition = $this->entityTypeManager + ->getDefinition($entityTypeId); + + $entityQuery = $storage->getQuery() + ->accessCheck(FALSE) + ->exists($fieldName); + + if ($bundle && $bundleKey = $definition->getKey('bundle')) { + $entityQuery->condition($bundleKey, $bundle); + } + + $entityIds = $entityQuery->execute(); + + if ($entityIds === []) { + return NULL; + } + + $batch_builder = (new BatchBuilder()) + ->setTitle(t('Migrating paragraphs')) + ->setFinishCallback([$this, 'batchFinishCallback']); + + foreach ($entityIds as $entityId) { + $batch_builder->addOperation( + [$this, 'migrateEntity'], + [$fieldName, $entityTypeId, $entityId] + ); + } + + return $batch_builder->toArray(); + } + + /** + * Migrate paragraphs from symmetric to asymmetric for a single entity. + * + * @param string $fieldName + * The name of the paragraphs field that will be migrated. + * @param string $entityTypeId + * The entity type ID of the entity whose paragraphs will be migrated. + * @param string $entityId + * The ID of the entity whose paragraphs will be migrated. + * @param array|\DrushBatchContext $context + * An associative array containing contextual information. + */ + public function migrateEntity(string $fieldName, string $entityTypeId, string $entityId, &$context): void { + $entityTypeDefinition = $this->entityTypeManager->getDefinition($entityTypeId); + $entity = $this->entityTypeManager->getStorage($entityTypeId)->load($entityId); + $has_any_changes = FALSE; + + if ($entity instanceof TranslatableInterface) { + foreach ($entity->getTranslationLanguages(FALSE) as $language) { + // Go through all translations of the entity and set the values from + // original entity if there are no existing values. + $entity_translation = $entity->getTranslation($language->getId()); + $has_translation_changes = FALSE; + + /** @var \Drupal\paragraphs\ParagraphInterface[] $source_paragraphs */ + $source_paragraphs = $entity->get($fieldName)->referencedEntities(); + /** @var \Drupal\paragraphs\ParagraphInterface[] $target_paragraphs */ + $target_paragraphs = $entity_translation->get($fieldName)->referencedEntities(); + + foreach ($source_paragraphs as $i => $source_paragraph) { + $target_paragraph = $target_paragraphs[$i] ?? NULL; + // Only update if the target paragraph is empty or + // if it references the same entity & delta as the source. + if (!$target_paragraph instanceof ParagraphInterface || $source_paragraph->id() === $target_paragraph->id()) { + if ($source_paragraph->hasTranslation($language->getId())) { + /** @var \Drupal\paragraphs\ParagraphInterface $source_paragraph_translation */ + $source_paragraph_translation = $source_paragraph->getTranslation($language->getId()); + $duplicate = $this->createDuplicate($source_paragraph_translation); + } + else { + $duplicate = $this->createDuplicate($source_paragraph); + } + $entity_translation->get($fieldName)->set($i, $duplicate); + $has_translation_changes = TRUE; + $has_any_changes = TRUE; + } + } + + if ($has_translation_changes) { + $entity_translation->save(); + } + } + } + + // Collect processed entities. + $context['results'][] = $entityId; + + // Optional message displayed under the progress bar. + $params = [ + '@field_name' => $fieldName, + '@entity_type_label' => $entityTypeDefinition->getSingularLabel(), + '@bundle' => $entity->bundle(), + '@id' => $entityId, + ]; + + if ($has_any_changes) { + $context['message'] = $this->t('Migrated @field_name from symmetric to asymmetric for @entity_type_label with bundle @bundle and ID @id.', $params); + } + else { + $context['message'] = $this->t('Skipped migrating @field_name from symmetric to asymmetric for @entity_type_label with bundle @bundle and ID @id.', $params); + } + } + + /** + * Finish callback for the batch process. + * + * @param bool $success + * Success of the operation. + * @param array $results + * Array of results for post processing. + * @param array $operations + * Array of operations. + */ + public function batchFinishCallback(bool $success, array $results, array $operations) { + if ($success) { + $this->messenger->addMessage( + $this->formatPlural( + count($results), + 'Successfully migrated paragraphs for 1 entity.', + 'Successfully migrated paragraphs for @count entities.', + ) + ); + } + else { + // An error occurred. + // $operations contains the operations that remained unprocessed. + $error_operation = reset($operations); + $this->messenger->addMessage( + $this->t('An error occurred while processing @operation with arguments: @args', [ + '@operation' => $error_operation[0], + '@args' => print_r($error_operation[0], TRUE), + ]) + ); + } + } + + /** + * Create a duplicate of a paragraph. + * + * @param \Drupal\paragraphs\ParagraphInterface $paragraph + * The paragraph to duplicate. + * + * @return \Drupal\paragraphs\ParagraphInterface + * The duplicated paragraph. + */ + protected function createDuplicate(ParagraphInterface $paragraph): ParagraphInterface { + // We cannot use Paragraph::createDuplicate() here + // because it won't change default_langcode to 1. + $values = []; + $fieldNamesToIgnore = [ + 'id', + 'uuid', + 'revision_id', + 'default_langcode', + 'revision_default', + 'revision_translation_affected', + 'content_translation_source', + 'content_translation_outdated', + 'content_translation_changed', + ]; + + foreach ($paragraph->getFields(FALSE) as $fieldName => $field) { + if (in_array($fieldName, $fieldNamesToIgnore)) { + continue; + } + + // @see Paragraph::createDuplicate() + if ($field instanceof EntityReferenceFieldItemListInterface && $field->getSetting('target_type') === $paragraph->getEntityTypeId()) { + foreach ($field as $delta => $item) { + if ($item->entity) { + $values[$fieldName][$delta] = $item->entity->createDuplicate(); + } + } + } + else { + $values[$fieldName] = $field->getValue(); + } + } + + return Paragraph::create($values); + } + +}