search_api_autocomplete-8.x-1.x-dev/src/Plugin/search_api_autocomplete/suggester/LiveResults.php
src/Plugin/search_api_autocomplete/suggester/LiveResults.php
<?php
namespace Drupal\search_api_autocomplete\Plugin\search_api_autocomplete\suggester;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\ComplexDataInterface;
use Drupal\Core\Url;
use Drupal\search_api\Datasource\DatasourceInterface;
use Drupal\search_api\LoggerTrait;
use Drupal\search_api\Plugin\PluginFormTrait;
use Drupal\search_api\Processor\ProcessorPluginManager;
use Drupal\search_api\Query\QueryInterface;
use Drupal\search_api\Query\ResultSetInterface;
use Drupal\search_api\SearchApiException;
use Drupal\search_api_autocomplete\Attribute\SearchApiAutocompleteSuggester;
use Drupal\search_api_autocomplete\SearchApiAutocompleteException;
use Drupal\search_api_autocomplete\Suggester\SuggesterPluginBase;
use Drupal\search_api_autocomplete\Suggestion\SuggestionFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a suggester plugin that displays live results.
*/
#[SearchApiAutocompleteSuggester(
id: 'live_results',
label: new TranslatableMarkup('Display live results'),
description: new TranslatableMarkup('Display live results to visitors as they type. (Unless the server is configured to find partial matches, this will most likely only produce results once the visitor has finished typing.)'),
)]
class LiveResults extends SuggesterPluginBase implements PluginFormInterface {
use LoggerTrait;
use PluginFormTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface|null
*/
protected $entityTypeManager;
/**
* The Search API processor plugin manager.
*
* @var \Drupal\search_api\Processor\ProcessorPluginManager
*/
protected $processorPluginManager;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
/** @var static $plugin */
$plugin = parent::create($container, $configuration, $plugin_id, $plugin_definition);
$plugin->setEntityTypeManager($container->get('entity_type.manager'));
$plugin->setLogger($container->get('logger.channel.search_api_autocomplete'));
$plugin->setProcessorPluginManager($container->get('plugin.manager.search_api.processor'));
return $plugin;
}
/**
* Retrieves the entity type manager.
*
* @return \Drupal\Core\Entity\EntityTypeManagerInterface
* The entity type manager.
*/
public function getEntityTypeManager() {
return $this->entityTypeManager ?: \Drupal::service('entity_type.manager');
}
/**
* Sets the entity type manager.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The new entity type manager.
*
* @return $this
*/
public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
return $this;
}
/**
* Retrieves the Search API processor plugin manager.
*
* @return \Drupal\search_api\Processor\ProcessorPluginManager
* The Search API processor plugin manager.
*/
public function getProcessorPluginManager(): ProcessorPluginManager {
return $this->processorPluginManager ?: \Drupal::service('plugin.manager.search_api.processor');
}
/**
* Sets the Search API processor plugin manager.
*
* @param \Drupal\search_api\Processor\ProcessorPluginManager $processor_plugin_manager
* The Search API processor plugin manager.
*
* @return $this
*/
public function setProcessorPluginManager(ProcessorPluginManager $processor_plugin_manager): self {
$this->processorPluginManager = $processor_plugin_manager;
return $this;
}
/**
* Retrieves the logger.
*
* @return \Psr\Log\LoggerInterface
* The logger.
*/
public function getLogger() {
return $this->logger ?: \Drupal::service('logger.channel.search_api_autocomplete');
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'fields' => [],
'highlight' => [
'enabled' => FALSE,
'field' => '',
],
'suggest_keys' => FALSE,
'view_modes' => [],
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['#attached']['library'][] = 'search_api/drupal.search_api.admin_css';
// Let the user select the fulltext fields to use for the searches.
$search = $this->getSearch();
$fields = $search->getIndex()->getFields();
$fulltext_fields = $search->getIndex()->getFulltextFields();
$options = [];
foreach ($fulltext_fields as $field) {
$options[$field] = $fields[$field]->getFieldIdentifier();
}
$form['fields'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Override used fields'),
'#description' => $this->t('Select the fields which should be searched for matches when looking for autocompletion suggestions. Leave blank to use the same fields as the underlying search.'),
'#options' => $options,
'#default_value' => array_combine($this->configuration['fields'], $this->configuration['fields']),
'#attributes' => ['class' => ['search-api-checkboxes-list']],
];
$form['suggest_keys'] = [
'#type' => 'checkbox',
'#title' => $this->t('Suggest result labels as keywords'),
'#default_value' => $this->configuration['suggest_keys'],
'#description' => $this->t('By default, the found results will be displayed to the visitor as links to those results. When this option is enabled, the label of the result will be instead presented as a suggestion for search keywords.'),
];
$form['view_modes'] = [
'#type' => 'fieldset',
'#title' => $this->t('View modes'),
'#description' => $this->t('Select the view modes to use for live results.'),
'#states' => [
'invisible' => [
':input[name="suggesters[settings][live_results][suggest_keys]"]' => [
'checked' => TRUE,
],
],
],
];
// Let the user select the view mode to use for live results.
$selected_view_modes = $this->configuration['view_modes'];
foreach ($search->getIndex()->getDatasources() as $datasource_id => $datasource) {
foreach ($datasource->getBundles() as $bundle => $name) {
$view_modes = $datasource->getViewModes($bundle);
// If there are no view modes available, there's no need to display a
// select box. Just default to "Use only label".
if (!$view_modes) {
$form['view_modes'][$datasource_id][$bundle] = [
'#type' => 'value',
'#value' => '',
];
continue;
}
$default_value = '';
if (isset($selected_view_modes[$datasource_id][$bundle])) {
$default_value = $selected_view_modes[$datasource_id][$bundle];
}
$form['view_modes'][$datasource_id][$bundle] = [
'#type' => 'select',
'#title' => $name,
'#options' => [
'' => $this->t('Use only label'),
] + $view_modes,
'#default_value' => $default_value,
];
}
}
$args = [
'%highlight' => $this->t('Highlight'),
];
try {
$args['%highlight'] = $this->getProcessorPluginManager()
->createInstance('highlight')
->label();
}
catch (PluginException $e) {
// Ignore – really not that important.
}
$form['highlight'] = [
'#type' => 'fieldset',
'#title' => $this->t('Highlight live results'),
'#description' => $this->t('Replace the live result with the output of a highlighted field, if available. Requires the %highlight processor to be enabled.', $args),
'#description_display' => 'before',
'#disabled' => !$this->isHighlightingAvailable(),
'#tree' => TRUE,
];
$form['highlight']['enabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable highlighting of live results'),
'#default_value' => $this->configuration['highlight']['enabled'],
];
$form['highlight']['field'] = [
'#type' => 'select',
'#empty_value' => '',
'#title' => $this->t('Select the field to replace live result'),
'#options' => $options,
'#default_value' => $this->configuration['highlight']['field'],
'#states' => [
'visible' => [
':input[name="suggesters[settings][live_results][highlight][enabled]"]' => [
'checked' => TRUE,
],
],
'required' => [
':input[name="suggesters[settings][live_results][highlight][enabled]"]' => [
'checked' => TRUE,
],
],
],
];
return $form;
}
/**
* Checks whether the "highlight" processor is enabled for this index.
*
* @return bool
* TRUE if the "highlight" processor is enabled for this index, FALSE
* otherwise.
*/
protected function isHighlightingAvailable(): bool {
try {
return $this->getSearch()->getIndex()->isValidProcessor('highlight');
}
catch (SearchApiAutocompleteException $e) {
return FALSE;
}
}
/**
* Checks whether highlighting is configured and available.
*
* @return bool
* TRUE if highlighting is configured and available, FALSE otherwise.
*/
protected function isHighlightingEnabled(): bool {
return $this->configuration['highlight']['enabled']
&& $this->configuration['highlight']['field']
&& $this->isHighlightingAvailable();
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$values = $form_state->getValues();
// Change the "fields" option to an array of just the selected fields.
$values['fields'] = array_keys(array_filter($values['fields']));
// Cast to bool to prevent schema mismatches.
$values['highlight']['enabled'] = (bool) $values['highlight']['enabled'];
$this->setConfiguration($values);
}
/**
* {@inheritdoc}
*/
public function getAutocompleteSuggestions(QueryInterface $query, $incomplete_key, $user_input) {
$fulltext_fields = $this->configuration['fields'];
$index = $query->getIndex();
if ($fulltext_fields) {
// Take care only to set fields that are still indexed fulltext fields.
$index_fields = $index->getFulltextFields();
$fulltext_fields = array_intersect($fulltext_fields, $index_fields);
if ($fulltext_fields) {
$query->setFulltextFields($fulltext_fields);
}
else {
$args = [
'@suggester' => $this->label(),
'@search' => $this->getSearch()->label(),
'@index' => $index->label(),
];
$this->getLogger()->warning('Only invalid fulltext fields set for suggester "@suggester" in autocomplete settings for search "@search" on index "@index".', $args);
}
}
$query->keys($user_input);
try {
$results = $query->execute();
}
catch (SearchApiException $e) {
// If the query fails, there's nothing we can do about that.
return [];
}
// Pre-load the result items for performance reasons.
$objects = $to_load = [];
foreach ($results->getResultItems() as $item_id => $item) {
try {
$object = $item->getOriginalObject(FALSE);
if ($object) {
$objects[$item_id] = $object;
}
else {
$to_load[$item_id] = $item_id;
}
}
catch (SearchApiException) {
// Impossible.
}
}
if ($to_load) {
$objects += $index->loadItemsMultiple($to_load);
}
$factory = new SuggestionFactory($user_input);
$suggestions = [];
$view_modes = $this->configuration['view_modes'];
$suggest_keys = $this->configuration['suggest_keys'];
$highlight_field = NULL;
if ($this->isHighlightingEnabled()) {
$highlight_field = $this->configuration['highlight']['field'];
}
foreach ($results->getResultItems() as $item_id => $item) {
// If the result object could not be loaded, there's little we can do
// here.
if (empty($objects[$item_id])) {
continue;
}
$object = $objects[$item_id];
if (isset($to_load[$item_id])) {
$item->setOriginalObject($object);
}
try {
$datasource = $item->getDatasource();
}
catch (SearchApiException $e) {
// This should almost never happen, but theoretically it could, so we
// just skip the item if this happens.
continue;
}
// Check whether the user has access to this item.
if (!$item->getAccessResult()->isAllowed()) {
continue;
}
// Can't include results that don't have a URL.
$url = $this->getItemUrl($object, $datasource, $results);
if (!$url) {
continue;
}
$datasource_id = $item->getDatasourceId();
$bundle = $datasource->getItemBundle($object);
// Use highlighted field, if configured.
if ($highlight_field) {
$highlighted_fields = $item->getExtraData('highlighted_fields');
if (isset($highlighted_fields[$highlight_field][0])) {
$highlighted_field = [
'#type' => 'markup',
'#markup' => Xss::filterAdmin($highlighted_fields[$highlight_field][0]),
];
$suggestions[] = $factory->createUrlSuggestion($url, NULL, $highlighted_field);
continue;
}
}
if ($suggest_keys) {
// Return the item label as suggested keywords, if one can be retrieved.
$label = $datasource->getItemLabel($object);
if ($label) {
$suggestions[] = $factory->createFromSuggestedKeys($label);
}
}
elseif (empty($view_modes[$datasource_id][$bundle])) {
// If no view mode was selected for this bundle, just use the label.
$label = $datasource->getItemLabel($object);
$suggestions[] = $factory->createUrlSuggestion($url, $label);
}
else {
$view_mode = $view_modes[$datasource_id][$bundle];
$render = $datasource->viewItem($object, $view_mode);
if ($render) {
// Add the excerpt to the render array to allow adding it to view
// modes.
$render['#search_api_excerpt'] = $item->getExcerpt();
$suggestions[] = $factory->createUrlSuggestion($url, NULL, $render);
}
}
}
return $suggestions;
}
/**
* Retrieves a result item's URL.
*
* Extracted into a separate method for easier overriding.
*
* @param \Drupal\Core\TypedData\ComplexDataInterface $object
* The result item.
* @param \Drupal\search_api\Datasource\DatasourceInterface $datasource
* The datasource to which the item belongs.
* @param \Drupal\search_api\Query\ResultSetInterface $results
* The search results.
*
* @return \Drupal\Core\Url|null
* The item's URL, if any.
*/
protected function getItemUrl(
ComplexDataInterface $object,
DatasourceInterface $datasource,
ResultSetInterface $results,
): ?Url {
return $datasource->getItemUrl($object);
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$this->dependencies = parent::calculateDependencies();
$index = $this->getSearch()->getIndex();
foreach ($this->configuration['view_modes'] as $datasource_id => $bundles) {
try {
$datasource = $index->getDatasource($datasource_id);
}
catch (SearchApiException) {
continue;
}
$entity_type_id = $datasource->getEntityTypeId();
// If the datasource doesn't represent an entity type, we unfortunately
// can't know what dependencies its view modes might have.
if (!$entity_type_id) {
continue;
}
foreach ($bundles as $view_mode) {
if ($view_mode === '') {
continue;
}
/** @var \Drupal\Core\Entity\EntityViewModeInterface $view_mode_entity */
$view_mode_entity = $this->getEntityTypeManager()
->getStorage('entity_view_mode')
->load($entity_type_id . '.' . $view_mode);
if ($view_mode_entity) {
$key = $view_mode_entity->getConfigDependencyKey();
$name = $view_mode_entity->getConfigDependencyName();
$this->addDependency($key, $name);
}
}
}
return $this->dependencies;
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
// All dependencies of this suggester are entity view modes, so we go
// through all of the view modes set for specific bundles and remove all
// those which have been removed (setting them back to "Use only label").
// First, we collect all view mode dependencies keyed by entity ID, to make
// this easier.
$removed_view_modes = [];
$non_view_mode_dependencies = FALSE;
foreach ($dependencies as $objects) {
foreach ($objects as $object) {
if ($object instanceof EntityInterface
&& $object->getEntityTypeId() === 'entity_view_mode') {
$removed_view_modes[$object->id()] = TRUE;
}
else {
$non_view_mode_dependencies = TRUE;
}
}
}
// Then, we go through all bundle settings and look for those removed view
// modes.
$index = $this->getSearch()->getIndex();
foreach ($this->configuration['view_modes'] as $datasource_id => $bundles) {
$datasource = $index->getDatasource($datasource_id);
$entity_type_id = $datasource->getEntityTypeId();
// If the datasource doesn't represent an entity type, we unfortunately
// can't know what dependencies its view modes might have.
if (!$entity_type_id) {
continue;
}
foreach ($bundles as $bundle => $view_mode) {
if ($view_mode === '') {
continue;
}
$view_mode_entity_id = $entity_type_id . '.' . $view_mode;
if (!empty($removed_view_modes[$view_mode_entity_id])) {
$this->configuration['view_modes'][$datasource_id][$bundle] = '';
}
}
}
// This will have successfully dealt with all affected dependencies unless
// non-view mode dependencies (perhaps set by the parent class) were
// involved.
return !$non_view_mode_dependencies;
}
}
