From ea38025e48d7c69f83e859a03b5607c2e20691c5 Mon Sep 17 00:00:00 2001
From: Christophe Goffin <christophe.goffin@tobania.be>
Date: Thu, 24 Feb 2022 17:08:35 +0100
Subject: [PATCH 1/5] Take translations into account to find searched text.

Caught this fork up to 2.0.x (about 3 years out of date) and then
cherry-picked 8b1326b41c14d4c2cf667d5558fe3081dad59f10 atop it.

Still WIP. Needs to account for more multilanguage circumstnces,
such as a string field not having the match found in any one given
language.
---
 src/Plugin/Scanner/Node.php | 79 ++++++++++++++++++++-----------------
 1 file changed, 43 insertions(+), 36 deletions(-)

diff --git a/src/Plugin/Scanner/Node.php b/src/Plugin/Scanner/Node.php
index 4dd0b95..173ce50 100644
--- a/src/Plugin/Scanner/Node.php
+++ b/src/Plugin/Scanner/Node.php
@@ -43,49 +43,56 @@ class Node extends Entity {
       // Disable the normal access check.
       $query->accessCheck(FALSE);
 
+      $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
       $entities = $query->execute();
+
       // Iterate over matched entities (nodes) to extract information that will
       // be rendered in the results.
       foreach ($entities as $id) {
         $node = CoreNode::load($id);
-        $nodeField = $node->get($fieldname);
-        $fieldType = $nodeField->getFieldDefinition()->getType();
-        if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
-          $fieldValue = $nodeField->getValue()[0];
-          $title_collect[$id]['title'] = $node->getTitle();
-          // Find all instances of the term we're looking for.
-          preg_match_all($conditionVals['phpRegex'], $fieldValue['value'], $matches, PREG_OFFSET_CAPTURE);
-          $newValues = [];
-          // Build an array of strings which are displayed in the results.
-          foreach ($matches[0] as $v) {
-            // The offset of the matched term(s) in the field's text.
-            $start = $v[1];
-            if ($values['preceded'] !== '') {
-              // Bolding won't work if starting position is in the middle of a
-              // word (non-word bounded searches), therefore move the start
-              // position back as many character as there are in the 'preceded'
-              // text.
-              $start -= strlen($values['preceded']);
-            }
-            // Extract part of the text which include the search term plus six
-            // "words" following it. After finding the string, bold the search
-            // term.
-            $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$v[0]</strong>", preg_split("/\s+/", substr($fieldValue['value'], $start), 6));
-            if (count($replaced) > 1) {
-              // The final index contains the remainder of the text, which we
-              // don't care about, so we discard it.
-              array_pop($replaced);
+        foreach ($languages as $language) {
+          if ($node->hasTranslation($language)) {
+            $node = $node->getTranslation($language);
+          }
+          $nodeField = $node->get($fieldname);
+          $fieldType = $nodeField->getFieldDefinition()->getType();
+          if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
+            $fieldValue = $nodeField->getValue()[0];
+            $title_collect[$id]['title'] = $node->getTitle();
+            // Find all instances of the term we're looking for.
+            preg_match_all($conditionVals['phpRegex'], $fieldValue['value'], $matches, PREG_OFFSET_CAPTURE);
+            $newValues = [];
+            // Build an array of strings which are displayed in the results.
+            foreach ($matches[0] as $v) {
+              // The offset of the matched term(s) in the field's text.
+              $start = $v[1];
+              if ($values['preceded'] !== '') {
+                // Bolding won't work if starting position is in the middle of a
+                // word (non-word bounded searches), therefore move the start
+                // position back as many character as there are in the 'preceded'
+                // text.
+                $start -= strlen($values['preceded']);
+              }
+              // Extract part of the text which include the search term plus six
+              // "words" following it. After finding the string, bold the search
+              // term.
+              $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$v[0]</strong>", preg_split("/\s+/", substr($fieldValue['value'], $start), 6));
+              if (count($replaced) > 1) {
+                // The final index contains the remainder of the text, which we
+                // don't care about, so we discard it.
+                array_pop($replaced);
+              }
+              $newValues[] = implode(' ', $replaced);
             }
-            $newValues[] = implode(' ', $replaced);
+            $title_collect[$id]['field'] = $newValues;
+          }
+          elseif (in_array($fieldType, ['string', 'link'])) {
+            $title_collect[$id]['title'] = $node->getTitle();
+            preg_match($conditionVals['phpRegex'], $nodeField->getString(), $matches, PREG_OFFSET_CAPTURE);
+            $match = $matches[0][0];
+            $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$match</strong>", $nodeField->getString());
+            $title_collect[$id]['field'] = [$replaced];
           }
-          $title_collect[$id]['field'] = $newValues;
-        }
-        elseif (in_array($fieldType, ['string', 'link'])) {
-          $title_collect[$id]['title'] = $node->getTitle();
-          preg_match($conditionVals['phpRegex'], $nodeField->getString(), $matches, PREG_OFFSET_CAPTURE);
-          $match = $matches[0][0];
-          $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$match</strong>", $nodeField->getString());
-          $title_collect[$id]['field'] = [$replaced];
         }
       }
     }
-- 
GitLab


From 015cb9744aba424ee4f0051948778e3431b5842f Mon Sep 17 00:00:00 2001
From: Brad Garner <bgarner606@yahoo.com>
Date: Sat, 29 Mar 2025 21:40:32 -0700
Subject: [PATCH 2/5] Issue #3266343: Complete logic for searching all
 languages

In several places, the existence of multiple translations of
a given Node or Paragraph is now taken into account when searching.
Also, render array now includes URLs for viewing and editing
Nodes associated with these matches; default logic in
Twig template was not taking multiple languages into account.

WIP. Replace and undo methods still not fully evaluated for
correctness in light of multiple languages.

This edit is minimally invasive. A few early returns were
added for refactoring, but that was only to make the logic
easier to follow and to be less nested. These methods still
contain considerable duplicated code which could likely
be refactored into the Entity base class, but that is a
separate issue.
---
 src/Plugin/Scanner/Node.php         | 205 ++++++++++------------------
 src/Plugin/Scanner/Paragraph.php    | 162 ++++++++++++----------
 templates/scanner-results.html.twig |   4 +-
 3 files changed, 164 insertions(+), 207 deletions(-)

diff --git a/src/Plugin/Scanner/Node.php b/src/Plugin/Scanner/Node.php
index 173ce50..897160d 100644
--- a/src/Plugin/Scanner/Node.php
+++ b/src/Plugin/Scanner/Node.php
@@ -44,25 +44,31 @@ class Node extends Entity {
       $query->accessCheck(FALSE);
 
       $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
-      $entities = $query->execute();
+      $nids = $query->execute();
 
       // Iterate over matched entities (nodes) to extract information that will
       // be rendered in the results.
-      foreach ($entities as $id) {
-        $node = CoreNode::load($id);
+      foreach ($nids as $nid) {
         foreach ($languages as $language) {
+          $node = CoreNode::load($nid);
           if ($node->hasTranslation($language)) {
             $node = $node->getTranslation($language);
           }
           $nodeField = $node->get($fieldname);
           $fieldType = $nodeField->getFieldDefinition()->getType();
           if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
-            $fieldValue = $nodeField->getValue()[0];
-            $title_collect[$id]['title'] = $node->getTitle();
+            $fieldValue = $nodeField->getValue()[0] ?? [];
+            // @todo: Search currently only handles fields with cardinality of 1. Handle multi-value fields.
+
             // Find all instances of the term we're looking for.
             preg_match_all($conditionVals['phpRegex'], $fieldValue['value'], $matches, PREG_OFFSET_CAPTURE);
-            $newValues = [];
+            if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
+              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              continue;
+            }
+
             // Build an array of strings which are displayed in the results.
+            $newValues = [];
             foreach ($matches[0] as $v) {
               // The offset of the matched term(s) in the field's text.
               $start = $v[1];
@@ -84,14 +90,25 @@ class Node extends Entity {
               }
               $newValues[] = implode(' ', $replaced);
             }
-            $title_collect[$id]['field'] = $newValues;
+            $title_collect[$nid]['title'] = $node->getTitle();
+            $title_collect[$nid]['field'] = $newValues;
+            $title_collect[$nid]['view_url'] = $node->toUrl()->toString();
+            $title_collect[$nid]['edit_url'] = $node->toUrl('edit-form')->toString();
           }
           elseif (in_array($fieldType, ['string', 'link'])) {
-            $title_collect[$id]['title'] = $node->getTitle();
             preg_match($conditionVals['phpRegex'], $nodeField->getString(), $matches, PREG_OFFSET_CAPTURE);
+            if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
+              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              continue;
+            }
+
             $match = $matches[0][0];
             $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$match</strong>", $nodeField->getString());
-            $title_collect[$id]['field'] = [$replaced];
+
+            $title_collect[$nid]['title'] = $node->getTitle();
+            $title_collect[$nid]['field'] = [$replaced];
+            $title_collect[$nid]['view_url'] = $node->toUrl()->toString();
+            $title_collect[$nid]['edit_url'] = $node->toUrl('edit-form')->toString();
           }
         }
       }
