View source
<?php
namespace Drupal\node\Plugin\Search;
use Drupal\Core\Access\AccessibleInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\Config;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Query\PagerSelectExtender;
use Drupal\Core\Database\Query\SelectExtender;
use Drupal\Core\Database\StatementInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;
use Drupal\search\Plugin\ConfigurableSearchPluginBase;
use Drupal\search\Plugin\SearchIndexingInterface;
use Drupal\search\SearchIndexInterface;
use Drupal\search\SearchQuery;
use Symfony\Component\DependencyInjection\ContainerInterface;
class NodeSearch extends ConfigurableSearchPluginBase implements AccessibleInterface, SearchIndexingInterface, TrustedCallbackInterface {
protected $database;
protected $databaseReplica;
protected $entityTypeManager;
protected $moduleHandler;
protected $searchSettings;
protected $languageManager;
protected $account;
protected $renderer;
protected $searchIndex;
protected $rankings;
protected $advanced = [
'type' => [
'column' => 'n.type',
],
'language' => [
'column' => 'i.langcode',
],
'author' => [
'column' => 'n.uid',
],
'term' => [
'column' => 'ti.tid',
'join' => [
'table' => 'taxonomy_index',
'alias' => 'ti',
'condition' => 'n.nid = ti.nid',
],
],
];
const ADVANCED_FORM = 'advanced-form';
protected $messenger;
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container
->get('database'), $container
->get('entity_type.manager'), $container
->get('module_handler'), $container
->get('config.factory')
->get('search.settings'), $container
->get('language_manager'), $container
->get('renderer'), $container
->get('messenger'), $container
->get('current_user'), $container
->get('database.replica'), $container
->get('search.index'));
}
public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, EntityTypeManagerInterface $entity_type_manager, ModuleHandlerInterface $module_handler, Config $search_settings, LanguageManagerInterface $language_manager, RendererInterface $renderer, MessengerInterface $messenger, AccountInterface $account, Connection $database_replica, SearchIndexInterface $search_index) {
$this->database = $database;
$this->databaseReplica = $database_replica;
$this->entityTypeManager = $entity_type_manager;
$this->moduleHandler = $module_handler;
$this->searchSettings = $search_settings;
$this->languageManager = $language_manager;
$this->renderer = $renderer;
$this->messenger = $messenger;
$this->account = $account;
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this
->addCacheTags([
'node_list',
]);
$this->searchIndex = $search_index;
}
public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = AccessResult::allowedIfHasPermission($account, 'access content');
return $return_as_object ? $result : $result
->isAllowed();
}
public function isSearchExecutable() {
return !empty($this->keywords) || isset($this->searchParameters['f']) && count($this->searchParameters['f']);
}
public function getType() {
return $this
->getPluginId();
}
public function execute() {
if ($this
->isSearchExecutable()) {
$results = $this
->findResults();
if ($results) {
return $this
->prepareResults($results);
}
}
return [];
}
protected function findResults() {
$keys = $this->keywords;
$query = $this->databaseReplica
->select('search_index', 'i')
->extend(SearchQuery::class)
->extend(PagerSelectExtender::class);
$query
->join('node_field_data', 'n', '[n].[nid] = [i].[sid] AND [n].[langcode] = [i].[langcode]');
$query
->condition('n.status', 1)
->addTag('node_access')
->searchExpression($keys, $this
->getPluginId());
$parameters = $this
->getParameters();
if (!empty($parameters['f']) && is_array($parameters['f'])) {
$filters = [];
$pattern = '/^(' . implode('|', array_keys($this->advanced)) . '):([^ ]*)/i';
foreach ($parameters['f'] as $item) {
if (preg_match($pattern, $item, $m)) {
$filters[$m[1]][$m[2]] = $m[2];
}
}
foreach ($filters as $option => $matched) {
$info = $this->advanced[$option];
$operator = empty($info['operator']) ? 'OR' : $info['operator'];
$where = $this->databaseReplica
->condition($operator);
foreach ($matched as $value) {
$where
->condition($info['column'], $value);
}
$query
->condition($where);
if (!empty($info['join'])) {
$query
->join($info['join']['table'], $info['join']['alias'], $info['join']['condition']);
}
}
}
$this
->addNodeRankings($query);
$find = $query
->fields('i', [
'langcode',
])
->groupBy('i.langcode')
->limit(10)
->execute();
$status = $query
->getStatus();
if ($status & SearchQuery::EXPRESSIONS_IGNORED) {
$this->messenger
->addWarning($this
->t('Your search used too many AND/OR expressions. Only the first @count terms were included in this search.', [
'@count' => $this->searchSettings
->get('and_or_limit'),
]));
}
if ($status & SearchQuery::LOWER_CASE_OR) {
$this->messenger
->addWarning($this
->t('Search for either of the two terms with uppercase <strong>OR</strong>. For example, <strong>cats OR dogs</strong>.'));
}
if ($status & SearchQuery::NO_POSITIVE_KEYWORDS) {
$this->messenger
->addWarning($this
->formatPlural($this->searchSettings
->get('index.minimum_word_size'), 'You must include at least one keyword to match in the content, and punctuation is ignored.', 'You must include at least one keyword to match in the content. Keywords must be at least @count characters, and punctuation is ignored.'));
}
return $find;
}
protected function prepareResults(StatementInterface $found) {
$results = [];
$node_storage = $this->entityTypeManager
->getStorage('node');
$node_render = $this->entityTypeManager
->getViewBuilder('node');
$keys = $this->keywords;
foreach ($found as $item) {
$node = $node_storage
->load($item->sid)
->getTranslation($item->langcode);
$build = $node_render
->view($node, 'search_result', $item->langcode);
$type = $this->entityTypeManager
->getStorage('node_type')
->load($node
->bundle());
unset($build['#theme']);
$build['#pre_render'][] = [
$this,
'removeSubmittedInfo',
];
$rendered = $this->renderer
->renderPlain($build);
$this
->addCacheableDependency(CacheableMetadata::createFromRenderArray($build));
$rendered .= ' ' . $this->moduleHandler
->invoke('comment', 'node_update_index', [
$node,
]);
$extra = $this->moduleHandler
->invokeAll('node_search_result', [
$node,
]);
$username = [
'#theme' => 'username',
'#account' => $node
->getOwner(),
];
$result = [
'link' => $node
->toUrl('canonical', [
'absolute' => TRUE,
])
->toString(),
'type' => $type
->label(),
'title' => $node
->label(),
'node' => $node,
'extra' => $extra,
'score' => $item->calculated_score,
'snippet' => search_excerpt($keys, $rendered, $item->langcode),
'langcode' => $node
->language()
->getId(),
];
$this
->addCacheableDependency($node);
$this
->addCacheableDependency($node
->getOwner());
if ($type
->displaySubmitted()) {
$result += [
'user' => $this->renderer
->renderPlain($username),
'date' => $node
->getChangedTime(),
];
}
$results[] = $result;
}
return $results;
}
public function removeSubmittedInfo(array $build) {
unset($build['created']);
unset($build['uid']);
return $build;
}
protected function addNodeRankings(SelectExtender $query) {
if ($ranking = $this
->getRankings()) {
$tables =& $query
->getTables();
foreach ($ranking as $rank => $values) {
if (isset($this->configuration['rankings'][$rank]) && !empty($this->configuration['rankings'][$rank])) {
$node_rank = $this->configuration['rankings'][$rank];
if (isset($values['join']) && !isset($tables[$values['join']['alias']])) {
$query
->addJoin($values['join']['type'], $values['join']['table'], $values['join']['alias'], $values['join']['on']);
}
$arguments = $values['arguments'] ?? [];
$query
->addScore($values['score'], $arguments, $node_rank);
}
}
}
}
public function updateIndex() {
$limit = (int) $this->searchSettings
->get('index.cron_limit');
$query = $this->databaseReplica
->select('node', 'n');
$query
->addField('n', 'nid');
$query
->leftJoin('search_dataset', 'sd', '[sd].[sid] = [n].[nid] AND [sd].[type] = :type', [
':type' => $this
->getPluginId(),
]);
$query
->addExpression('CASE MAX([sd].[reindex]) WHEN NULL THEN 0 ELSE 1 END', 'ex');
$query
->addExpression('MAX([sd].[reindex])', 'ex2');
$query
->condition($query
->orConditionGroup()
->where('[sd].[sid] IS NULL')
->condition('sd.reindex', 0, '<>'));
$query
->orderBy('ex', 'DESC')
->orderBy('ex2')
->orderBy('n.nid')
->groupBy('n.nid')
->range(0, $limit);
$nids = $query
->execute()
->fetchCol();
if (!$nids) {
return;
}
$node_storage = $this->entityTypeManager
->getStorage('node');
$words = [];
try {
foreach ($node_storage
->loadMultiple($nids) as $node) {
$words += $this
->indexNode($node);
}
} finally {
$this->searchIndex
->updateWordWeights($words);
}
}
protected function indexNode(NodeInterface $node) {
$words = [];
$languages = $node
->getTranslationLanguages();
$node_render = $this->entityTypeManager
->getViewBuilder('node');
foreach ($languages as $language) {
$node = $node
->getTranslation($language
->getId());
$build = $node_render
->view($node, 'search_index', $language
->getId());
unset($build['#theme']);
$build['search_title'] = [
'#prefix' => '<h1>',
'#plain_text' => $node
->label(),
'#suffix' => '</h1>',
'#weight' => -1000,
];
$text = $this->renderer
->renderPlain($build);
$extra = $this->moduleHandler
->invokeAll('node_update_index', [
$node,
]);
foreach ($extra as $t) {
$text .= $t;
}
$words += $this->searchIndex
->index($this
->getPluginId(), $node
->id(), $language
->getId(), $text, FALSE);
}
return $words;
}
public function indexClear() {
$this->searchIndex
->clear($this
->getPluginId());
}
public function markForReindex() {
$this->searchIndex
->markForReindex($this
->getPluginId());
}
public function indexStatus() {
$total = $this->database
->query('SELECT COUNT(*) FROM {node}')
->fetchField();
$remaining = $this->database
->query("SELECT COUNT(DISTINCT [n].[nid]) FROM {node} [n] LEFT JOIN {search_dataset} [sd] ON [sd].[sid] = [n].[nid] AND [sd].[type] = :type WHERE [sd].[sid] IS NULL OR [sd].[reindex] <> 0", [
':type' => $this
->getPluginId(),
])
->fetchField();
return [
'remaining' => $remaining,
'total' => $total,
];
}
public function searchFormAlter(array &$form, FormStateInterface $form_state) {
$parameters = $this
->getParameters();
$keys = $this
->getKeywords();
$used_advanced = !empty($parameters[self::ADVANCED_FORM]);
if ($used_advanced) {
$f = isset($parameters['f']) ? (array) $parameters['f'] : [];
$defaults = $this
->parseAdvancedDefaults($f, $keys);
}
else {
$defaults = [
'keys' => $keys,
];
}
$form['basic']['keys']['#default_value'] = $defaults['keys'];
$form['advanced'] = [
'#type' => 'details',
'#title' => t('Advanced search'),
'#attributes' => [
'class' => [
'search-advanced',
],
],
'#access' => $this->account && $this->account
->hasPermission('use advanced search'),
'#open' => $used_advanced,
];
$form['advanced']['keywords-fieldset'] = [
'#type' => 'fieldset',
'#title' => t('Keywords'),
];
$form['advanced']['keywords'] = [
'#prefix' => '<div class="criterion">',
'#suffix' => '</div>',
];
$form['advanced']['keywords-fieldset']['keywords']['or'] = [
'#type' => 'textfield',
'#title' => t('Containing any of the words'),
'#size' => 30,
'#maxlength' => 255,
'#default_value' => $defaults['or'] ?? '',
];
$form['advanced']['keywords-fieldset']['keywords']['phrase'] = [
'#type' => 'textfield',
'#title' => t('Containing the phrase'),
'#size' => 30,
'#maxlength' => 255,
'#default_value' => $defaults['phrase'] ?? '',
];
$form['advanced']['keywords-fieldset']['keywords']['negative'] = [
'#type' => 'textfield',
'#title' => t('Containing none of the words'),
'#size' => 30,
'#maxlength' => 255,
'#default_value' => $defaults['negative'] ?? '',
];
$types = array_map([
'\\Drupal\\Component\\Utility\\Html',
'escape',
], node_type_get_names());
$form['advanced']['types-fieldset'] = [
'#type' => 'fieldset',
'#title' => t('Types'),
];
$form['advanced']['types-fieldset']['type'] = [
'#type' => 'checkboxes',
'#title' => t('Only of the type(s)'),
'#prefix' => '<div class="criterion">',
'#suffix' => '</div>',
'#options' => $types,
'#default_value' => $defaults['type'] ?? [],
];
$form['advanced']['submit'] = [
'#type' => 'submit',
'#value' => t('Advanced search'),
'#prefix' => '<div class="action">',
'#suffix' => '</div>',
'#weight' => 100,
];
$language_options = [];
$language_list = $this->languageManager
->getLanguages(LanguageInterface::STATE_ALL);
foreach ($language_list as $langcode => $language) {
$language_options[$langcode] = $language
->isLocked() ? t('- @name -', [
'@name' => $language
->getName(),
]) : $language
->getName();
}
if (count($language_options) > 1) {
$form['advanced']['lang-fieldset'] = [
'#type' => 'fieldset',
'#title' => t('Languages'),
];
$form['advanced']['lang-fieldset']['language'] = [
'#type' => 'checkboxes',
'#title' => t('Languages'),
'#prefix' => '<div class="criterion">',
'#suffix' => '</div>',
'#options' => $language_options,
'#default_value' => $defaults['language'] ?? [],
];
}
}
public function buildSearchUrlQuery(FormStateInterface $form_state) {
$keys = trim($form_state
->getValue('keys'));
$advanced = FALSE;
$filters = [];
if ($form_state
->hasValue('type') && is_array($form_state
->getValue('type'))) {
foreach ($form_state
->getValue('type') as $type) {
if ($type) {
$advanced = TRUE;
$filters[] = 'type:' . $type;
}
}
}
if ($form_state
->hasValue('term') && is_array($form_state
->getValue('term'))) {
foreach ($form_state
->getValue('term') as $term) {
$filters[] = 'term:' . $term;
$advanced = TRUE;
}
}
if ($form_state
->hasValue('language') && is_array($form_state
->getValue('language'))) {
foreach ($form_state
->getValue('language') as $language) {
if ($language) {
$advanced = TRUE;
$filters[] = 'language:' . $language;
}
}
}
if ($form_state
->getValue('or') != '') {
if (preg_match_all('/ ("[^"]+"|[^" ]+)/i', ' ' . $form_state
->getValue('or'), $matches)) {
$keys .= ' ' . implode(' OR ', $matches[1]);
$advanced = TRUE;
}
}
if ($form_state
->getValue('negative') != '') {
if (preg_match_all('/ ("[^"]+"|[^" ]+)/i', ' ' . $form_state
->getValue('negative'), $matches)) {
$keys .= ' -' . implode(' -', $matches[1]);
$advanced = TRUE;
}
}
if ($form_state
->getValue('phrase') != '') {
$keys .= ' "' . str_replace('"', ' ', $form_state
->getValue('phrase')) . '"';
$advanced = TRUE;
}
$keys = trim($keys);
$query = [
'keys' => $keys,
];
if ($filters) {
$query['f'] = $filters;
}
if ($advanced) {
$query[self::ADVANCED_FORM] = '1';
}
return $query;
}
protected function parseAdvancedDefaults($f, $keys) {
$defaults = [];
foreach ($f as $advanced) {
[
$key,
$value,
] = explode(':', $advanced, 2);
if (!isset($defaults[$key])) {
$defaults[$key] = [];
}
$defaults[$key][] = $value;
}
$matches = [];
$keys = ' ' . $keys . ' ';
if (preg_match('/ "([^"]+)" /', $keys, $matches)) {
$keys = str_replace($matches[0], ' ', $keys);
$defaults['phrase'] = $matches[1];
}
if (preg_match_all('/ -([^ ]+)/', $keys, $matches)) {
$keys = str_replace($matches[0], ' ', $keys);
$defaults['negative'] = implode(' ', $matches[1]);
}
if (preg_match('/ [^ ]+( OR [^ ]+)+ /', $keys, $matches)) {
$keys = str_replace($matches[0], ' ', $keys);
$words = explode(' OR ', trim($matches[0]));
$defaults['or'] = implode(' ', $words);
}
$defaults['keys'] = trim($keys);
return $defaults;
}
protected function getRankings() {
if (!$this->rankings) {
$this->rankings = $this->moduleHandler
->invokeAll('ranking');
}
return $this->rankings;
}
public function defaultConfiguration() {
$configuration = [
'rankings' => [],
];
return $configuration;
}
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['content_ranking'] = [
'#type' => 'details',
'#title' => t('Content ranking'),
'#open' => TRUE,
];
$form['content_ranking']['info'] = [
'#markup' => '<p><em>' . $this
->t('Influence is a numeric multiplier used in ordering search results. A higher number means the corresponding factor has more influence on search results; zero means the factor is ignored. Changing these numbers does not require the search index to be rebuilt. Changes take effect immediately.') . '</em></p>',
];
$header = [
$this
->t('Factor'),
$this
->t('Influence'),
];
$form['content_ranking']['rankings'] = [
'#type' => 'table',
'#header' => $header,
];
$range = range(0, 10);
$options = array_combine($range, $range);
foreach ($this
->getRankings() as $var => $values) {
$form['content_ranking']['rankings'][$var]['name'] = [
'#markup' => $values['title'],
];
$form['content_ranking']['rankings'][$var]['value'] = [
'#type' => 'select',
'#options' => $options,
'#attributes' => [
'aria-label' => $this
->t("Influence of '@title'", [
'@title' => $values['title'],
]),
],
'#default_value' => $this->configuration['rankings'][$var] ?? 0,
];
}
return $form;
}
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
foreach ($this
->getRankings() as $var => $values) {
if (!$form_state
->isValueEmpty([
'rankings',
$var,
'value',
])) {
$this->configuration['rankings'][$var] = $form_state
->getValue([
'rankings',
$var,
'value',
]);
}
else {
unset($this->configuration['rankings'][$var]);
}
}
}
public static function trustedCallbacks() {
return [
'removeSubmittedInfo',
];
}
}