From a8a1cc5daba9c6be3879dc0c75285014d938cd27 Mon Sep 17 00:00:00 2001
From: Anna De Langhe <anna@wax.be>
Date: Thu, 21 Nov 2024 16:06:16 +0100
Subject: [PATCH] Add paragraph support with batch.

---
 chatgpt_plugin.routing.yml                    |   2 +
 chatgpt_plugin.services.yml                   |   2 +
 src/ChatGPTBatches.php                        |  79 +++++++
 src/ChatGPTTranslationService.php             | 157 +++++++++++++
 src/Controller/ChatGPTTranslateController.php | 217 +++++++++++-------
 src/Form/ChatGPTConfigForm.php                |   1 +
 6 files changed, 381 insertions(+), 77 deletions(-)
 create mode 100644 src/ChatGPTBatches.php
 create mode 100644 src/ChatGPTTranslationService.php

diff --git a/chatgpt_plugin.routing.yml b/chatgpt_plugin.routing.yml
index 28e9b9b..080d4f2 100644
--- a/chatgpt_plugin.routing.yml
+++ b/chatgpt_plugin.routing.yml
@@ -19,6 +19,8 @@ chatgpt_plugin.translate_content:
     _title: 'ChatGPT Translation'
   requirements:
     _permission: 'access chatgpt translation'
+  options:
+    _admin_route: TRUE
 chatgpt_plugin.chatgpt_assist_tool:
   path: '/admin/config/chatgpt_assist_tool'
   defaults:
diff --git a/chatgpt_plugin.services.yml b/chatgpt_plugin.services.yml
index 4357d30..c251f1c 100644
--- a/chatgpt_plugin.services.yml
+++ b/chatgpt_plugin.services.yml
@@ -9,3 +9,5 @@ services:
   chatgpt_plugin.dalle_api:
     class: Drupal\chatgpt_plugin\DallEApiService
     arguments: ['@http_client', '@config.factory']
