From 004314668a5cf2f5c79ff0e2f9023a2d9397a428 Mon Sep 17 00:00:00 2001 From: Benjamin Rasmussen Date: Tue, 17 Dec 2024 14:31:54 +0100 Subject: [PATCH 1/3] Update BnfImporter to also import text paragraphs. This feels a bit dirty, but for now, until we've cleared how to deal with GraphQL schemas/codegen, it'll do. Only text_body paragraphs are imported. --- .../custom/bnf/src/Services/BnfImporter.php | 144 ++++++++++++++++-- 1 file changed, 132 insertions(+), 12 deletions(-) diff --git a/web/modules/custom/bnf/src/Services/BnfImporter.php b/web/modules/custom/bnf/src/Services/BnfImporter.php index 138db8519..371556350 100644 --- a/web/modules/custom/bnf/src/Services/BnfImporter.php +++ b/web/modules/custom/bnf/src/Services/BnfImporter.php @@ -6,10 +6,12 @@ use Drupal\bnf\Exception\AlreadyExistsException; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\paragraphs\ParagraphInterface; use GuzzleHttp\ClientInterface; use Psr\Log\LoggerInterface; use function Safe\json_decode; use function Safe\parse_url; +use function Safe\preg_replace; /** * Service related to importing content from an external source. @@ -20,6 +22,15 @@ */ class BnfImporter { + const ALLOWED_PARAGRAPHS = [ + 'text_body', + ]; + + /** + * The BNF UUID of the content that we import. + */ + protected string $uuid; + /** * Constructor. */ @@ -30,10 +41,128 @@ public function __construct( protected LoggerInterface $logger, ) {} + /** + * Building the query we use to get data from source. + */ + protected function getQuery(string $queryName): string { + // Example of GraphQL query: "nodeArticle". + return <<uuid") { + title + paragraphs { + ... on ParagraphTextBody { + __typename + body { + format, + value + } + } + } + } + } + GRAPHQL; + + } + + /** + * Parses paragraphs from GraphQL node data into Drupal-compatible structures. + * + * @param mixed[] $nodeData + * The GraphQL node data containing paragraphs. + * + * @return mixed[] + * Array of paragraph values, that we can use to create paragraph entities. + */ + protected function parseParagraphs(array $nodeData) { + $parsedParagraphs = []; + + // Ensure paragraphs exist in the GraphQL response. + if (empty($nodeData['paragraphs'])) { + return $parsedParagraphs; + } + + foreach ($nodeData['paragraphs'] as $paragraphData) { + $type = $paragraphData['__typename'] ?? ''; + + // Convert typename to Drupal paragraph bundle name. + $bundleName = $this->graphqlTypeToBundle($type); + + if (!in_array($bundleName, self::ALLOWED_PARAGRAPHS)) { + continue; + } + + $paragraph = ['type' => $bundleName]; + + // Map fields dynamically. + foreach ($paragraphData as $key => $value) { + if ($key === '__typename') { + continue; + } + + // Assume Drupal uses field names like "field_{key}". + $drupalFieldName = 'field_' . $key; + $paragraph[$drupalFieldName] = $value; + } + + $parsedParagraphs[] = $paragraph; + } + + return $parsedParagraphs; + } + + /** + * Creating the paragraphs, that we will add to the nodes. + * + * @param mixed[] $nodeData + * The GraphQL node data containing paragraphs. + * + * @return \Drupal\paragraphs\ParagraphInterface[] + * The paragraph entities. + */ + protected function getParagraphs(array $nodeData): array { + $parsedParagraphs = $this->parseParagraphs($nodeData); + $storage = $this->entityTypeManager->getStorage('paragraph'); + $paragraphs = []; + foreach ($parsedParagraphs as $paragraphData) { + $paragraph = $storage->create($paragraphData); + + if ($paragraph instanceof ParagraphInterface) { + $paragraph->save(); + $paragraphs[] = $paragraph; + } + } + + return $paragraphs; + } + + /** + * Converts a GraphQL typename to a Drupal paragraph bundle name. + * + * @param string $typeName + * The GraphQL typename (e.g., ParagraphTextBody). + * + * @return string + * The Drupal paragraph bundle name (e.g., text_body). + */ + protected function graphqlTypeToBundle(string $typeName): string { + // Removing 'Paragraph' prefix. + $typeName = preg_replace('/^Paragraph/', '', $typeName); + + // Converting CamelCase to snake_case. + $pattern = '/(?<=\\w)(?=[A-Z])|(?<=[a-z])(?=[0-9])/'; + $typeName = preg_replace($pattern, '_', $typeName); + + return strtolower($typeName); + } + /** * Importing a node from a GraphQL source endpoint. */ public function importNode(string $uuid, string $endpointUrl, string $nodeType = 'article'): void { + $this->uuid = $uuid; + $queryName = 'node' . ucfirst($nodeType); + $nodeStorage = $this->entityTypeManager->getStorage('node'); $existingNodes = @@ -48,18 +177,6 @@ public function importNode(string $uuid, string $endpointUrl, string $nodeType = throw new AlreadyExistsException('Cannot import node - already exists.'); } - // Example of GraphQL query: "nodeArticle". - $queryName = 'node' . ucfirst($nodeType); - - // For now, we only support the title of the nodes. - $query = <<getQuery($queryName); + $response = $this->httpClient->request('post', $endpointUrl, [ 'headers' => [ 'Content-Type' => 'application/json', @@ -95,6 +214,7 @@ public function importNode(string $uuid, string $endpointUrl, string $nodeType = try { $nodeData['type'] = $nodeType; $nodeData['uuid'] = $uuid; + $nodeData['field_paragraphs'] = $this->getParagraphs($nodeData); $node = $nodeStorage->create($nodeData); $node->save(); From fcceb1b391d9bbd5bbc2d5ceaff1c78a8b9f4b43 Mon Sep 17 00:00:00 2001 From: Benjamin Rasmussen Date: Tue, 17 Dec 2024 14:31:54 +0100 Subject: [PATCH 2/3] Update BnfImporter to also import text paragraphs. This feels a bit dirty, but for now, until we've cleared how to deal with GraphQL schemas/codegen, it'll do. Only text_body paragraphs are imported. --- web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php b/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php index b4803e9f7..6886b86d6 100644 --- a/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php +++ b/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php @@ -56,6 +56,7 @@ public function exportNode(NodeInterface $node): void { try { $bnfServer = (string) getenv('BNF_SERVER_GRAPHQL_ENDPOINT'); + $bnfServer = 'https://dapple-cms.local/graphql'; if (!filter_var($bnfServer, FILTER_VALIDATE_URL)) { throw new \InvalidArgumentException('The provided BNF server URL is not valid.'); From 6dc3631ba75d2e501a68d37ed2b2ea76566b9856 Mon Sep 17 00:00:00 2001 From: Benjamin Rasmussen Date: Tue, 17 Dec 2024 22:14:57 +0100 Subject: [PATCH 3/3] BNF: Library import forms. DDFHER-166 Allowing the editor to input either a UUID or URL to a content. We will then display a preview, where they can see the title. If they wish to continue, they can import, and the node will be created, and they will be redirected to the edit form. --- web/modules/custom/bnf/bnf.module | 26 +++++ .../bnf/bnf_client/bnf_client.links.menu.yml | 6 ++ .../bnf/bnf_client/bnf_client.permissions.yml | 2 + .../bnf/bnf_client/bnf_client.routing.yml | 16 +++ .../src/Form/BnfImportConfirmForm.php | 100 ++++++++++++++++++ .../bnf/bnf_client/src/Form/BnfImportForm.php | 89 ++++++++++++++++ .../bnf_client/src/Services/BnfExporter.php | 1 - .../custom/bnf/src/Services/BnfImporter.php | 36 +++++-- 8 files changed, 264 insertions(+), 12 deletions(-) create mode 100644 web/modules/custom/bnf/bnf_client/bnf_client.links.menu.yml create mode 100644 web/modules/custom/bnf/bnf_client/bnf_client.routing.yml create mode 100644 web/modules/custom/bnf/bnf_client/src/Form/BnfImportConfirmForm.php create mode 100644 web/modules/custom/bnf/bnf_client/src/Form/BnfImportForm.php diff --git a/web/modules/custom/bnf/bnf.module b/web/modules/custom/bnf/bnf.module index d19fd7ed8..11e83cce5 100644 --- a/web/modules/custom/bnf/bnf.module +++ b/web/modules/custom/bnf/bnf.module @@ -3,6 +3,7 @@ use Drupal\bnf\BnfStateEnum; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\node\NodeInterface; /** * Implements hook_entity_base_field_info(). @@ -41,3 +42,28 @@ function bnf_get_bnf_state_allowed_values(): array { } return $values; } + +/** + * Implements theme_preprocess_html(). + * + * Adding the node UUID as a metatag, that we can use when the user submits + * a URL to the BNF import form. + */ +function bnf_preprocess_html(array &$variables): void { + $route = \Drupal::routeMatch(); + $node = $route->getParameter('node'); + + if ($route->getRouteName() !== 'entity.node.canonical' || !($node instanceof NodeInterface)) { + return; + } + + $uuid_metatag = [ + '#tag' => 'meta', + '#attributes' => [ + 'name' => 'uuid', + 'content' => $node->uuid(), + ], + ]; + + $variables['page']['#attached']['html_head'][] = [$uuid_metatag, 'node-uuid']; +} diff --git a/web/modules/custom/bnf/bnf_client/bnf_client.links.menu.yml b/web/modules/custom/bnf/bnf_client/bnf_client.links.menu.yml new file mode 100644 index 000000000..5bce3976d --- /dev/null +++ b/web/modules/custom/bnf/bnf_client/bnf_client.links.menu.yml @@ -0,0 +1,6 @@ +--- +bnf_client.import_content: + title: 'Import BNF content' + parent: system.admin_content + route_name: bnf_client.import_form + weight: 10 diff --git a/web/modules/custom/bnf/bnf_client/bnf_client.permissions.yml b/web/modules/custom/bnf/bnf_client/bnf_client.permissions.yml index e8a3704a5..b3a94242f 100644 --- a/web/modules/custom/bnf/bnf_client/bnf_client.permissions.yml +++ b/web/modules/custom/bnf/bnf_client/bnf_client.permissions.yml @@ -1,2 +1,4 @@ bnf client export nodes: title: 'Can export nodes to BNF' +bnf client import nodes: + title: 'Can import nodes from BNF' diff --git a/web/modules/custom/bnf/bnf_client/bnf_client.routing.yml b/web/modules/custom/bnf/bnf_client/bnf_client.routing.yml new file mode 100644 index 000000000..6d3987e66 --- /dev/null +++ b/web/modules/custom/bnf/bnf_client/bnf_client.routing.yml @@ -0,0 +1,16 @@ +--- +bnf_client.import_form: + path: '/admin/bnf/import' + defaults: + _form: '\Drupal\bnf_client\Form\BnfImportForm' + _title: 'Import' + requirements: + _permission: 'bnf client import nodes' + +bnf_client.import_confirm_form: + path: '/admin/bnf/import/{uuid}' + defaults: + _form: '\Drupal\bnf_client\Form\BnfImportConfirmForm' + _title: 'Confirm import' + requirements: + _permission: 'bnf client import nodes' diff --git a/web/modules/custom/bnf/bnf_client/src/Form/BnfImportConfirmForm.php b/web/modules/custom/bnf/bnf_client/src/Form/BnfImportConfirmForm.php new file mode 100644 index 000000000..1a8130673 --- /dev/null +++ b/web/modules/custom/bnf/bnf_client/src/Form/BnfImportConfirmForm.php @@ -0,0 +1,100 @@ +get('current_route_match'), + $container->get('bnf.importer') + ); + } + + /** + * {@inheritDoc} + */ + public function getFormId(): string { + return 'bnf_import_form_form'; + } + + /** + * {@inheritDoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $form['#title'] = $this->t('Confirm import of BNF content', [], ['context' => 'BNF']); + + $uuid = $this->routeMatch->getParameter('uuid'); + $bnfServer = (string) getenv('BNF_SERVER_GRAPHQL_ENDPOINT'); + + $form_state->set('uuid', $uuid); + $form_state->set('bnfServer', $bnfServer); + + $nodeData = $this->bnfImporter->loadNodeData($uuid, $bnfServer); + + $form['uuid'] = [ + '#title' => 'UUID', + '#type' => 'textfield', + '#default_value' => $uuid, + '#disabled' => TRUE, + ]; + + $form['label'] = [ + '#title' => $this->t('Content label', [], ['context' => 'BNF']), + '#type' => 'textfield', + '#default_value' => $nodeData['title'] ?? NULL, + '#disabled' => TRUE, + ]; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Import content'), + ]; + + return $form; + } + + /** + * {@inheritDoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + + } + + /** + * {@inheritDoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $uuid = $form_state->get('uuid'); + $bnfServer = $form_state->get('bnfServer'); + $node = $this->bnfImporter->importNode($uuid, $bnfServer); + $form_state->setRedirect('entity.node.edit_form', ['node' => $node->id()]); + } + +} diff --git a/web/modules/custom/bnf/bnf_client/src/Form/BnfImportForm.php b/web/modules/custom/bnf/bnf_client/src/Form/BnfImportForm.php new file mode 100644 index 000000000..fffb83073 --- /dev/null +++ b/web/modules/custom/bnf/bnf_client/src/Form/BnfImportForm.php @@ -0,0 +1,89 @@ +t('Import nodes from BNF', [], ['context' => 'BNF']); + $form['reference'] = [ + '#type' => 'textfield', + '#title' => $this->t('URL or UUID of content'), + ]; + + $form['actions'] = [ + '#type' => 'actions', + ]; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Preview content'), + ]; + + return $form; + } + + /** + * {@inheritDoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + $reference = $form_state->getValue('reference'); + $uuid = $this->parseAndValidateUuid($reference); + + if (empty($uuid)) { + $form_state->setErrorByName( + 'reference', + $this->t('Invalid URL or UUID.', [], ['context' => 'BNF']) + ); + } + + $form_state->set('uuid', $uuid); + } + + /** + * {@inheritDoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $uuid = $form_state->get('uuid'); + $form_state->setRedirect('bnf_client.import_confirm_form', ['uuid' => $uuid]); + } + + /** + * Getting and validate a UUID from string or URL. + */ + protected function parseAndValidateUuid(string $reference): string|false { + // Detect if reference is a URL. + if (filter_var($reference, FILTER_VALIDATE_URL)) { + // Finding the metatag that contains the UUID. + $meta_tags = get_meta_tags($reference); + $reference = $meta_tags['uuid'] ?? NULL; + } + + return Uuid::isValid($reference) ? $reference : FALSE; + } + +} diff --git a/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php b/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php index 6886b86d6..b4803e9f7 100644 --- a/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php +++ b/web/modules/custom/bnf/bnf_client/src/Services/BnfExporter.php @@ -56,7 +56,6 @@ public function exportNode(NodeInterface $node): void { try { $bnfServer = (string) getenv('BNF_SERVER_GRAPHQL_ENDPOINT'); - $bnfServer = 'https://dapple-cms.local/graphql'; if (!filter_var($bnfServer, FILTER_VALIDATE_URL)) { throw new \InvalidArgumentException('The provided BNF server URL is not valid.'); diff --git a/web/modules/custom/bnf/src/Services/BnfImporter.php b/web/modules/custom/bnf/src/Services/BnfImporter.php index 371556350..dfcb4b516 100644 --- a/web/modules/custom/bnf/src/Services/BnfImporter.php +++ b/web/modules/custom/bnf/src/Services/BnfImporter.php @@ -6,6 +6,7 @@ use Drupal\bnf\Exception\AlreadyExistsException; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\node\NodeInterface; use Drupal\paragraphs\ParagraphInterface; use GuzzleHttp\ClientInterface; use Psr\Log\LoggerInterface; @@ -26,11 +27,6 @@ class BnfImporter { 'text_body', ]; - /** - * The BNF UUID of the content that we import. - */ - protected string $uuid; - /** * Constructor. */ @@ -44,11 +40,11 @@ public function __construct( /** * Building the query we use to get data from source. */ - protected function getQuery(string $queryName): string { + protected function getQuery(string $queryName, string $uuid): string { // Example of GraphQL query: "nodeArticle". return <<uuid") { + $queryName(id: "$uuid") { title paragraphs { ... on ParagraphTextBody { @@ -157,10 +153,12 @@ protected function graphqlTypeToBundle(string $typeName): string { } /** - * Importing a node from a GraphQL source endpoint. + * Loading the node data from a GraphQL endpoint. + * + * @return mixed[] + * Array of node values, that we can use to create node entities. */ - public function importNode(string $uuid, string $endpointUrl, string $nodeType = 'article'): void { - $this->uuid = $uuid; + public function loadNodeData(string $uuid, string $endpointUrl, string $nodeType = 'article'): array { $queryName = 'node' . ucfirst($nodeType); $nodeStorage = $this->entityTypeManager->getStorage('node'); @@ -188,7 +186,7 @@ public function importNode(string $uuid, string $endpointUrl, string $nodeType = throw new \InvalidArgumentException('The provided callback URL must use HTTPS.'); } - $query = $this->getQuery($queryName); + $query = $this->getQuery($queryName, $uuid); $response = $this->httpClient->request('post', $endpointUrl, [ 'headers' => [ @@ -211,11 +209,25 @@ public function importNode(string $uuid, string $endpointUrl, string $nodeType = throw new \Exception('Could not retrieve content values.'); } + return $nodeData; + + } + + /** + * Importing a node from a GraphQL source endpoint. + */ + public function importNode(string $uuid, string $endpointUrl, string $nodeType = 'article'): NodeInterface { + $nodeStorage = $this->entityTypeManager->getStorage('node'); + try { + $nodeData = $this->loadNodeData($uuid, $endpointUrl, $nodeType); + $nodeData['type'] = $nodeType; $nodeData['uuid'] = $uuid; + $nodeData['status'] = NodeInterface::NOT_PUBLISHED; $nodeData['field_paragraphs'] = $this->getParagraphs($nodeData); + /** @var \Drupal\node\NodeInterface $node */ $node = $nodeStorage->create($nodeData); $node->save(); @@ -236,6 +248,8 @@ public function importNode(string $uuid, string $endpointUrl, string $nodeType = '@type' => $nodeType, ]); + return $node; + } }