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\Condition;
use Drupal\Core\Database\Query\SelectExtender;
use Drupal\Core\Database\StatementInterface;
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
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 {
use DeprecatedServicePropertyTrait;
protected $deprecatedProperties = [
'entityManager' => 'entity.manager',
];
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 = NULL, Connection $database_replica = NULL, SearchIndexInterface $search_index = NULL) {
$this->database = $database;
$this->databaseReplica = $database_replica ?: $database;
$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',
]);
if (!$search_index) {
@trigger_error('Calling NodeSearch::__construct() without the $search_index argument is deprecated in drupal:8.8.0 and is required in drupal:9.0.0. See https://www.drupal.org/node/3075696', E_USER_DEPRECATED);
$search_index = \Drupal::service('search.index');
}
$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('Drupal\\search\\SearchQuery')
->extend('Drupal\\Core\\Database\\Query\\PagerSelectExtender');
$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 = new 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 = isset($values['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' => isset($defaults['or']) ? $defaults['or'] : '',
];
$form['advanced']['keywords-fieldset']['keywords']['phrase'] = [
'#type' => 'textfield',
'#title' => t('Containing the phrase'),
'#size' => 30,
'#maxlength' => 255,
'#default_value' => isset($defaults['phrase']) ? $defaults['phrase'] : '',
];
$form['advanced']['keywords-fieldset']['keywords']['negative'] = [
'#type' => 'textfield',
'#title' => t('Containing none of the words'),
'#size' => 30,
'#maxlength' => 255,
'#default_value' => isset($defaults['negative']) ? $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' => isset($defaults['type']) ? $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' => isset($defaults['language']) ? $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) {
list($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' => isset($this->configuration['rankings'][$var]) ? $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',
];
}
}