+  chatgpt_plugin.translation_service:
+    class: Drupal\chatgpt_plugin\ChatGPTTranslationService
diff --git a/src/ChatGPTBatches.php b/src/ChatGPTBatches.php
new file mode 100644
index 0000000..b5c4e09
--- /dev/null
+++ b/src/ChatGPTBatches.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\chatgpt_plugin;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\node\Entity\Node;
+use GuzzleHttp\Exception\GuzzleException;
+
+class ChatGPTBatches {
+
+  public static function initiateBatchProcessing(array $bundleFields, Node $node, $langName, $langCode) {
+    $batch = [
+      'title' => t('Translating content'),
+      'operations' => [],
+      'finished' => '\Drupal\chatgpt_plugin\ChatGPTBatches::finishedCallback',
+    ];
+
+    foreach ($bundleFields as $fieldName => $bundleField) {
+      $batch['operations'][] = ['\Drupal\chatgpt_plugin\ChatGPTBatches::operationCallback', [$fieldName, $bundleField, $node, $langName, $langCode, $bundleFields]];
+    }
+
+    // Set the batch.
+    batch_set($batch);
+  }
+
+  public static function operationCallback($fieldName, $bundleField, $node, $langName, $langCode, $bundleFields, &$context) {
+    // Access the service using Drupal's service container.
+    $translationService = \Drupal::service('chatgpt_plugin.translation_service');
+
+    if (!isset($context['results']['processed'])) {
+      $context['results']['processed'] = 0;
+    }
+
+    if (!isset($context['results']['bundleFields'])) {
+      $context['results']['bundleFields'] = $bundleFields;
+    }
+
+    if (!isset($context['results']['node'])) {
+      $context['results']['node'] = $node;
+    }
+
+    if (!isset($context['results']['lang_code'])) {
+      $context['results']['lang_code'] = $langCode;
+    }
+
+    $context['results']['processed']++;
+
+    if ($bundleField['isTranslatable']) {
+      $field = $node->get($fieldName);
+
+      if (!isset($bundleField['subfields'])) {
+        $field_value = $field->value;
+        if ($field_value) {
+          $translated_text = $translationService->chatgptTranslateContent($field_value, $langName);
+          $context['results']['bundleFields'][$fieldName]['translation'] = $translated_text;
+        }
+      }
+      else {
+        $context['results']['bundleFields'] = $translationService->getTranslationsRecursively($fieldName, $bundleField, $langName, $context['results']['bundleFields']);
+      }
+    }
+
+    $context['message'] = t('Processed @count nodes.', ['@count' => $context['results']['processed']]);
+  }
+
+  public static function finishedCallback($success, array $results, array $operations) {
+    // Access the service using Drupal's service container.
+    $translationService = \Drupal::service('chatgpt_plugin.translation_service');
+    $translationService->insertTranslation($results['node'], $results['lang_code'], $results['bundleFields']);
+
+    if ($success) {
+      $message = t('Translation process completed successfully.');
+    }
+    else {
+      $message = t('Translation process finished with errors.');
+    }
+    \Drupal::messenger()->addStatus($message);
+  }
+}
diff --git a/src/ChatGPTTranslationService.php b/src/ChatGPTTranslationService.php
new file mode 100644
index 0000000..6aeb405
--- /dev/null
+++ b/src/ChatGPTTranslationService.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\chatgpt_plugin;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use GuzzleHttp\Exception\GuzzleException;
+use Drupal\paragraphs\Entity\Paragraph;
+
+class ChatGPTTranslationService {
+
+  public function chatgptTranslateContent($input_text, $lang_name) {
+    $prompt_text = "Translate this into " . $lang_name . " - " . $input_text;
+
+    try {
+      $gptApi = \Drupal::service('chatgpt_plugin.gpt_api');
+      $response = $gptApi->getGptResponse($prompt_text);
+    } catch (GuzzleException $exception) {
+      $error_msg = $exception->getMessage();
+      return $error_msg;
+    }
+
+    return $response;
+  }
+
+  public function getTranslationsRecursively($field_name, $label, $lang_name, $bundleFields): array {
+    foreach ($label['subfields'] as $subfield_key => $subfield) {
+      foreach ($subfield as $field_name_sub => $label_sub) {
+        if (!isset($label_sub['subfields'])) {
+          if (isset($label_sub['value'])) {
+            $field_value = $label_sub['value'];
+            $translated_text = $this->chatgptTranslateContent($field_value, $lang_name);
+            $bundleFields[$field_name]['subfields'][$subfield_key][$field_name_sub]['translation'] = $translated_text;
+          }
+        }
+        else {
+          $bundleFields[$field_name]['subfields'][$subfield_key] =
+            $this->getTranslationsRecursively($field_name_sub, $label_sub, $lang_name, $bundleFields[$field_name]['subfields'][$subfield_key]);
+        }
+      }
+    }
+
+    return $bundleFields;
+  }
+
+  public function insertTranslation(ContentEntityInterface $node, string $target_language, array $translations): bool {
+    $status = FALSE;
+    if (!$node->hasTranslation($target_language)) {
+      $translatedNode = $node->addTranslation($target_language);
+      $status = TRUE;
+
+      foreach ($translations as $field_name => $field_info) {
+        if ($field_name != 'moderation_state') {
+          if (!empty($field_info['subfields'])) {
+            $paragraph = $this->createParagraphsFromStructure($target_language, $field_info['subfields']);
+            $translatedNode->set($field_name, $paragraph);
+          }
+          else {
+            if ($field_info['isTranslatable']) {
+              if (isset($field_info['translation'])) {
+                $translatedNode->$field_name->value = $field_info['translation'];
+              }
+              if (isset($field_info['format'])) {
+                $translatedNode->$field_name->format = $field_info['format'];
+              }
+            }
+          }
+        }
+      }
+
+      try {
+        $translatedNode->save();
+      } catch (EntityStorageException $e) {
+        $status = FALSE;
+        \Drupal::logger('chatgpt_plugin')->error($e->getMessage());
+      }
+    }
+
+    return $status;
+  }
+
+  private function createParagraphsFromStructure(string $target_language, array $structure): array {
+    $paragraphs = [];
+    foreach ($structure as $structure_item) {
+      if (isset($structure_item[array_key_first($structure_item)]['bundle'])) {
+        $paragraph = Paragraph::create([
+          'type' => $structure_item[array_key_first($structure_item)]['bundle'],
+          'langcode' => $target_language,
+        ]);
+
+        foreach ($structure_item as $field_name => $field_info) {
+          if ($field_info['isTranslatable'] || !empty($field_info['subfields'])) {
+            if (isset($field_info['translation'])) {
+              $paragraph->$field_name->value = $field_info['translation'];
+            }
+            if (isset($field_info['format'])) {
+              $paragraph->$field_name->format = $field_info['format'];
+            }
+            if (!empty($field_info['subfields'])) {
+              $nested_paragraphs = $this->createParagraphsFromStructure($target_language, $field_info['subfields']);
+              $paragraph->set($field_name, $nested_paragraphs);
+            }
+          }
+          elseif (str_starts_with($field_name, 'field_')) {
+            $field_definition = $paragraph->getFieldDefinition($field_name);
+            $field_type = $field_definition->getType();
+            $field_value = $field_info['field_value'];
+            switch ($field_type) {
+              case 'entity_reference':
+              case 'entity_reference_revisions':
+                if (is_array($field_value)) {
+                  $paragraph->set($field_name, $field_value);
+                }
+                break;
+              case 'file':
+                if (is_array($field_value) && isset($field_value['target_id'])) {
+                  $paragraph->set($field_name, ['target_id' => $field_value['target_id']]);
+                }
+                break;
+              case 'image':
+                if (is_array($field_value) && isset($field_value['target_id'])) {
+                  $paragraph->set($field_name, ['target_id' => $field_value['target_id'], 'alt' => $field_value['alt'] ?? '']);
+                }
+                break;
+              case 'link':
+                if (is_array($field_value)) {
+                  $paragraph->set($field_name, [
+                    'uri' => $field_value['uri'],
+                    'title' => $field_value['title'] ?? '',
+                  ]);
+                }
+                break;
+              case 'boolean':
+                $paragraph->set($field_name, (bool) $field_value);
+                break;
+              default:
+                $paragraph->set($field_name, $field_value);
+                break;
+            }
+          }
+          else {
+            \Drupal::logger('chatgpt_plugin')->error(t('@fieldName is not translatable.', ['@fieldName' => $field_name]));
+          }
+
+        }
+
+        try {
+          $paragraph->save();
+          $paragraphs[] = $paragraph;
+        } catch (EntityStorageException $e) {
+          \Drupal::logger('chatgpt_plugin')->error($e->getMessage());
+        }
+      }
+    }
+    return $paragraphs;
+  }
+
+}
diff --git a/src/Controller/ChatGPTTranslateController.php b/src/Controller/ChatGPTTranslateController.php
index 72b614e..740c332 100644
--- a/src/Controller/ChatGPTTranslateController.php
+++ b/src/Controller/ChatGPTTranslateController.php
@@ -2,11 +2,15 @@

 namespace Drupal\chatgpt_plugin\Controller;