@@ -108,9 +125,12 @@ class Node extends Entity {
    */
   public function replace(string $field, array $values, array $undo_data): array {
     $data = $undo_data;
-    [$entityType, $bundle, $fieldname] = explode(':', $field);
 
     try {
+      // $field will be string composed of entity type, bundle name, and field
+      // name delimited by ':' characters.
+      [$entityType, $bundle, $fieldname] = explode(':', $field);
+
       $query = $this->entityTypeManager->getStorage($entityType)->getQuery();
       $query->condition('type', $bundle);
       if ($values['published']) {
@@ -122,77 +142,53 @@ class Node extends Entity {
       // Disable the normal access check.
       $query->accessCheck(FALSE);
 
-      $entities = $query->execute();
+      $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
+      $nids = $query->execute();
 
-      foreach ($entities as $id) {
-        $node = CoreNode::load($id);
-        $nodeField = $node->get($fieldname);
-        $fieldType = $nodeField->getFieldDefinition()->getType();
-        if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
-          if ($values['language'] === 'all') {
-            $other_languages = AdminHelper::getAllEnabledLanguages();
-            foreach ($other_languages as $langcode => $languageName) {
-              if ($node->hasTranslation($langcode)) {
-                $node = $node->getTranslation($langcode);
-                $nodeField = $node->get($fieldname);
-              }
-              $fieldValue = $nodeField->getValue()[0];
-              // Replace the search term with the replacement term.
-              $fieldValue['value'] = preg_replace($conditionVals['phpRegex'], $values['replace'], $fieldValue['value']);
-              $node->$fieldname = $fieldValue;
-            }
+      // Iterate over matched entities (nodes) to perform the replacement
+      foreach ($nids as $nid) {
+        foreach ($languages as $language) {
+          $node = CoreNode::load($nid);
+          if ($node->hasTranslation($language)) {
+            $node = $node->getTranslation($language);
           }
-          else {
-            $requested_lang = $values['language'];
-            if ($node->hasTranslation($requested_lang)) {
-              $node = $node->getTranslation($requested_lang);
-              $nodeField = $node->get($fieldname);
-            }
-            $fieldValue = $nodeField->getValue()[0];
+          $nodeField = $node->get($fieldname);
+          $fieldType = $nodeField->getFieldDefinition()->getType();
+
+          if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
+            $fieldValue = $nodeField->getValue()[0] ?? [];
+            // @todo: Replace currently only handles fields with cardinality of 1. Handle multi-value fields.
+
             // Replace the search term with the replacement term.
             $fieldValue['value'] = preg_replace($conditionVals['phpRegex'], $values['replace'], $fieldValue['value']);
             $node->$fieldname = $fieldValue;
+
           }
-          if (!isset($data["node:$id"]['new_vid'])) {
-            $data["node:$id"]['old_vid'] = $node->vid->getString();
-            // Crete a new revision so that we can have the option of undoing it
-            // later on.
-            $node->setNewRevision();
-            $node->revision_log = $this->t('Replaced %search with %replace via Scanner Search and Replace module.', [
-              '%search' => $values['search'],
-              '%replace' => $values['replace'],
-            ]);
-            $node->setRevisionUserId($this->currentUser->id());
+          elseif ($fieldType == 'string') {
+            $fieldValue = preg_replace($conditionVals['phpRegex'], $values['replace'], $nodeField->getString());
+            $node->$fieldname = $fieldValue;
           }
-          // Save the updated node.
-          $node->save();
-          // Fetch the new revision id.
-          $data["node:$id"]['new_vid'] = $node->vid->getString();
-        }
-        elseif ($fieldType == 'string') {
-          if (!isset($data["node:$id"]['new_vid'])) {
-            if ($values['language'] === 'all') {
-              $all_languages = AdminHelper::getAllEnabledLanguages();
-              foreach ($all_languages as $langcode => $languageName) {
-                if ($node->hasTranslation($langcode)) {
-                  $node = $node->getTranslation($langcode);
-                  $nodeField = $node->get($fieldname);
+          elseif ($fieldType == 'link') {
+            $new_value = [];
+            foreach ($nodeField->getValue() as $delta => $field_value) {
+              foreach ($field_value as $field_element => $field_element_value) {
+                if (is_string($field_element_value)) {
+                  $fieldValue = preg_replace($conditionVals['phpRegex'], $values['replace'], $field_element_value);
                 }
-                $fieldValue = preg_replace($conditionVals['phpRegex'], $values['replace'], $nodeField->getString());
-                $node->$fieldname = $fieldValue;
-              }
-            }
-            else {
-              $requested_lang = $values['language'];
-              if ($node->hasTranslation($requested_lang)) {
-                // $nodeField = $nodeField->getTranslation($requested_lang);
-                $node = $node->getTranslation($requested_lang);
-                $nodeField = $node->get($fieldname);
+                else {
+                  $fieldValue = $field_element_value;
+                }
+                $new_value[$delta][$field_element] = $fieldValue;
               }
-              $fieldValue = preg_replace($conditionVals['phpRegex'], $values['replace'], $nodeField->getString());
-              $node->$fieldname = $fieldValue;
             }
-            $data["node:$id"]['old_vid'] = $node->vid->getString();
+            $node->$fieldname = $new_value;
+          }
+
+          // Create a new revision and save the node for this translation.
+          if (!isset($data["node:$nid"]['new_vid'])) {
+            $data["node:$nid"]['old_vid'] = $node->vid->getString();
+            // Crete a new revision so that we can have the option of undoing it
+            // later on.
             $node->setNewRevision();
             $node->revision_log = $this->t('Replaced %search with %replace via Scanner Search and Replace module.', [
               '%search' => $values['search'],
@@ -200,70 +196,11 @@ class Node extends Entity {
             ]);
             $node->setRevisionUserId($this->currentUser->id());
           }
+
+          // Save the updated node.
           $node->save();
-          $data["node:$id"]['new_vid'] = $node->vid->getString();
-        }
-        elseif ($fieldType == 'link') {
-          if (!isset($data["node:$id"]['new_vid'])) {
-            if ($values['language'] === 'all') {
-              $all_languages = AdminHelper::getAllEnabledLanguages();
-              foreach ($all_languages as $langcode => $languageName) {
-                if ($node->hasTranslation($langcode)) {
-                  $node = $node->getTranslation($langcode);
-                  $nodeField = $node->get($fieldname);
-                }
-                $new_value = [];
-                foreach ($nodeField->getValue() as $delta => $field_value) {
-                  foreach ($field_value as $field_element => $field_element_value) {
-                    if (is_string($field_element_value)) {
-                      $fieldValue = preg_replace($conditionVals['phpRegex'], $values['replace'], $field_element_value);
-                    }
-                    else {
-                      $fieldValue = $field_element_value;
-                    }
-                    $new_value[$delta][$field_element] = $fieldValue;
-                  }
-                }
-                $node->$fieldname = $new_value;
-              }
-              $data["node:$id"]['old_vid'] = $node->vid->getString();
-              $node->setNewRevision();
-              $node->revision_log = $this->t('Replaced %search with %replace via Scanner Search and Replace module.', [
-                '%search' => $values['search'],
-                '%replace' => $values['replace'],
-              ]);
-            }
-            else {
-              $requested_lang = $values['language'];
-              if ($node->hasTranslation($requested_lang)) {
-                // $nodeField = $nodeField->getTranslation($requested_lang);
-                $node = $node->getTranslation($requested_lang);
-                $nodeField = $node->get($fieldname);
-              }
-              $new_value = [];
-              foreach ($nodeField->getValue() as $delta => $field_value) {
-                foreach ($field_value as $field_element => $field_element_value) {
-                  if (is_string($field_element_value)) {
-                    $fieldValue = preg_replace($conditionVals['phpRegex'], $values['replace'], $field_element_value);
-                  }
-                  else {
-                    $fieldValue = $field_element_value;
-                  }
-                  $new_value[$delta][$field_element] = $fieldValue;
-                }
-              }
-              $node->$fieldname = $new_value;
-              $data["node:$id"]['old_vid'] = $node->vid->getString();
-              $node->setNewRevision();
-              $node->revision_log = $this->t('Replaced %search with %replace via Scanner Search and Replace module.', [
-                '%search' => $values['search'],
-                '%replace' => $values['replace'],
-              ]);
-            }
-          }
-          $node->setRevisionUserId($this->currentUser->id());
-          $node->save();
-          $data["node:$id"]['new_vid'] = $node->vid->getString();
+          // Fetch the new revision id.
+          $data["node:$nid"]['new_vid'] = $node->vid->getString();
         }
       }
     }
diff --git a/src/Plugin/Scanner/Paragraph.php b/src/Plugin/Scanner/Paragraph.php
index 0d29ddb..6fd04d3 100644
--- a/src/Plugin/Scanner/Paragraph.php
+++ b/src/Plugin/Scanner/Paragraph.php
@@ -7,6 +7,8 @@ use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 use Drupal\Core\Entity\EntityStorageException;
 use Drupal\Core\Logger\LoggerChannelTrait;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\scanner\AdminHelper;
+
 
 /**
  * A Scanner plugin for handling Paragraph entities.
@@ -27,10 +29,10 @@ class Paragraph extends Entity {
   public function search(string $field, array $values): array {
     $title_collect = [];
     try {
-      [, $bundle, $fieldname] = explode(':', $field);
+      [$entityType, $bundle, $fieldname] = explode(':', $field);
 
-      $query = $this->entityTypeManager->getStorage('paragraph')->getQuery();
-      $query->condition('type', $bundle);
+      $query = $this->entityTypeManager->getStorage($entityType)->getQuery();
+      $query->condition('type', $bundle, '=');
       if ($values['published']) {
         $query->condition('status', 1);
       }
@@ -40,82 +42,100 @@ class Paragraph extends Entity {
       // Disable the normal access check.
       $query->accessCheck(FALSE);
 
+      $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
       $entities = $query->execute();
+      if (empty($entities)) {
+        return $title_collect;
+      }
 
-      if (!empty($entities)) {
-        // Load the paragraph(s) which match the criteria.
-        $paragraphs = $this->entityTypeManager->getStorage('paragraph')
-          ->loadMultiple($entities);
-        // Iterate over matched paragraphs to extract information that will be
-        // rendered in the results.
-        foreach ($paragraphs as $paragraph) {
-          if (!empty($paragraph)) {
-            // Load the entity the paragraph is referenced in.
-            $parentEntity = $paragraph->getParentEntity();
+      // Load the paragraph(s) which match the criteria.
+      /** @var \Drupal\paragraphs\Entity\Paragraph[] $paragraphs */
+      $paragraphs = $this->entityTypeManager->getStorage('paragraph')->loadMultiple($entities);
 
-            if (!empty($parentEntity)) {
-              $parentEntityType = $parentEntity->getEntityTypeId();
-              // In the case of nested relationships we need to find the base
-              // entity.
-              if ($parentEntityType != 'node') {
-                // If child is only nested one level deep.
-                if ($parentEntity->getParentEntity()->getEntityTypeId() == 'node') {
-                  $parentEntity = $parentEntity->getParentEntity();
-                }
-                // Two or more levels of nesting.
-                else {
-                  while ($parentEntity->getParentEntity()->getEntityTypeId() != 'node') {
-                    $parentEntity = $parentEntity->getParentEntity();
-                  }
-                }
+      // Iterate over matched paragraphs to extract information to be rendered in results.
+      foreach ($paragraphs as $paragraph) {
+        foreach ($languages as $language) {
+          if ($paragraph->hasTranslation($language)) {
+            $paragraph = $paragraph->getTranslation($language);
+          }
+
+          // Load the entity the paragraph is referenced in.
+          $parentEntity = $paragraph->getParentEntity();
+          if (empty($parentEntity)) {
+            continue;
+          }
+
+          // Find the base node this paragraph is associated with
+          if ($parentEntity->getEntityTypeId() != 'node') {
+            // If child is only nested one level deep.
+            if ($parentEntity->getParentEntity()->getEntityTypeId() == 'node') {
+              $parentEntity = $parentEntity->getParentEntity();
+            }
+            else {
+              // Two or more levels of nesting.
+              while ($parentEntity->getParentEntity()->getEntityTypeId() != 'node') {
+                $parentEntity = $parentEntity->getParentEntity();
               }
-              $id = $parentEntity->id();
-              // Get the value of the specified field.
-              $paraField = $paragraph->get($fieldname);
-              $fieldType = $paraField->getFieldDefinition()->getType();
-              if (in_array($fieldType, [
-                'text_with_summary',
-                'text',
-                'text_long',
-              ])) {
-                // Get the value of the field.
-                $fieldValue = $paraField->getValue()[0];
-                // Get the parent entity's title.
-                $title_collect[$id]['title'] = $parentEntity->getTitle();
-                // Find all instances of the term we're looking for.
-                preg_match_all($conditionVals['phpRegex'], $fieldValue['value'], $matches, PREG_OFFSET_CAPTURE);
-                $newValues = [];
-                // Build an array of strings which are displayed in the results
-                // with the searched term bolded.
-                foreach ($matches[0] as $v) {
-                  // The offset of the matched term(s) in the field's text.
-                  $start = $v[1];
-                  if ($values['preceded'] !== '') {
-                    // Bolding won't work if starting position is in the middle
-                    // of a word (non-word bounded searches), therefore we move
-                    // the start position back as many character as there are in
-                    // the 'preceded' text.
-                    $start -= strlen($values['preceded']);
-                  }
-                  $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$v[0]</strong>", preg_split("/\s+/", substr($fieldValue['value'], $start), 6));
-                  if (count($replaced) > 1) {
-                    // The final index contains the remainder of the text, which
-                    // we don't care about, so we discard it.
-                    array_pop($replaced);
-                  }
-                  $newValues[] = implode(' ', $replaced);
-                }
-                $title_collect[$id]['field'] = $newValues;
+            }
+          }
+          $id = $parentEntity->id();
+          // Get the value of the specified field.
+          $paraField = $paragraph->get($fieldname);
+          $fieldType = $paraField->getFieldDefinition()->getType();
+          if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
+            // Get the value of the field.
+            $fieldValue = $paraField->getValue()[0] ?? [];
+            // @todo: Search currently only handles fields with cardinality of 1. Handle multi-value fields.
+
+            // Find all instances of the term we're looking for.
+            preg_match_all($conditionVals['phpRegex'], $fieldValue['value'], $matches, PREG_OFFSET_CAPTURE);
+            if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
+              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              continue;
+            }
+            
+            $newValues = [];
+            // Build an array of strings which are displayed in the results
+            // with the searched term bolded.
+            foreach ($matches[0] as $v) {
+              // The offset of the matched term(s) in the field's text.
+              $start = $v[1];
+              if ($values['preceded'] !== '') {
+                // Bolding won't work if starting position is in the middle
+                // of a word (non-word bounded searches), therefore we move
+                // the start position back as many character as there are in
+                // the 'preceded' text.
+                $start -= strlen($values['preceded']);
               }
-              elseif (in_array($fieldType, ['string', 'link'])) {
-                $title_collect[$id]['title'] = $parentEntity->getTitle();
-                preg_match($conditionVals['phpRegex'], $paraField->getString(), $matches, PREG_OFFSET_CAPTURE);
-                $match = $matches[0][0];
-                $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$match</strong>", $paraField->getString());
-                $title_collect[$id]['field'] = [$replaced];
+              $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$v[0]</strong>", preg_split("/\s+/", substr($fieldValue['value'], $start), 6));
+              if (count($replaced) > 1) {
+                // The final index contains the remainder of the text, which
+                // we don't care about, so we discard it.
+                array_pop($replaced);
               }
+              $newValues[] = implode(' ', $replaced);
             }
+            $title_collect[$id]['title'] = $parentEntity->getTitle();
+            $title_collect[$id]['field'] = $newValues;
+            $title_collect[$id]['view_url'] = $parentEntity->toUrl()->toString();
+            $title_collect[$id]['edit_url'] = $parentEntity->toUrl('edit-form')->toString();
           }
+          elseif (in_array($fieldType, ['string', 'link'])) {
+            preg_match($conditionVals['phpRegex'], $paraField->getString(), $matches, PREG_OFFSET_CAPTURE);
+            if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
+              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              continue;
+            }
+
+            $match = $matches[0][0];
+            $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$match</strong>", $paraField->getString());
+
+            $title_collect[$id]['title'] = $parentEntity->getTitle();
+            $title_collect[$id]['field'] = [$replaced];
+            $title_collect[$id]['view_url'] = $parentEntity->toUrl()->toString();
+            $title_collect[$id]['edit_url'] = $parentEntity->toUrl('edit-form')->toString();
+          }
+
         }
       }
     }
diff --git a/templates/scanner-results.html.twig b/templates/scanner-results.html.twig
index b1c7375..06c762e 100644
--- a/templates/scanner-results.html.twig
+++ b/templates/scanner-results.html.twig
@@ -10,8 +10,8 @@
           {% for id, value in values %}
             <li>
               {{value.title}} | 
-              <a href="{{ path('entity.node.canonical', {'node':id}) }}">{{ 'view'|t }}</a> |
-              <a href="/node/{{id}}/edit">{{ 'edit'|t }}</a>
+              <a href="{{ value.view_url }}">{{ 'view'|t }}</a> |
+              <a href="{{ value.edit_url }}">{{ 'edit'|t }}</a>
             </li>
             {% if value.field|length > 1 %}
               <span>[{{ '@count matches in the field'|t({'@count': value.field|length})}}]</span>
-- 
GitLab


From f0817b47f3666d523b9009dc6ab2f6c36eac1add Mon Sep 17 00:00:00 2001
From: Brad Garner <bgarner606@yahoo.com>
Date: Sat, 29 Mar 2025 22:30:40 -0700
Subject: [PATCH 3/5] linting code for phpcs issues

---
 src/Plugin/Scanner/Node.php      | 18 +++++++++---------
 src/Plugin/Scanner/Paragraph.php | 12 +++++-------
 2 files changed, 14 insertions(+), 16 deletions(-)

diff --git a/src/Plugin/Scanner/Node.php b/src/Plugin/Scanner/Node.php
index 897160d..dac750c 100644
--- a/src/Plugin/Scanner/Node.php
+++ b/src/Plugin/Scanner/Node.php
@@ -57,13 +57,13 @@ class Node extends Entity {
           $nodeField = $node->get($fieldname);
           $fieldType = $nodeField->getFieldDefinition()->getType();
           if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
+            // @todo Add support for fields with cardinality > 1.
             $fieldValue = $nodeField->getValue()[0] ?? [];
-            // @todo: Search currently only handles fields with cardinality of 1. Handle multi-value fields.
 
             // Find all instances of the term we're looking for.
             preg_match_all($conditionVals['phpRegex'], $fieldValue['value'], $matches, PREG_OFFSET_CAPTURE);
             if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
-              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              // Match not guaranteed for nodes with > 1 language.
               continue;
             }
 
@@ -73,10 +73,10 @@ class Node extends Entity {
               // The offset of the matched term(s) in the field's text.
               $start = $v[1];
               if ($values['preceded'] !== '') {
-                // Bolding won't work if starting position is in the middle of a
-                // word (non-word bounded searches), therefore move the start
-                // position back as many character as there are in the 'preceded'
-                // text.
+                // Bolding won't work if starting position is in the middle of
+                // a word (non-word bounded searches), therefore move the start
+                // position back as many character as there are in the
+                // 'preceded' text.
                 $start -= strlen($values['preceded']);
               }
               // Extract part of the text which include the search term plus six
@@ -98,7 +98,7 @@ class Node extends Entity {
           elseif (in_array($fieldType, ['string', 'link'])) {
             preg_match($conditionVals['phpRegex'], $nodeField->getString(), $matches, PREG_OFFSET_CAPTURE);
             if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
-              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              // Match not guaranteed for nodes with > 1 language.
               continue;
             }
 
@@ -145,7 +145,7 @@ class Node extends Entity {
       $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
       $nids = $query->execute();
 
-      // Iterate over matched entities (nodes) to perform the replacement
+      // Iterate over matched entities (nodes) to perform the replacement.
       foreach ($nids as $nid) {
         foreach ($languages as $language) {
           $node = CoreNode::load($nid);
@@ -156,8 +156,8 @@ class Node extends Entity {
           $fieldType = $nodeField->getFieldDefinition()->getType();
 
           if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
+            // @todo Add support for fields with cardinality > 1.
             $fieldValue = $nodeField->getValue()[0] ?? [];
-            // @todo: Replace currently only handles fields with cardinality of 1. Handle multi-value fields.
 
             // Replace the search term with the replacement term.
             $fieldValue['value'] = preg_replace($conditionVals['phpRegex'], $values['replace'], $fieldValue['value']);
diff --git a/src/Plugin/Scanner/Paragraph.php b/src/Plugin/Scanner/Paragraph.php
index 6fd04d3..b522e71 100644
--- a/src/Plugin/Scanner/Paragraph.php
+++ b/src/Plugin/Scanner/Paragraph.php
@@ -9,7 +9,6 @@ use Drupal\Core\Logger\LoggerChannelTrait;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\scanner\AdminHelper;
 
-
 /**
  * A Scanner plugin for handling Paragraph entities.
  *
@@ -52,7 +51,6 @@ class Paragraph extends Entity {
       /** @var \Drupal\paragraphs\Entity\Paragraph[] $paragraphs */
       $paragraphs = $this->entityTypeManager->getStorage('paragraph')->loadMultiple($entities);
 
-      // Iterate over matched paragraphs to extract information to be rendered in results.
       foreach ($paragraphs as $paragraph) {
         foreach ($languages as $language) {
           if ($paragraph->hasTranslation($language)) {
@@ -65,7 +63,7 @@ class Paragraph extends Entity {
             continue;
           }
 
-          // Find the base node this paragraph is associated with
+          // Find the base node this paragraph is associated with.
           if ($parentEntity->getEntityTypeId() != 'node') {
             // If child is only nested one level deep.
             if ($parentEntity->getParentEntity()->getEntityTypeId() == 'node') {
@@ -84,16 +82,16 @@ class Paragraph extends Entity {
           $fieldType = $paraField->getFieldDefinition()->getType();
           if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
             // Get the value of the field.
+            // @todo Handle fields with cardinality greater than 1.
             $fieldValue = $paraField->getValue()[0] ?? [];
-            // @todo: Search currently only handles fields with cardinality of 1. Handle multi-value fields.
 
             // Find all instances of the term we're looking for.
             preg_match_all($conditionVals['phpRegex'], $fieldValue['value'], $matches, PREG_OFFSET_CAPTURE);
             if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
-              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              // Match not guaranteed when paragraph has > 1 language.
               continue;
             }
-            
+
             $newValues = [];
             // Build an array of strings which are displayed in the results
             // with the searched term bolded.
@@ -123,7 +121,7 @@ class Paragraph extends Entity {
           elseif (in_array($fieldType, ['string', 'link'])) {
             preg_match($conditionVals['phpRegex'], $paraField->getString(), $matches, PREG_OFFSET_CAPTURE);
             if (empty($matches) || !isset($matches[0]) || !isset($matches[0][0])) {
-              // Match is not guaranteed in the case of nodes with > 1 language. Skip if no match found.
+              // Match not guaranteed for paragraphs with > 1 language.
               continue;
             }
 
-- 
GitLab


From 91fb3944613a9f6f95e12b2e4e2af94c6b764cda Mon Sep 17 00:00:00 2001
From: Brad Garner <bgarner606@yahoo.com>
Date: Wed, 2 Apr 2025 20:04:26 -0700
Subject: [PATCH 4/5] support serch results in > 1 lang of same node

Previously, a search hit in 2 or more languages of the same node would only return 1 of those hits in search results. Changing the key to incllude the language is minimally invasive, and is not a problem because the Twig template no longer relies on the key being a true node ID (see previous commit)
---
 src/Plugin/Scanner/Node.php | 30 +++++++++++++++++-------------
 1 file changed, 17 insertions(+), 13 deletions(-)

diff --git a/src/Plugin/Scanner/Node.php b/src/Plugin/Scanner/Node.php
index dac750c..6e9c5e8 100644
--- a/src/Plugin/Scanner/Node.php
+++ b/src/Plugin/Scanner/Node.php
@@ -40,22 +40,25 @@ class Node extends Entity {
       $conditionVals = parent::buildCondition($values['search'], $values['mode'], $values['wholeword'], $values['regex'], $values['preceded'], $values['followed']);
       $this->addQueryCondition($query, $conditionVals, $fieldname, $values['mode'], $values['language']);
 
-      // Disable the normal access check.
+      // Run Drupal database query
       $query->accessCheck(FALSE);
-
-      $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
       $nids = $query->execute();
 
-      // Iterate over matched entities (nodes) to extract information that will
-      // be rendered in the results.
+      // Iterate over matched entities (nodes) in all serched languages
+      // to extract information that will be rendered in the results.
+      $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
       foreach ($nids as $nid) {
         foreach ($languages as $language) {
+          // Results key must be unique, accounting for > 1 language
+          $result_key = $values['language'] === 'all' ? "$nid-$language" : "$nid";
+
           $node = CoreNode::load($nid);
           if ($node->hasTranslation($language)) {
             $node = $node->getTranslation($language);
           }
           $nodeField = $node->get($fieldname);
           $fieldType = $nodeField->getFieldDefinition()->getType();
+
           if (in_array($fieldType, ['text_with_summary', 'text', 'text_long'])) {
             // @todo Add support for fields with cardinality > 1.
             $fieldValue = $nodeField->getValue()[0] ?? [];
@@ -90,10 +93,11 @@ class Node extends Entity {
               }
               $newValues[] = implode(' ', $replaced);
             }
-            $title_collect[$nid]['title'] = $node->getTitle();
-            $title_collect[$nid]['field'] = $newValues;
-            $title_collect[$nid]['view_url'] = $node->toUrl()->toString();
-            $title_collect[$nid]['edit_url'] = $node->toUrl('edit-form')->toString();
+
+            $title_collect[$result_key]['title'] = $node->getTitle();
+            $title_collect[$result_key]['field'] = $newValues;
+            $title_collect[$result_key]['view_url'] = $node->toUrl()->toString();
+            $title_collect[$result_key]['edit_url'] = $node->toUrl('edit-form')->toString();
           }
           elseif (in_array($fieldType, ['string', 'link'])) {
             preg_match($conditionVals['phpRegex'], $nodeField->getString(), $matches, PREG_OFFSET_CAPTURE);
@@ -105,10 +109,10 @@ class Node extends Entity {
             $match = $matches[0][0];
             $replaced = preg_replace($conditionVals['phpRegex'], "<strong>$match</strong>", $nodeField->getString());
 
-            $title_collect[$nid]['title'] = $node->getTitle();
-            $title_collect[$nid]['field'] = [$replaced];
-            $title_collect[$nid]['view_url'] = $node->toUrl()->toString();
-            $title_collect[$nid]['edit_url'] = $node->toUrl('edit-form')->toString();
+            $title_collect[$result_key]['title'] = $node->getTitle();
+            $title_collect[$result_key]['field'] = [$replaced];
+            $title_collect[$result_key]['view_url'] = $node->toUrl()->toString();
+            $title_collect[$result_key]['edit_url'] = $node->toUrl('edit-form')->toString();
           }
         }
       }
-- 
GitLab


From 776238c2468ba0e8eefe8144a3f58ccca037d96b Mon Sep 17 00:00:00 2001
From: Brad Garner <bgarner606@yahoo.com>
Date: Tue, 8 Apr 2025 17:27:53 -0700
Subject: [PATCH 5/5] Improve translation logic for nodes lacking translations

Without this change, all languages for a site will be looped through, but if a node lacks that translation, it was just searching the sit default language again, returning multiple results.
---
 src/Plugin/Scanner/Node.php | 11 ++++++-----
 1 file changed, 6 insertions(+), 5 deletions(-)

diff --git a/src/Plugin/Scanner/Node.php b/src/Plugin/Scanner/Node.php
index 6e9c5e8..b8744e9 100644
--- a/src/Plugin/Scanner/Node.php
+++ b/src/Plugin/Scanner/Node.php
@@ -40,22 +40,23 @@ class Node extends Entity {
       $conditionVals = parent::buildCondition($values['search'], $values['mode'], $values['wholeword'], $values['regex'], $values['preceded'], $values['followed']);
       $this->addQueryCondition($query, $conditionVals, $fieldname, $values['mode'], $values['language']);
 
-      // Run Drupal database query
+      // Run Drupal database query.
       $query->accessCheck(FALSE);
       $nids = $query->execute();
 
-      // Iterate over matched entities (nodes) in all serched languages
+      // Iterate over matched entities (nodes) in all saerched languages
       // to extract information that will be rendered in the results.
       $languages = $values['language'] === 'all' ? array_keys(AdminHelper::getAllEnabledLanguages()) : [$values['language']];
       foreach ($nids as $nid) {
         foreach ($languages as $language) {
-          // Results key must be unique, accounting for > 1 language
+          // Results key must be unique, accounting for > 1 language.
           $result_key = $values['language'] === 'all' ? "$nid-$language" : "$nid";
 
           $node = CoreNode::load($nid);
-          if ($node->hasTranslation($language)) {
-            $node = $node->getTranslation($language);
+          if (!$node->hasTranslation($language)) {
+            continue;
           }
+          $node = $node->getTranslation($language);
           $nodeField = $node->get($fieldname);
           $fieldType = $nodeField->getFieldDefinition()->getType();
 
-- 
GitLab