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