+use Drupal\chatgpt_plugin\ChatGPTBatches;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Extension\ModuleHandler;
+use Drupal\node\Entity\Node;
+use Drupal\paragraphs\Entity\Paragraph;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use GuzzleHttp\Exception\GuzzleException;
 use Drupal\Core\Entity\EntityStorageException;
-use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Entity\EntityFieldManagerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -54,6 +58,13 @@ class ChatGPTTranslateController extends ControllerBase {
    */
   protected $gptApi;

+  /**
+   * If the paragraph module is enabled.
+   *
+   * @var bool
+   */
+  protected $isParagraphsEnabled;
+
   /**
    * Creates an ContentTranslationPreviewController object.
    *
@@ -68,17 +79,23 @@ class ChatGPTTranslateController extends ControllerBase {
    * @param \Drupal\chatgpt_plugin\GPTApiService $gpt_api
    *   Our custom GPT API service.
    */
-  public function __construct(EntityTypeManagerInterface $entity_type_manager,
-  EntityFieldManagerInterface $entity_field_manager,
-  ClientInterface $http_client,
-  RequestStack $requestStack,
-  GPTApiService $gpt_api
+  public function __construct(
+    EntityTypeManagerInterface $entity_type_manager,
+    EntityFieldManagerInterface $entity_field_manager,
+    ClientInterface $http_client,
+    RequestStack $requestStack,
+    GPTApiService $gpt_api,
+    ModuleHandler $moduleHandler
   ) {
     $this->entityTypeManager = $entity_type_manager;
     $this->entityFieldManager = $entity_field_manager;
     $this->httpclient = $http_client;
     $this->request = $requestStack;
     $this->gptApi = $gpt_api;
+    $this->isParagraphsEnabled = FALSE;
+    if ($moduleHandler->moduleExists('paragraphs')) {
+      $this->isParagraphsEnabled = TRUE;
+    }
   }

   /**
@@ -91,6 +108,7 @@ class ChatGPTTranslateController extends ControllerBase {
       $container->get('http_client'),
       $container->get('request_stack'),
       $container->get('chatgpt_plugin.gpt_api'),
+      $container->get('module_handler'),
     );
   }

@@ -107,13 +125,34 @@ class ChatGPTTranslateController extends ControllerBase {
    * @return \Symfony\Component\HttpFoundation\RedirectResponse
    *   the function will return a RedirectResponse to the translate
    *   overview page by showing a success or error message.
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   * @throws \Drupal\Core\Entity\EntityStorageException
    */
-  public function translate($lang_code, $lang_name, $node_id) {
+  public function translate(string $lang_code, string $lang_name, int $node_id) {
     $node = $this->entityTypeManager->getStorage('node')->load($node_id);
-    $entity_type_id = 'node';
-    $bundle = $node->bundle();
+    $bundleFields = $this->getFieldsToTranslate($node);
     $bundleFields['title']['label'] = 'Title';
+    $bundleFields['title']['value'] = $node->label();
+    $bundleFields['title']['isTranslatable'] = TRUE;
+
+    ChatGPTBatches::initiateBatchProcessing($bundleFields, $node, $lang_name, $lang_code);

+    $refererUrl = $this->request->getCurrentRequest()->server->get('HTTP_REFERER');
+
+    return batch_process($refererUrl);
+  }
+
+  /**
+   * Get the fields that can be translated.
+   *
+   * @param $entity
+   *  The entity to translate.
+   *
+   * @return array
+   *  The fields that can be translated.
+   */
+  private function getFieldsToTranslate($entity): array {
     $allowed_values = [
       'text',
       'text_with_summary',
@@ -121,93 +160,117 @@ class ChatGPTTranslateController extends ControllerBase {
       'string',
       'string_long',
     ];
-    foreach ($this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle) as $field_name => $field_definition) {
-      if (!empty($field_definition->getTargetBundle()) && in_array($field_definition->getType(), $allowed_values)) {
-        $bundleFields[$field_name]['label'] = $field_definition->getLabel();
-      }
-    }

-    foreach ($bundleFields as $field_name => $label) {
-      $field = $node->get($field_name);
-      $field_value = $field->value;
-      if ($field_value) {
-        $translated_text = $this->chatgptTranslateContent($field_value, $lang_name);
-        $bundleFields[$field_name]['translation'] = $translated_text;
-      }
+    if ($this->isParagraphsEnabled) {
+      $allowed_values[] = 'entity_reference_revisions';
     }
-    $insertTranslation = $this->insertTranslation($node, $lang_code, $bundleFields);

-    $refererUrl = $this->request->getCurrentRequest()->server->get('HTTP_REFERER');
-    $response = new RedirectResponse($refererUrl);
-    $response->send();
-    $messenger = $this->messenger();
-
-    if ($insertTranslation) {
-      $messenger->addStatus($this->t('Content translated successfully.'));
-    }
-    else {
-      $messenger->addError($this->t('There was some issue with content translation.'));
-    }
-    return $response;
+    return $this->getFieldsToTranslateRecursive($entity, $allowed_values);
   }

   /**
-   * Get the trnslated content by making API call to GPT API.
-   *
-   * @param string $input_text
-   *   Input prompt for the GPT API.
-   * @param string $lang_name
-   *   The target language name.
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *  The entity to translate.
+   * @param array $allowed_values
+   *  The allowed values.
    *
-   * @return string
-   *   The text translation received from GPT API.
+   * @return array
    */
-  public function chatgptTranslateContent($input_text, $lang_name) {
-    $prompt_text = "Translate this into " . $lang_name . " - " . $input_text;
+  private function getFieldsToTranslateRecursive(ContentEntityInterface $entity, array $allowed_values): array {
+    $bundleFields = [];

-    // Calling our custom GPT API service..
-    try {
-      $response = $this->gptApi->getGptResponse($prompt_text);
-    }
-    catch (GuzzleException $exception) {
-      // Error handling for ChatGPT API call.
-      $error_msg = $exception->getMessage();
-      return $error_msg;
+    foreach ($this->entityFieldManager->getFieldDefinitions($entity->getEntityTypeId(), $entity->bundle()) as $field_name => $field_definition) {
+      $bundleFields[$field_name] = [
+        'label' => $field_name,
+        'bundle' => $entity->bundle(),
+        'type' => $entity->getEntityTypeId(),
+      ];
+
+      if (!empty($field_definition->getTargetBundle()) && in_array($field_definition->getType(), $allowed_values)) {
+        $bundleFields[$field_name]['isTranslatable'] = TRUE;
+
+        if ($entity->get($field_name)->value) {
+          $bundleFields[$field_name]['value'] = $entity->get($field_name)->value;
+          $bundleFields[$field_name]['format'] = $entity->get($field_name)->format;
+        }
+
+        if ($this->isParagraphsEnabled && $field_definition->getType() === 'entity_reference_revisions') {
+          $referencedEntities = $entity->$field_name->referencedEntities();
+          foreach ($referencedEntities as $referencedEntity) {
+            if ($referencedEntity instanceof Paragraph) {
+              $bundleFields[$field_name]['subfields'][] = $this->getFieldsToTranslateRecursive($referencedEntity, $allowed_values);
+            }
+          }
+        }
+      }
+      else {
+        $bundleFields[$field_name]['isTranslatable'] = FALSE;
+        $bundleFields[$field_name]['field_value'] = $this->getEntityFieldValue($entity, $field_name);
+      }
     }

-    return $response;
+    return $bundleFields;
   }

   /**
-   * Adding the translation in database and linking it to the original node.
+   * Get the value of a field from an entity.
    *
-   * @param \Drupal\Core\Entity\ContentEntityInterface $node
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
    *   The entity object.
-   * @param string $target_language
-   *   The target language.
-   * @param array $bundleFields
-   *   An array of field name and their translation.
+   * @param string $field_name
+   *   The machine name of the field.
+   *
+   * @return mixed
+   *   The field value, or NULL if the field does not exist or is empty.
    */
-  public function insertTranslation(ContentEntityInterface $node, $target_language, array $bundleFields) {
-    if (!$node->hasTranslation($target_language)) {
-      $node_translation = $node->addTranslation($target_language);
-      $status = TRUE;
-
-      foreach ($bundleFields as $field_name => $val) {
-        if ($field_name != 'moderation_state') {
-          $node_translation->$field_name->value = $val['translation'] ?? NULL;
-          $node_translation->$field_name->format = 'full_html';
-        }
-      }
-
-      try {
-        $node_translation->save();
+  function getEntityFieldValue(ContentEntityInterface $entity, string $field_name) {
+    if ($entity->hasField($field_name) && !$entity->get($field_name)->isEmpty()) {
+      $field = $entity->get($field_name);
+      $field_type = $field->getFieldDefinition()->getType();
+      switch ($field_type) {
+        case 'string':
+        case 'string_long':
+        case 'text':
+        case 'list_integer':
+        case 'list_string':
+        case 'decimal':
+        case 'float':
+        case 'integer':
+        case 'date':
+        case 'datetime':
+        case 'text_long':
+          return $field->value;
+        case 'text_with_summary':
+          return [
+            'value' => $field->value,
+            'summary' => $field->summary,
+            'format' => $field->format,
+          ];
+        case 'entity_reference_revisions':
+        case 'entity_reference':
+          return $field->referencedEntities();
+        case 'image':
+          return [
+            'alt' => $field->alt,
+            'target_id' => $field->target_id,
+          ];
+        case 'file':
+          return [
+            'target_id' => $field->target_id,
+          ];
+        case 'link':
+          return [
+            'uri' => $field->uri,
+            'title' => $field->title,
+          ];
+        case 'boolean':
+          return (bool) $field->value;
+        default:
+          return $field->getValue();
       }
-      catch (EntityStorageException $e) {
-        $status = FALSE;
-      }
-      return $status;
     }
+
+    return NULL;
   }

 }
diff --git a/src/Form/ChatGPTConfigForm.php b/src/Form/ChatGPTConfigForm.php
index 5391278..038b6b0 100644
--- a/src/Form/ChatGPTConfigForm.php
+++ b/src/Form/ChatGPTConfigForm.php
@@ -127,6 +127,7 @@ class ChatGPTConfigForm extends ConfigFormBase {
       '#description' => $this->t('Please provide the OpenAI API Access Token here.'),
       '#default_value' => $config->get('access_token'),
       '#required' => TRUE,
+      '#maxlength' => 164
     ];

     $form['chatgpt_max_token'] = [
--
2.37.0

