From d300a2c33abaf3e2c3437d5ba42b50ab146e31e6 Mon Sep 17 00:00:00 2001
From: Jeroen Opdebeeck <jeroen.opdebeeck@cegeka.com>
Date: Mon, 17 Jun 2024 10:23:41 +0200
Subject: [PATCH] Issue #3452682: Add keymatch functionality

---
 elasticsearch_search_api.services.yml.example |   7 +
 src/Factory/KeymatchEntryFactory.php          |  25 ++
 src/Form/KeymatchForm.php                     | 175 ++++++++++++
 src/KeymatchEntry.php                         |  92 +++++++
 src/KeymatchService.php                       | 252 ++++++++++++++++++
 5 files changed, 551 insertions(+)
 create mode 100644 src/Factory/KeymatchEntryFactory.php
 create mode 100644 src/Form/KeymatchForm.php
 create mode 100644 src/KeymatchEntry.php
 create mode 100644 src/KeymatchService.php

diff --git a/elasticsearch_search_api.services.yml.example b/elasticsearch_search_api.services.yml.example
index 1600581..3e96c25 100644
--- a/elasticsearch_search_api.services.yml.example
+++ b/elasticsearch_search_api.services.yml.example
@@ -40,6 +40,13 @@ services:
     tags:
       - { name: event_subscriber }
 
+  elasticsearch_search_api.factory.keymatch_entry:
+    class: Drupal\elasticsearch_search_api\Factory\KeymatchEntryFactory
+    arguments: []
+  elasticsearch_search_api.keymatch_service:
+    class: Drupal\elasticsearch_search_api\KeymatchService
+    arguments: ['@config.factory', '@path.validator', '@elasticsearch_search_api.factory.keymatch_entry']
+
 #
 #
 #
