diff --git a/cambridge_migrations.services.yml b/cambridge_migrations.services.yml index cc08c86b99fb407177091e91235d3bd0c1ea1962..fab37c32e01f3c2fe1718330807e340d9d02c48e 100644 --- a/cambridge_migrations.services.yml +++ b/cambridge_migrations.services.yml @@ -10,3 +10,9 @@ services: arguments: ['@entity_type.manager', '@file_system', '@database'] tags: - { name: drush.command } + + cambridge_migrations.cleanup_command: + class: Drupal\cambridge_migrations\Drush\Commands\MigrateCleanupCommand + arguments: ['@entity_type.manager'] + tags: + - { name: drush.command } diff --git a/drush.services.yml b/drush.services.yml index cc08c86b99fb407177091e91235d3bd0c1ea1962..fab37c32e01f3c2fe1718330807e340d9d02c48e 100644 --- a/drush.services.yml +++ b/drush.services.yml @@ -10,3 +10,9 @@ services: arguments: ['@entity_type.manager', '@file_system', '@database'] tags: - { name: drush.command } + + cambridge_migrations.cleanup_command: + class: Drupal\cambridge_migrations\Drush\Commands\MigrateCleanupCommand + arguments: ['@entity_type.manager'] + tags: + - { name: drush.command } diff --git a/src/Drush/Commands/MigrateCleanupCommand.php b/src/Drush/Commands/MigrateCleanupCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..d6cd92133ef05bde1dbc0790096baa8b7d45743f --- /dev/null +++ b/src/Drush/Commands/MigrateCleanupCommand.php @@ -0,0 +1,277 @@ +<?php + +namespace Drupal\cambridge_migrations\Drush\Commands; + +use Drush\Commands\DrushCommands; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\paragraphs\Entity\Paragraph; +use Symfony\Component\DependencyInjection\ContainerInterface; +use DOMDocument; +use DOMElement; +use DOMNode; + +/** + * Drush command for cleaning up HTML content in text paragraphs. + * + * This command: + * - Queries all paragraphs of type "text" + * - For each paragraph, processes its HTML content to: + * - Remove all class and style attributes from every element + * - Recursively remove empty tags or tags with only non-breaking spaces + * - Remove all <style> and <script> elements entirely + * + * To run: + * ddev drush cambridge:migrate-cleanup + */ +class MigrateCleanupCommand extends DrushCommands { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Constructs a new MigrateCleanupCommand object. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + */ + public function __construct( + EntityTypeManagerInterface $entity_type_manager + ) { + parent::__construct(); + $this->entityTypeManager = $entity_type_manager; + } + + /** + * Clean up HTML content in text paragraphs. + * + * @command cambridge:migrate-cleanup + * @aliases cm-cleanup + */ + public function migrateCleanup() { + try { + // Query all paragraphs of type "text". + $paragraph_storage = $this->entityTypeManager->getStorage('paragraph'); + $query = $paragraph_storage->getQuery() + ->condition('type', 'text') + ->accessCheck(FALSE); + $paragraph_ids = $query->execute(); + + if (empty($paragraph_ids)) { + $this->logger()->warning(dt('No text paragraphs found to clean up.')); + return; + } + + $this->logger()->notice(dt('Found @count text paragraphs to process.', [ + '@count' => count($paragraph_ids), + ])); + + $success_count = 0; + $error_count = 0; + + // Load paragraphs in chunks to avoid memory issues. + $chunk_size = 50; + $chunks = array_chunk($paragraph_ids, $chunk_size, TRUE); + + foreach ($chunks as $chunk) { + $paragraphs = $paragraph_storage->loadMultiple($chunk); + + foreach ($paragraphs as $paragraph_id => $paragraph) { + try { + // Ensure we're working with a Paragraph entity + if (!($paragraph instanceof Paragraph)) { + $paragraph = Paragraph::load($paragraph_id); + if (!$paragraph) { + $this->logger()->warning(dt('Could not load paragraph @id', [ + '@id' => $paragraph_id, + ])); + continue; + } + } + + // Skip if paragraph doesn't have a text field. + if (!$paragraph->hasField('field_text')) { + $this->logger()->warning(dt('Skipping paragraph @id - no text field found', [ + '@id' => $paragraph->id(), + ])); + continue; + } + + // Get the current HTML content. + $text_field = $paragraph->get('field_text'); + $html = $text_field->value; + $format = $text_field->format; + + if (empty($html)) { + $this->logger()->notice(dt('Skipping paragraph @id - empty content', [ + '@id' => $paragraph->id(), + ])); + continue; + } + + // Process the HTML content. + $cleaned_html = $this->cleanupHtml($html); + + // Update the paragraph with the cleaned HTML. + $paragraph->set('field_text', [ + 'value' => $cleaned_html, + 'format' => $format, + ]); + $paragraph->save(); + + $success_count++; + $this->logger()->notice(dt('Successfully cleaned up paragraph @id', [ + '@id' => $paragraph->id(), + ])); + } + catch (\Exception $e) { + $error_count++; + $this->logger()->error(dt('Error cleaning up paragraph @id: @message', [ + '@id' => $paragraph->id(), + '@message' => $e->getMessage(), + ])); + } + } + + // Clear static caches to avoid memory issues. + $paragraph_storage->resetCache($chunk); + } + + $this->logger()->notice(dt('Cleanup complete. Successes: @success, Errors: @errors', [ + '@success' => $success_count, + '@errors' => $error_count, + ])); + } + catch (\Exception $e) { + $this->logger()->error(dt('Cleanup failed: @message', [ + '@message' => $e->getMessage(), + ])); + } + } + + /** + * Clean up HTML content. + * + * @param string $html + * The HTML content to clean up. + * + * @return string + * The cleaned up HTML content. + */ + protected function cleanupHtml($html) { + // If the HTML is empty, return it as is. + if (empty($html)) { + return $html; + } + + // Create a new DOMDocument. + $doc = new DOMDocument(); + + // Preserve whitespace to avoid unwanted text nodes. + $doc->preserveWhiteSpace = true; + + // Disable error reporting temporarily to suppress warnings about HTML5 tags. + $previous_value = libxml_use_internal_errors(true); + + // Load the HTML content. + // Add a wrapper to ensure proper parsing of fragments. + $doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + // Restore error reporting. + libxml_use_internal_errors($previous_value); + + // Get the body element. + $body = $doc->getElementsByTagName('body')->item(0); + + // Process the body element recursively. + $this->processNode($body); + + // Save the processed HTML. + $processed_html = ''; + $children = $body->childNodes; + + foreach ($children as $child) { + $processed_html .= $doc->saveHTML($child); + } + + return $processed_html; + } + + /** + * Process a DOM node recursively. + * + * @param \DOMNode $node + * The DOM node to process. + * + * @return bool + * TRUE if the node should be kept, FALSE if it should be removed. + */ + protected function processNode(DOMNode $node) { + // If it's not an element node, keep it unless it's empty. + if ($node->nodeType !== XML_ELEMENT_NODE) { + // For text nodes, check if they contain only whitespace or non-breaking spaces. + if ($node->nodeType === XML_TEXT_NODE) { + $text = trim($node->textContent); + $text = str_replace(' ', '', $text); + $text = str_replace("\xC2\xA0", '', $text); // UTF-8 non-breaking space + return !empty($text); + } + return true; + } + + // If it's a script or style element, remove it. + if (in_array(strtolower($node->nodeName), ['script', 'style'])) { + $node->parentNode->removeChild($node); + return false; + } + + // If it's an element node, remove class and style attributes. + if ($node instanceof DOMElement) { + $node->removeAttribute('class'); + $node->removeAttribute('style'); + } + + // Process child nodes recursively. + $children = []; + foreach ($node->childNodes as $child) { + $children[] = $child; + } + + $keep_node = false; + foreach ($children as $child) { + $keep_child = $this->processNode($child); + if (!$keep_child && $child->parentNode) { + $child->parentNode->removeChild($child); + } + else { + $keep_node = true; + } + } + + // Only check specific tags for emptiness + $tags_to_check = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', 'em', 'i', + 'small', 'mark', 'del', 'ins', 'sub', 'sup', 'q', 'cite', 'pre']; + + // If the node has no children and no text content, check if it's in our list of tags to check + if (!$keep_node && $node->childNodes->length === 0) { + if (in_array(strtolower($node->nodeName), $tags_to_check)) { + return false; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager') + ); + } + +} diff --git a/src/Drush/Commands/MigrateTextBlocksCommand.php b/src/Drush/Commands/MigrateTextBlocksCommand.php index 50cfa66634b19aaad49b09d42c9e2c984c7c8a41..dcf6a33bc19b6e5c44e86a0347cd75216f937822 100644 --- a/src/Drush/Commands/MigrateTextBlocksCommand.php +++ b/src/Drush/Commands/MigrateTextBlocksCommand.php @@ -108,6 +108,32 @@ class MigrateTextBlocksCommand extends DrushCommands { } } + /** + * Remove all paragraphs from a node's sidebar items field. + */ + protected function removeSidebarItems($node) { + if (!$node->hasField('field_sidebar_items')) { + return; + } + + $paragraphs = $node->get('field_sidebar_items')->referencedEntities(); + $removed_count = 0; + + foreach ($paragraphs as $paragraph) { + $paragraph->delete(); + $removed_count++; + } + + if ($removed_count > 0) { + $node->set('field_sidebar_items', []); + $node->save(); + $this->logger()->notice(dt('Removed @count sidebar paragraph(s) from node @nid', [ + '@count' => $removed_count, + '@nid' => $node->id(), + ])); + } + } + /** * Helper to find the new D10 Media entity that corresponds to a given old D7 file fid. * @@ -169,6 +195,68 @@ class MigrateTextBlocksCommand extends DrushCommands { return $requested_view_mode; } + /** + * Process text block content to convert media tokens and clean up. + * + * @param string $content + * The raw content from D7. + * @param string $format + * The text format to use. + * + * @return array + * An array with 'value' and 'format' keys for the processed content. + */ + protected function processTextBlockContent($content, $format) { + // Clean up content slightly. + $content = $this->cleanContent($content); + + // Remove literal occurrences of 'text_block'. + $content = str_replace('text_block', '', $content); + + // Determine the text format to use in D10: if not found, fall back to 'filtered_html' + $paragraph_format = !empty($format) ? $format : 'filtered_html'; + + // Convert D7 media tokens to <drupal-media>. + $content = preg_replace_callback( + '/\[\[\s*(\{.*?"fid":.*?\})\s*\]\]/s', + function ($matches) { + $json_string = $matches[1]; + $embed_data = json_decode($json_string, TRUE); + if (is_array($embed_data) && isset($embed_data['fid'])) { + // Get the correct new media entity. + $old_fid = $embed_data['fid']; + $media = $this->getMediaEntityFromOldFid($old_fid); + if ($media) { + // If "fields.format" is set in the old JSON, treat that as the requested view mode. + // Otherwise default to "media_library". + $requested_mode = !empty($embed_data['fields']['format']) + ? $embed_data['fields']['format'] + : 'media_library'; + + // Validate if this view mode actually exists in D10; fallback if not. + $view_mode = $this->getValidMediaViewMode($requested_mode); + + $uuid = $media->uuid(); + return '<drupal-media data-entity-type="media" data-entity-uuid="' . $uuid . '" data-view-mode="' . $view_mode . '"></drupal-media>'; + } + else { + // No mapping => log warning & keep original token. + $this->logger()->warning(sprintf('No media mapping found for old fid "%s".', $old_fid)); + return $matches[0]; + } + } + // If JSON parse fails or no "fid", leave token as-is. + return $matches[0]; + }, + $content + ); + + return [ + 'value' => $content, + 'format' => $paragraph_format, + ]; + } + /** * Migrate text blocks to text paragraphs. * @@ -177,10 +265,47 @@ class MigrateTextBlocksCommand extends DrushCommands { */ public function migrateTextBlocks() { try { - // Query the D7 database for text blocks. - $query = $this->sourceDb->select('paragraphs_item', 'p') - ->fields('p', ['item_id', 'bundle', 'field_name']) - ->condition('p.bundle', 'text_block'); + // First, migrate main content text blocks + $this->migrateMainContentTextBlocks(); + + // Rebuild caches between migrations + drupal_flush_all_caches(); + + // Then, create sidebar paragraph items + $sidebar_items = $this->createSidebarParagraphItems(); + + // Finally, insert sidebar items into the database + $this->insertSidebarItemsIntoDatabase($sidebar_items); + + // Rebuild caches again + drupal_flush_all_caches(); + + $this->logger()->notice(dt('Text block migration complete for both main content and sidebar items.')); + } + catch (\Exception $e) { + $this->logger()->error(dt('Migration failed: @message', [ + '@message' => $e->getMessage(), + ])); + } + } + + /** + * Migrate text blocks to text paragraphs in the main content field. + */ + protected function migrateMainContentTextBlocks() { + try { + // Query the D7 database for text blocks in the main content field. + $query = $this->sourceDb->select('field_data_field_content_items', 'n') + ->fields('n', ['entity_id', 'field_content_items_value']); + + // Join with paragraphs_item to get only text blocks + $query->join( + 'paragraphs_item', + 'p', + 'p.item_id = n.field_content_items_value AND p.bundle = :bundle', + [':bundle' => 'text_block'] + ); + $query->fields('p', ['item_id', 'bundle', 'field_name']); // Join with field_data_field_paragraph_heading for headings. $query->leftJoin( @@ -200,17 +325,9 @@ class MigrateTextBlocksCommand extends DrushCommands { ); $query->fields('t', ['field_paragraph_text_content_value', 'field_paragraph_text_content_format']); - // Join with node reference field to get parent node ID. - $query->leftJoin( - 'field_data_field_content_items', - 'n', - 'p.item_id = n.field_content_items_value' - ); - $query->fields('n', ['entity_id']); - $text_blocks = $query->execute()->fetchAll(); if (empty($text_blocks)) { - $this->logger()->warning(dt('No text blocks found to migrate.')); + $this->logger()->warning(dt('No main content text blocks found to migrate.')); return; } @@ -236,50 +353,10 @@ class MigrateTextBlocksCommand extends DrushCommands { $content .= $block->field_paragraph_text_content_value; } - // Clean up content slightly. - $content = $this->cleanContent($content); - - // Remove literal occurrences of 'text_block'. - $content = str_replace('text_block', '', $content); - - // Determine the text format to use in D10: if not found, fall back to 'filtered_html'. - $paragraph_format = !empty($block->field_paragraph_text_content_format) - ? $block->field_paragraph_text_content_format - : 'filtered_html'; - - // Convert D7 media tokens to <drupal-media>. - $content = preg_replace_callback( - '/\[\[\s*(\{.*?"fid":.*?\})\s*\]\]/s', - function ($matches) { - $json_string = $matches[1]; - $embed_data = json_decode($json_string, TRUE); - if (is_array($embed_data) && isset($embed_data['fid'])) { - // Get the correct new media entity. - $old_fid = $embed_data['fid']; - $media = $this->getMediaEntityFromOldFid($old_fid); - if ($media) { - // If "fields.format" is set in the old JSON, treat that as the requested view mode. - // Otherwise default to "media_library". - $requested_mode = !empty($embed_data['fields']['format']) - ? $embed_data['fields']['format'] - : 'media_library'; - - // Validate if this view mode actually exists in D10; fallback if not. - $view_mode = $this->getValidMediaViewMode($requested_mode); - - $uuid = $media->uuid(); - return '<drupal-media data-entity-type="media" data-entity-uuid="' . $uuid . '" data-view-mode="' . $view_mode . '"></drupal-media>'; - } - else { - // No mapping => log warning & keep original token. - $this->logger()->warning(sprintf('No media mapping found for old fid "%s".', $old_fid)); - return $matches[0]; - } - } - // If JSON parse fails or no "fid", leave token as-is. - return $matches[0]; - }, - $content + // Process the content + $processed_content = $this->processTextBlockContent( + $content, + $block->field_paragraph_text_content_format ); // Load the corresponding D10 node. @@ -294,42 +371,253 @@ class MigrateTextBlocksCommand extends DrushCommands { // Create a new paragraph of type 'text'. $paragraph = Paragraph::create([ 'type' => 'text', + 'langcode' => 'und', // Set paragraph language to 'und' (undefined) to match existing data 'field_text' => [ - 'value' => $content, - 'format' => $paragraph_format, + 'value' => $processed_content['value'], + 'format' => $processed_content['format'], // Use the imported format for main content ], ]); $paragraph->save(); - // Attach the new paragraph to the node’s field_paragraph. + // Attach the new paragraph to the node's field_paragraph. $node->field_paragraph[] = [ 'target_id' => $paragraph->id(), 'target_revision_id' => $paragraph->getRevisionId(), ]; $node->save(); + + // Debug: Log the structure of the main content field after saving + $this->logger()->debug(dt('Main content field structure after save for node @nid: @count paragraphs', [ + '@nid' => $node->id(), + '@count' => count($node->field_paragraph), + ])); $success_count++; - $this->logger()->notice(dt('Successfully migrated text block @id to node @nid', [ + $this->logger()->notice(dt('Successfully migrated main content text block @id to node @nid', [ '@id' => $block->item_id, '@nid' => $block->entity_id, ])); } catch (\Exception $e) { $error_count++; - $this->logger()->error(dt('Error migrating text block @id: @message', [ + $this->logger()->error(dt('Error migrating main content text block @id: @message', [ '@id' => $block->item_id, '@message' => $e->getMessage(), ])); } } - $this->logger()->notice(dt('Migration complete. Successes: @success, Errors: @errors', [ + $this->logger()->notice(dt('Main content text block migration complete. Successes: @success, Errors: @errors', [ '@success' => $success_count, '@errors' => $error_count, ])); } catch (\Exception $e) { - $this->logger()->error(dt('Migration failed: @message', [ + $this->logger()->error(dt('Main content text block migration failed: @message', [ + '@message' => $e->getMessage(), + ])); + } + } + + /** + * Create sidebar paragraph items and return an array of data for database insertion. + * + * @return array + * An array of sidebar item data for database insertion. + */ + protected function createSidebarParagraphItems() { + try { + $sidebar_items = []; + + // Query the D7 database for text blocks in the sidebar field. + $query = $this->sourceDb->select('field_data_field_sidebar_items', 'n') + ->fields('n', ['entity_id', 'field_sidebar_items_value']); + + // Join with paragraphs_item to get only text blocks + $query->join( + 'paragraphs_item', + 'p', + 'p.item_id = n.field_sidebar_items_value AND p.bundle = :bundle', + [':bundle' => 'text_block'] + ); + $query->fields('p', ['item_id', 'bundle', 'field_name']); + + // Join with field_data_field_paragraph_heading for headings. + $query->leftJoin( + 'field_data_field_paragraph_heading', + 'h', + 'p.item_id = h.entity_id AND h.entity_type = :entity_type', + [':entity_type' => 'paragraphs_item'] + ); + $query->fields('h', ['field_paragraph_heading_value']); + + // Join with field_data_field_paragraph_text_content for body + format. + $query->leftJoin( + 'field_data_field_paragraph_text_content', + 't', + 'p.item_id = t.entity_id AND t.entity_type = :entity_type', + [':entity_type' => 'paragraphs_item'] + ); + $query->fields('t', ['field_paragraph_text_content_value', 'field_paragraph_text_content_format']); + + $text_blocks = $query->execute()->fetchAll(); + if (empty($text_blocks)) { + $this->logger()->warning(dt('No sidebar text blocks found to migrate.')); + return $sidebar_items; + } + + $success_count = 0; + $error_count = 0; + $processed_nodes = []; + + foreach ($text_blocks as $block) { + try { + // Skip if no node reference. + if (empty($block->entity_id)) { + $this->logger()->warning(dt('Skipping sidebar text block @id - no node reference found', [ + '@id' => $block->item_id, + ])); + continue; + } + + // Load the corresponding D10 node. + $node = $this->entityTypeManager->getStorage('node')->load($block->entity_id); + if (!$node) { + throw new \Exception("Node {$block->entity_id} not found in D10."); + } + + // Remove existing sidebar items before adding new ones, but only once per node + if (!in_array($block->entity_id, $processed_nodes)) { + $this->removeSidebarItems($node); + $processed_nodes[] = $block->entity_id; + } + + // Combine heading + body text. + $content = ''; + if (!empty($block->field_paragraph_heading_value)) { + $content .= '<h2>' . $block->field_paragraph_heading_value . '</h2>'; + } + if (!empty($block->field_paragraph_text_content_value)) { + $content .= $block->field_paragraph_text_content_value; + } + + // Process the content + $processed_content = $this->processTextBlockContent( + $content, + $block->field_paragraph_text_content_format + ); + + // Create a new paragraph of type 'text'. + $paragraph = Paragraph::create([ + 'type' => 'text', + 'langcode' => 'und', // Set paragraph language to 'und' (undefined) to match existing data + 'field_text' => [ + 'value' => $processed_content['value'], + 'format' => 'basic_html', // Explicitly set to basic_html as required + ], + ]); + $paragraph->save(); + + // Store the paragraph and node information for later database insertion + $sidebar_items[] = [ + 'node_id' => $node->id(), + 'node_revision_id' => $node->getRevisionId(), + 'node_bundle' => $node->bundle(), + 'paragraph_id' => $paragraph->id(), + 'paragraph_revision_id' => $paragraph->getRevisionId(), + ]; + + // Debug: Log detailed information about the paragraph and the node reference + $this->logger()->notice(dt('IMPORTANT: Sidebar paragraph created for node @nid: ID=@pid, RevID=@revid, Type=@type, Lang=@lang, Format=@format', [ + '@nid' => $node->id(), + '@pid' => $paragraph->id(), + '@revid' => $paragraph->getRevisionId(), + '@type' => $paragraph->getType(), + '@lang' => $paragraph->language()->getId(), + '@format' => $paragraph->get('field_text')->format, + ])); + + $success_count++; + $this->logger()->notice(dt('Successfully created sidebar paragraph for text block @id to node @nid', [ + '@id' => $block->item_id, + '@nid' => $block->entity_id, + ])); + } + catch (\Exception $e) { + $error_count++; + $this->logger()->error(dt('Error creating sidebar paragraph for text block @id: @message', [ + '@id' => $block->item_id, + '@message' => $e->getMessage(), + ])); + } + } + + $this->logger()->notice(dt('Sidebar paragraph creation complete. Successes: @success, Errors: @errors', [ + '@success' => $success_count, + '@errors' => $error_count, + ])); + + return $sidebar_items; + } + catch (\Exception $e) { + $this->logger()->error(dt('Sidebar paragraph creation failed: @message', [ + '@message' => $e->getMessage(), + ])); + return []; + } + } + + /** + * Insert sidebar items into the database. + * + * @param array $sidebar_items + * An array of sidebar item data for database insertion. + */ + protected function insertSidebarItemsIntoDatabase(array $sidebar_items) { + try { + // Truncate the sidebar items tables to start afresh + $this->targetDb->truncate('node__field_sidebar_items')->execute(); + $this->targetDb->truncate('node_revision__field_sidebar_items')->execute(); + $this->logger()->notice(dt('Truncated sidebar items tables to start afresh.')); + + if (empty($sidebar_items)) { + $this->logger()->warning(dt('No sidebar items to insert into the database.')); + return; + } + + $success_count = 0; + $error_count = 0; + + foreach ($sidebar_items as $item) { + try { + $node = $this->entityTypeManager->getStorage('node')->load($item['node_id']); + $node->field_sidebar_items[] = [ + 'target_id' => $item['paragraph_id'], + 'target_revision_id' => $item['paragraph_revision_id'], + ]; + $node->save(); + + $success_count++; + $this->logger()->notice(dt('Successfully inserted sidebar item for node @nid', [ + '@nid' => $item['node_id'], + ])); + } + catch (\Exception $e) { + $error_count++; + $this->logger()->error(dt('Error inserting sidebar item for node @nid: @message', [ + '@nid' => $item['node_id'], + '@message' => $e->getMessage(), + ])); + } + } + + $this->logger()->notice(dt('Sidebar item database insertion complete. Successes: @success, Errors: @errors', [ + '@success' => $success_count, + '@errors' => $error_count, + ])); + } + catch (\Exception $e) { + $this->logger()->error(dt('Sidebar item database insertion failed: @message', [ '@message' => $e->getMessage(), ])); }