diff --git a/src/Factory/KeymatchEntryFactory.php b/src/Factory/KeymatchEntryFactory.php
new file mode 100644
index 0000000..92401d3
--- /dev/null
+++ b/src/Factory/KeymatchEntryFactory.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\elasticsearch_search_api\Factory;
+
+use Drupal\elasticsearch_search_api\KeymatchEntry;
+
+/**
+ * Class KeymatchEntryFactory.
+ */
+class KeymatchEntryFactory {
+
+  /**
+   * Creates a KeymatchEntry object.
+   *
+   * @param string $keymatch_entry
+   *   Keymatch entry as a string.
+   *
+   * @return \Drupal\elasticsearch_search_api\KeymatchEntry
+   *   Returns a KeymatchEntry object
+   */
+  public function createEntry(string $keymatch_entry) {
+    return new KeymatchEntry($keymatch_entry);
+  }
+
+}
diff --git a/src/Form/KeymatchForm.php b/src/Form/KeymatchForm.php
new file mode 100644
index 0000000..866db50
--- /dev/null
+++ b/src/Form/KeymatchForm.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Drupal\elasticsearch_search_api\Form;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Render\Markup;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\elasticsearch_search_api\KeymatchService;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Class KeymatchForm.
+ *
+ * @package Drupal\general\Form
+ */
+class KeymatchForm extends ConfigFormBase {
+
+  const CONFIG_KEY = 'elasticsearch_search_api.keymatch';
+
+  /**
+   * The config instance.
+   *
+   * @var \Drupal\Core\Config\Config|\Drupal\Core\Config\ImmutableConfig
+   */
+  protected $configInstance;
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The keymatch service.
+   *
+   * @var \Drupal\elasticsearch_search_api\KeymatchService
+   */
+  protected $keymatchService;
+
+  /**
+   * KeymatchForm constructor.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config instance.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Drupal\elasticsearch_search_api\KeymatchService $keymatch_service
+   *   The keymatch service.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, RendererInterface $renderer, KeymatchService $keymatch_service) {
+    parent::__construct($config_factory);
+
+    $this->configInstance = $this->config(self::CONFIG_KEY);
+    $this->renderer = $renderer;
+    $this->keymatchService = $keymatch_service;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('config.factory'),
+      $container->get('renderer'),
+      $container->get('elasticsearch_search_api.keymatch_service')
+    );
+  }
+
+  /**
+   * Implements \Drupal\Core\Form\FormInterface::getFormID().
+   */
+  public function getFormId() {
+    return 'elasticsearch_search_api_keymatch_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditableConfigNames() {
+    return [
+      self::CONFIG_KEY,
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['keymatch_fieldset'] = [
+      '#type' => 'fieldset',
+      '#collapsible' => FALSE,
+      '#collapsed' => FALSE,
+      '#title' => '',
+    ];
+
+    $form['keymatch_fieldset']['keymatches'] = [
+      '#title' => t('Keymatches'),
+      '#type' => 'textarea',
+      '#default_value' => $this->configInstance->get('keymatches'),
+      '#rows' => 15,
+    ];
+
+    $form['keymatch_fieldset']['keymatch_information'] = [
+      '#markup' => $this->getKeymatchInfo(),
+    ];
+
+    return parent::buildForm($form, $form_state);
+  }
+
+  /**
+   * Provide help text for configuring keymatches.
+   *
+   * @return string
+   *   Info message markup.
+   *
+   * @throws \Exception
+   */
+  protected function getKeymatchInfo() {
+    $types_list = [
+      '#theme' => 'item_list',
+      '#items' => [
+        KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_TERM . ' (= ' . t('All terms, space delimited, occur anywhere in search query. Case-insensitive.') . ')',
+        KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_PHRASE . ' (= ' . t('Phrase occurs anywhere in search query. Case-insensitive.') . ')',
+        KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_EXACT . ' (= ' . t('Phrase/Term matches search query exactly. Case-sensitive.') . ')',
+      ],
+    ];
+
+    return '<div class="instruction-msg">' . t('Enter 1 entry per line (comma separated)') . ':<br />' .
+      '- ' . t('Search term') . ',' . t('{Type}') . ',' . t('URL for match') . ',' . t('Title for match') . '<br />' .
+      '- ' . 'Organisatie,TERM,https://www.vdab.be/vdab,Over VDAB <br />' .
+      '- ' . 'Organisatie Overheid,TERM,https://www.vdab.be/vdab,Over VDAB <br />' .
+      '- ' . 'VDAB-partners,EXACT,https://www.vdab.be/vdab,Over VDAB </div><br />' .
+      '<div class="instruction-tokens">' . t('{Type} can be one of the following') . ':' . $this->renderer->render($types_list) . '</div>';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    parent::validateForm($form, $form_state);
+
+    $config = $this->keymatchService->getKeymatchConfiguration($form_state->getValue('keymatches'));
+
+    $errors = '';
+    foreach ($config as $keymatch_entry) {
+      if (!$this->keymatchService->isValid($keymatch_entry, TRUE)) {
+        $errors .= '<li>Invalid entry: ' . $keymatch_entry . "</li>";
+      }
+    }
+    $errors = empty($errors) ? $errors : '<ul>' . $errors . '</ul>';
+    $errors = Markup::create($errors);
+
+    if (!empty($errors)) {
+      $message = $this->t('Not all provided keymatches were valid. @errors', ['@errors' => $errors]);
+      $form_state->setErrorByName('keymatches', $message);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    foreach ($form_state->cleanValues()->getValues() as $key => $formValue) {
+      $this->configInstance->set($key, $formValue);
+    }
+
+    $this->configInstance->save();
+
+    parent::submitForm($form, $form_state);
+  }
+
+}
diff --git a/src/KeymatchEntry.php b/src/KeymatchEntry.php
new file mode 100644
index 0000000..8ee9319
--- /dev/null
+++ b/src/KeymatchEntry.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\elasticsearch_search_api;
+
+/**
+ * A KeymatchEntry Object.
+ */
+class KeymatchEntry {
+
+  /**
+   * The keymatch query.
+   *
+   * @var string
+   */
+  private $query;
+
+  /**
+   * The keymatch url.
+   *
+   * @var string
+   */
+  private $url;
+
+  /**
+   * The keymatch title.
+   *
+   * @var string
+   */
+  private $title;
+
+  /**
+   * The keymatch type.
+   *
+   * @var string
+   */
+  private $type;
+
+  /**
+   * KeymatchEntry constructor.
+   *
+   * @param string $keymatch_entry
+   *   Keymatch entry as string.
+   */
+  public function __construct($keymatch_entry) {
+    $parts = explode(',', $keymatch_entry);
+    $this->query = $parts[0];
+    $this->url = $parts[2];
+    $this->title = $parts[3];
+    $this->type = $parts[1];
+  }
+
+  /**
+   * Gets the query.
+   *
+   * @return string
+   *   The keymatch query.
+   */
+  public function getQuery() {
+    return $this->query;
+  }
+
+  /**
+   * Gets the url.
+   *
+   * @return string
+   *   The keymatch url.
+   */
+  public function getUrl() {
+    return $this->url;
+  }
+
+  /**
+   * Gets the title.
+   *
+   * @return string
+   *   The keymatch title.
+   */
+  public function getTitle() {
+    return $this->title;
+  }
+
+  /**
+   * Gets the type.
+   *
+   * @return string
+   *   The keymatch type.
+   */
+  public function getType() {
+    return $this->type;
+  }
+
+}
diff --git a/src/KeymatchService.php b/src/KeymatchService.php
new file mode 100644
index 0000000..4323896
--- /dev/null
+++ b/src/KeymatchService.php
@@ -0,0 +1,252 @@
+<?php
+
+namespace Drupal\elasticsearch_search_api;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Path\PathValidatorInterface;
+use Drupal\elasticsearch_search_api\Factory\KeymatchEntryFactory;
+
+/**
+ * Keymatch Service class.
+ */
+class KeymatchService {
+
+  const VDAB_SEARCH_KEYMATCH_TYPE_TERM = 'TERM';
+  const VDAB_SEARCH_KEYMATCH_TYPE_PHRASE = 'PHRASE';
+  const VDAB_SEARCH_KEYMATCH_TYPE_EXACT = 'EXACT';
+
+  /**
+   * The config instance.
+   *
+   * @var \Drupal\Core\Config\Config|\Drupal\Core\Config\ImmutableConfig
+   */
+  protected $configInstance;
+
+  /**
+   * The path validator.
+   *
+   * @var \Drupal\Core\Path\PathValidatorInterface
+   */
+  protected $pathValidator;
+
+  /**
+   * The keymatch entry factory.
+   *
+   * @var \Drupal\elasticsearch_search_api\Factory\KeymatchEntryFactory
+   */
+  protected $keymatchEntryFactory;
+
+  /**
+   * KeymatchForm constructor.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config instance.
+   * @param \Drupal\Core\Path\PathValidatorInterface $path_validator
+   *   The path validator.
+   * @param \Drupal\elasticsearch_search_api\Factory\KeymatchEntryFactory $keymatch_entry_factory
+   *   The keymatchEntry factory.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, PathValidatorInterface $path_validator, KeymatchEntryFactory $keymatch_entry_factory) {
+    $this->configInstance = $config_factory->get('elasticsearch_search_api.keymatch');
+    $this->pathValidator = $path_validator;
+    $this->keymatchEntryFactory = $keymatch_entry_factory;
+  }
+
+  /**
+   * Returns the available keymatch types.
+   *
+   * @return array
+   *   Returns the types in an array.
+   */
+  public static function allKeymatchTypes() {
+    return [
+      KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_TERM => KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_TERM,
+      KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_PHRASE => KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_PHRASE,
+      KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_EXACT => KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_EXACT,
+    ];
+  }
+
+  /**
+   * Finds keymatches for the given query.
+   *
+   * @param string $search_query
+   *   The search query to match keymatches with.
+   *
+   * @return KeymatchEntry[]
+   *   Returns an array of KeymatchEntry objects.
+   */
+  public function find($search_query) {
+    $keymatch_configuration = $this->getKeymatchConfiguration();
+    $ordered_keymatches = $this->getKeymatchEntriesByType($keymatch_configuration);
+
+    $keymatches_found = [];
+    if (isset($search_query)) {
+      foreach ($ordered_keymatches as $type => $keymatches) {
+        $type_uppercase = strtoupper($type);
+
+        /** @var KeymatchEntry $keymatch */
+        foreach ($keymatches as $keymatch) {
+          $matches = FALSE;
+          switch ($type_uppercase) {
+            case KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_EXACT:
+              $matches = $this->matchesExact($search_query, $keymatch->getQuery());
+              break;
+
+            case KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_TERM:
+              $matches = $this->matchesTerm($search_query, $keymatch->getQuery());
+              break;
+
+            case KeymatchService::VDAB_SEARCH_KEYMATCH_TYPE_PHRASE:
+              $matches = $this->matchesPhrase($search_query, $keymatch->getQuery());
+              break;
+          }
+          if ($matches) {
+            $keymatches_found[] = $keymatch;
+          }
+        }
+      }
+    }
+    return $keymatches_found;
+  }
+
+  /**
+   * Checks an exact keymatch.
+   *
+   * @param string $query
+   *   The search query.
+   * @param string $keymatch
+   *   The keymatch.
+   *
+   * @return bool
+   *   TRUE if query matches on specified keymatch
+   */
+  public function matchesExact($query, $keymatch) {
+    return $query === $keymatch;
+  }
+
+  /**
+   * Checks a keymatch with the type 'phrase'.
+   *
+   * @param string $query
+   *   The search query.
+   * @param string $keymatch
+   *   The keymatch.
+   *
+   * @return bool
+   *   TRUE if query matches on specified keymatch
+   */
+  public function matchesPhrase($query, $keymatch) {
+    $query_lower = strtolower($query);
+    $keymatch_lower = strtolower($keymatch);
+
+    $regex = '/\b' . $keymatch_lower . '\b/';
+    preg_match($regex, $query_lower, $match);
+
+    return isset($match) && !empty($match);
+  }
+
+  /**
+   * Checks a keymatch with the type 'term'.
+   *
+   * @param string $query
+   *   The search query.
+   * @param string $keymatch
+   *   The keymatch.
+   *
+   * @return bool
+   *   TRUE if query matches on specified keymatch
+   */
+  public function matchesTerm($query, $keymatch) {
+    $keymatch_terms = explode(' ', $keymatch);
+    if (!isset($keymatch_terms) || $keymatch_terms === FALSE) {
+      return FALSE;
+    }
+
+    foreach ($keymatch_terms as $term) {
+      if (!$this->matchesPhrase($query, $term)) {
+        return FALSE;
+      };
+    }
+
+    return TRUE;
+  }
+
+  /**
+   * Checks whether a keymatch string is valid or not.
+   *
+   * @param string $keymatch_entry
+   *   Keymatch entry as a string, as it is stored in the config.
+   * @param bool $strict
+   *   Check if url is external or valid internal path.
+   *
+   * @return bool
+   *   Whether the keymatch entry is valid or not.
+   */
+  public function isValid($keymatch_entry, $strict = FALSE) {
+    $parts = explode(',', $keymatch_entry);
+    if (!$parts || !is_array($parts) || empty($parts) || count($parts) < 4) {
+      return FALSE;
+    }
+
+    $trimmed_query = trim($parts[0]);
+    $trimmed_url = trim($parts[2]);
+    $trimmed_title = trim($parts[3]);
+
+    $valid_path = TRUE;
+    if ($strict !== FALSE) {
+      $valid_path = $this->pathValidator->isValid($trimmed_url);
+    }
+
+    return !empty($trimmed_query) && !empty($trimmed_title) && !empty($trimmed_url) && isset($this->allKeymatchTypes()[$parts[1]]) && $valid_path !== FALSE;
+  }
+
+  /**
+   * Creates an array of keymatches as strings, either from a value or config.
+   *
+   * @param string|null $saved_keymatches
+   *   Uses this value if provided, otherwise it gets the value stored in
+   *   config.
+   *
+   * @return array
+   *   Returns an array of keymatch strings.
+   */
+  public function getKeymatchConfiguration($saved_keymatches = NULL) {
+    if (!isset($saved_keymatches)) {
+      $saved_keymatches = $this->configInstance->get('keymatches');
+    }
+
+    // Convert newlines to array based on \r\n or \n without empty matches.
+    $saved_keymatches = preg_split('/(\r\n?|\n)/', $saved_keymatches, -1, PREG_SPLIT_NO_EMPTY);
+    // Filter out values with only spaces.
+    $saved_keymatches = array_filter($saved_keymatches,
+      function ($split) {
+        $split_safe = trim($split);
+        return !empty($split_safe);
+      }
+    );
+
+    // Make sure our array indexes are reset in case entries were skipped.
+    return array_values($saved_keymatches);
+  }
+
+  /**
+   * Creates KeymatchEntry objects and orders them by type.
+   *
+   * @param array $config
+   *   An array of keymatch strings as returned by getKeymatchConfiguration.
+   *
+   * @return array
+   *   Returns KeymatchEntry objects ordered by type.
+   */
+  public function getKeymatchEntriesByType(array $config) {
+    $keymatches_by_type = [];
+    foreach ($config as $keymatch_entry) {
+      if ($this->isValid($keymatch_entry)) {
+        $entry = $this->keymatchEntryFactory->createEntry($keymatch_entry);
+        $keymatches_by_type[$entry->getType()][] = $entry;
+      }
+    }
+    return $keymatches_by_type;
+  }
+
+}
-- 
GitLab