Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,47 @@
<?php
namespace Drupal\search\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a SearchPlugin type annotation object.
*
* SearchPlugin classes define search types for the core Search module. Each
* search type can be used to create search pages from the Search settings page.
*
* @see SearchPluginBase
*
* @ingroup search
*
* @Annotation
*/
class SearchPlugin extends Plugin {
/**
* A unique identifier for the search plugin.
*
* @var string
*/
public $id;
/**
* The title for the search page tab.
*
* @var \Drupal\Core\Annotation\Translation
*
* @todo This will potentially be translated twice or cached with the wrong
* translation until the search tabs are converted to local task plugins.
*
* @ingroup plugin_translatable
*/
public $title;
/**
* Whether or not search results should be displayed in admin theme.
*
* @var bool
*/
public $use_admin_theme = FALSE;
}

View File

@ -0,0 +1,40 @@
<?php
namespace Drupal\search\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines a Search type attribute for plugin discovery.
*
* Search classes define search types for the core Search module. Each search
* type can be used to create search pages from the Search settings page.
*
* @see SearchPluginBase
*
* @ingroup search
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class Search extends Plugin {
/**
* Constructs a Search attribute.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $title
* The title for the search page tab.
* @param bool $use_admin_theme
* Whether search results should be displayed in admin theme or not.
* @param class-string|null $deriver
* (optional) The deriver class.
*/
public function __construct(
public readonly string $id,
public readonly ?TranslatableMarkup $title = NULL,
public readonly bool $use_admin_theme = FALSE,
public readonly ?string $deriver = NULL,
) {}
}

View File

@ -0,0 +1,227 @@
<?php
namespace Drupal\search\Controller;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Render\RendererInterface;
use Drupal\search\Form\SearchPageForm;
use Drupal\search\SearchPageInterface;
use Drupal\search\SearchPageRepositoryInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Route controller for search.
*/
class SearchController extends ControllerBase {
/**
* The search page repository.
*
* @var \Drupal\search\SearchPageRepositoryInterface
*/
protected $searchPageRepository;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a new search controller.
*
* @param \Drupal\search\SearchPageRepositoryInterface $search_page_repository
* The search page repository.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(SearchPageRepositoryInterface $search_page_repository, RendererInterface $renderer) {
$this->searchPageRepository = $search_page_repository;
$this->logger = $this->getLogger('search');
$this->renderer = $renderer;
}
/**
* Creates a render array for the search page.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
* @param \Drupal\search\SearchPageInterface $entity
* The search page entity.
*
* @return array
* The search form and search results build array.
*/
public function view(Request $request, SearchPageInterface $entity) {
$build = [];
$plugin = $entity->getPlugin();
// Build the form first, because it may redirect during the submit,
// and we don't want to build the results based on last time's request.
$build['#cache']['contexts'][] = 'url.query_args:keys';
if ($request->query->has('keys')) {
$keys = trim($request->query->get('keys'));
$plugin->setSearch($keys, $request->query->all(), $request->attributes->all());
}
$build['#title'] = $plugin->suggestedTitle();
$build['search_form'] = $this->formBuilder()->getForm(SearchPageForm::class, $entity);
// Build search results, if keywords or other search parameters are in the
// GET parameters. Note that we need to try the search if 'keys' is in
// there at all, vs. being empty, due to advanced search.
$results = [];
if ($request->query->has('keys')) {
if ($plugin->isSearchExecutable()) {
// Log the search.
if ($this->config('search.settings')->get('logging')) {
$this->logger->info('Searched %type for %keys.', ['%keys' => $keys, '%type' => $entity->label()]);
}
// Collect the search results.
$results = $plugin->buildResults();
}
else {
// The search not being executable means that no keywords or other
// conditions were entered.
$this->messenger()->addError($this->t('Enter some keywords.'));
}
}
if (count($results)) {
$build['search_results_title'] = [
'#markup' => '<h2>' . $this->t('Search results') . '</h2>',
];
}
$build['search_results'] = [
'#theme' => ['item_list__search_results__' . $plugin->getPluginId(), 'item_list__search_results'],
'#items' => $results,
'#empty' => [
'#type' => 'html_tag',
'#tag' => 'em',
'#value' => $this->t('Your search yielded no results.'),
],
'#list_type' => 'ol',
'#context' => [
'plugin' => $plugin->getPluginId(),
],
];
$this->renderer->addCacheableDependency($build, $entity);
if ($plugin instanceof CacheableDependencyInterface) {
$this->renderer->addCacheableDependency($build, $plugin);
}
// If this plugin uses a search index, then also add the cache tag tracking
// that search index, so that cached search result pages are invalidated
// when necessary.
if ($plugin->getType()) {
$build['search_results']['#cache']['tags'][] = 'search_index';
$build['search_results']['#cache']['tags'][] = 'search_index:' . $plugin->getType();
}
$build['pager'] = [
'#type' => 'pager',
];
return $build;
}
/**
* Creates a render array for the search help page.
*
* @param \Drupal\search\SearchPageInterface $entity
* The search page entity.
*
* @return array
* The search help page.
*/
public function searchHelp(SearchPageInterface $entity) {
$build = [];
$build['search_help'] = $entity->getPlugin()->getHelp();
return $build;
}
/**
* Redirects to a search page.
*
* This is used to redirect from /search to the default search page.
*
* @param \Drupal\search\SearchPageInterface $entity
* The search page entity.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirect to the search page.
*/
public function redirectSearchPage(SearchPageInterface $entity) {
return $this->redirect('search.view_' . $entity->id());
}
/**
* Route title callback.
*
* @param \Drupal\search\SearchPageInterface $search_page
* The search page entity.
*
* @return string
* The title for the search page edit form.
*/
public function editTitle(SearchPageInterface $search_page) {
return $this->t('Edit %label search page', ['%label' => $search_page->label()]);
}
/**
* Performs an operation on the search page entity.
*
* @param \Drupal\search\SearchPageInterface $search_page
* The search page entity.
* @param string $op
* The operation to perform, usually 'enable' or 'disable'.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirect back to the search settings page.
*/
public function performOperation(SearchPageInterface $search_page, $op) {
$search_page->$op()->save();
if ($op == 'enable') {
$this->messenger()->addStatus($this->t('The %label search page has been enabled.', ['%label' => $search_page->label()]));
}
elseif ($op == 'disable') {
$this->messenger()->addStatus($this->t('The %label search page has been disabled.', ['%label' => $search_page->label()]));
}
$url = $search_page->toUrl('collection');
return $this->redirect($url->getRouteName(), $url->getRouteParameters(), $url->getOptions());
}
/**
* Sets the search page as the default.
*
* @param \Drupal\search\SearchPageInterface $search_page
* The search page entity.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirect to the search settings page.
*/
public function setAsDefault(SearchPageInterface $search_page) {
// Set the default page to this search page.
$this->searchPageRepository->setDefaultSearchPage($search_page);
$this->messenger()->addStatus($this->t('The default search page is now %label. Be sure to check the ordering of your search pages.', ['%label' => $search_page->label()]));
return $this->redirect('entity.search_page.collection');
}
}

View File

@ -0,0 +1,272 @@
<?php
namespace Drupal\search\Entity;
use Drupal\Core\Entity\Attribute\ConfigEntityType;
use Drupal\Core\Entity\EntityDeleteForm;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
use Drupal\search\Form\SearchPageAddForm;
use Drupal\search\Form\SearchPageEditForm;
use Drupal\search\Plugin\SearchIndexingInterface;
use Drupal\search\Plugin\SearchPluginCollection;
use Drupal\search\SearchPageAccessControlHandler;
use Drupal\search\SearchPageInterface;
use Drupal\search\SearchPageListBuilder;
/**
* Defines a configured search page.
*/
#[ConfigEntityType(
id: 'search_page',
label: new TranslatableMarkup('Search page'),
label_collection: new TranslatableMarkup('Search pages'),
label_singular: new TranslatableMarkup('search page'),
label_plural: new TranslatableMarkup('search pages'),
config_prefix: 'page',
entity_keys: [
'id' => 'id',
'label' => 'label',
'weight' => 'weight',
'status' => 'status',
],
handlers: [
'access' => SearchPageAccessControlHandler::class,
'list_builder' => SearchPageListBuilder::class,
'form' => [
'add' => SearchPageAddForm::class,
'edit' => SearchPageEditForm::class,
'delete' => EntityDeleteForm::class,
],
],
links: [
'edit-form' => '/admin/config/search/pages/manage/{search_page}',
'delete-form' => '/admin/config/search/pages/manage/{search_page}/delete',
'enable' => '/admin/config/search/pages/manage/{search_page}/enable',
'disable' => '/admin/config/search/pages/manage/{search_page}/disable',
'set-default' => '/admin/config/search/pages/manage/{search_page}/set-default',
'collection' => '/admin/config/search/pages',
],
admin_permission: 'administer search',
label_count: [
'singular' => '@count search page',
'plural' => '@count search pages',
],
config_export: [
'id',
'label',
'path',
'weight',
'plugin',
'configuration',
],
)]
class SearchPage extends ConfigEntityBase implements SearchPageInterface, EntityWithPluginCollectionInterface {
/**
* The name (plugin ID) of the search page entity.
*
* @var string
*/
protected $id;
/**
* The label of the search page entity.
*
* @var string
*/
protected $label;
/**
* The configuration of the search page entity.
*
* @var array
*/
protected $configuration = [];
/**
* The search plugin ID.
*
* @var string
*/
protected $plugin;
/**
* The path this search page will appear upon.
*
* This value is appended to 'search/' when building the path.
*
* @var string
*/
protected $path;
/**
* The weight of the search page.
*
* @var int
*/
protected $weight;
/**
* The plugin collection that stores search plugins.
*
* @var \Drupal\search\Plugin\SearchPluginCollection
*/
protected $pluginCollection;
/**
* {@inheritdoc}
*/
public function getPlugin() {
return $this->getPluginCollection()->get($this->plugin);
}
/**
* Encapsulates the creation of the search page's LazyPluginCollection.
*
* @return \Drupal\Component\Plugin\LazyPluginCollection
* The search page's plugin collection.
*/
protected function getPluginCollection() {
if (!$this->pluginCollection) {
$this->pluginCollection = new SearchPluginCollection($this->searchPluginManager(), $this->plugin, $this->configuration, $this->id());
}
return $this->pluginCollection;
}
/**
* {@inheritdoc}
*/
public function getPluginCollections() {
return ['configuration' => $this->getPluginCollection()];
}
/**
* {@inheritdoc}
*/
public function setPlugin($plugin_id) {
$this->plugin = $plugin_id;
$this->getPluginCollection()->addInstanceID($plugin_id);
}
/**
* {@inheritdoc}
*/
public function isIndexable() {
return $this->status() && $this->getPlugin() instanceof SearchIndexingInterface;
}
/**
* {@inheritdoc}
*/
public function isDefaultSearch() {
return $this->searchPageRepository()->getDefaultSearchPage() == $this->id();
}
/**
* {@inheritdoc}
*/
public function getPath() {
return $this->path;
}
/**
* {@inheritdoc}
*/
public function getWeight() {
return $this->weight;
}
/**
* {@inheritdoc}
*/
public function postCreate(EntityStorageInterface $storage) {
parent::postCreate($storage);
// @todo Use self::applyDefaultValue() once
// https://www.drupal.org/node/2004756 is in.
if (!isset($this->weight)) {
$this->weight = $this->isDefaultSearch() ? -10 : 0;
}
}
/**
* {@inheritdoc}
*/
public function postSave(EntityStorageInterface $storage, $update = TRUE) {
parent::postSave($storage, $update);
$this->routeBuilder()->setRebuildNeeded();
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageInterface $storage, array $entities) {
parent::postDelete($storage, $entities);
$search_page_repository = \Drupal::service('search.search_page_repository');
if (!$search_page_repository->isSearchActive()) {
$search_page_repository->clearDefaultSearchPage();
}
}
/**
* Sorts search page entities by status, weight and label.
*
* Callback for uasort().
*/
public static function sort(ConfigEntityInterface $a, ConfigEntityInterface $b) {
/** @var \Drupal\search\SearchPageInterface $a */
/** @var \Drupal\search\SearchPageInterface $b */
$a_status = (int) $a->status();
$b_status = (int) $b->status();
if ($a_status != $b_status) {
return $b_status <=> $a_status;
}
return parent::sort($a, $b);
}
/**
* Wraps the route builder.
*
* @return \Drupal\Core\Routing\RouteBuilderInterface
* An object for state storage.
*/
protected function routeBuilder() {
return \Drupal::service('router.builder');
}
/**
* Wraps the config factory.
*
* @return \Drupal\Core\Config\ConfigFactoryInterface
* A config factory object.
*/
protected function configFactory() {
return \Drupal::service('config.factory');
}
/**
* Wraps the search page repository.
*
* @return \Drupal\search\SearchPageRepositoryInterface
* A search page repository object.
*/
protected function searchPageRepository() {
return \Drupal::service('search.search_page_repository');
}
/**
* Wraps the search plugin manager.
*
* @return \Drupal\Component\Plugin\PluginManagerInterface
* A search plugin manager object.
*/
protected function searchPluginManager() {
return \Drupal::service('plugin.manager.search');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace Drupal\search\Exception;
/**
* Exception thrown for search index errors.
*/
class SearchIndexException extends \RuntimeException {}

View File

@ -0,0 +1,73 @@
<?php
namespace Drupal\search\Form;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
/**
* Provides the search reindex confirmation form.
*
* @internal
*/
class ReindexConfirm extends ConfirmFormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'search_reindex_confirm';
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to re-index the site?');
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t("This will re-index content in the search indexes of all active search pages. Searching will continue to work, but new content won't be indexed until all existing content has been re-indexed. This action cannot be undone.");
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Re-index site');
}
/**
* {@inheritdoc}
*/
public function getCancelText() {
return $this->t('Cancel');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('entity.search_page.collection');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
if ($form['confirm']) {
// Ask each active search page to mark itself for re-index.
$search_page_repository = \Drupal::service('search.search_page_repository');
foreach ($search_page_repository->getIndexableSearchPages() as $entity) {
$entity->getPlugin()->markForReindex();
}
$this->messenger()->addStatus($this->t('All search indexes will be rebuilt.'));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace Drupal\search\Form;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Url;
use Drupal\search\SearchPageRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Builds the search form for the search block.
*
* @internal
*/
class SearchBlockForm extends FormBase implements WorkspaceSafeFormInterface {
/**
* The search page repository.
*
* @var \Drupal\search\SearchPageRepositoryInterface
*/
protected $searchPageRepository;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a new SearchBlockForm.
*
* @param \Drupal\search\SearchPageRepositoryInterface $search_page_repository
* The search page repository.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(SearchPageRepositoryInterface $search_page_repository, ConfigFactoryInterface $config_factory, RendererInterface $renderer) {
$this->searchPageRepository = $search_page_repository;
$this->configFactory = $config_factory;
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('search.search_page_repository'),
$container->get('config.factory'),
$container->get('renderer')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'search_block_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, $entity_id = NULL) {
// Set up the form to submit using GET to the correct search page.
if (!$entity_id) {
$entity_id = $this->searchPageRepository->getDefaultSearchPage();
// SearchPageRepository::getDefaultSearchPage() depends on
// search.settings. The dependency needs to be added before the
// conditional return, otherwise the block would get cached without the
// necessary cacheability metadata in case there is no default search page
// and would not be invalidated if that changes.
$this->renderer->addCacheableDependency($form, $this->configFactory->get('search.settings'));
}
if (!$entity_id) {
$form['message'] = [
'#markup' => $this->t('Search is currently disabled'),
];
return $form;
}
$route = 'search.view_' . $entity_id;
$form['#action'] = Url::fromRoute($route)->toString();
$form['#method'] = 'get';
$form['keys'] = [
'#type' => 'search',
'#title' => $this->t('Search'),
'#title_display' => 'invisible',
'#size' => 15,
'#default_value' => '',
'#attributes' => ['title' => $this->t('Enter the terms you wish to search for.')],
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Search'),
// Prevent op from showing up in the query string.
'#name' => '',
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// This form submits to the search page, so processing happens there.
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace Drupal\search\Form;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a form for adding a search page.
*
* @internal
*/
class SearchPageAddForm extends SearchPageFormBase {
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, $search_plugin_id = NULL) {
$this->entity->setPlugin($search_plugin_id);
$definition = $this->entity->getPlugin()->getPluginDefinition();
$this->entity->set('label', $definition['title']);
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
$actions['submit']['#value'] = $this->t('Save');
return $actions;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
// If there is no default search page, make the added search the default.
// TRICKY: ::getDefaultSearchPage() will return the first active search page
// as the default if no explicit default is configured in `search.settings`.
// That's why this must be checked *before* saving the form.
$make_default = !$this->searchPageRepository->getDefaultSearchPage();
parent::save($form, $form_state);
if ($make_default) {
$this->searchPageRepository->setDefaultSearchPage($this->entity);
}
$this->messenger()->addStatus($this->t('The %label search page has been added.', ['%label' => $this->entity->label()]));
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Drupal\search\Form;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a form for editing a search page.
*
* @internal
*/
class SearchPageEditForm extends SearchPageFormBase {
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
$actions['submit']['#value'] = $this->t('Save search page');
return $actions;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
parent::save($form, $form_state);
$this->messenger()->addStatus($this->t('The %label search page has been updated.', ['%label' => $this->entity->label()]));
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace Drupal\search\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\WorkspaceSafeFormInterface;
use Drupal\Core\Url;
use Drupal\search\SearchPageInterface;
/**
* Provides a search form for site wide search.
*
* Search plugins can define method searchFormAlter() to alter the form. If they
* have additional or substitute fields, they will need to override the form
* submit, making sure to redirect with a GET parameter of 'keys' included, to
* trigger the search being processed by the controller, and adding in any
* additional query parameters they need to execute search.
*
* @internal
*/
class SearchPageForm extends FormBase implements WorkspaceSafeFormInterface {
/**
* The search page entity.
*
* @var \Drupal\search\SearchPageInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'search_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?SearchPageInterface $search_page = NULL) {
$this->entity = $search_page;
$plugin = $this->entity->getPlugin();
$form_state->set('search_page_id', $this->entity->id());
$form['basic'] = [
'#type' => 'container',
'#attributes' => [
'class' => ['container-inline'],
],
];
$form['basic']['keys'] = [
'#type' => 'search',
'#title' => $this->t('Enter your keywords'),
'#default_value' => $plugin->getKeywords(),
'#size' => 30,
'#maxlength' => 255,
];
// processed_keys is used to coordinate keyword passing between other forms
// that hook into the basic search form.
$form['basic']['processed_keys'] = [
'#type' => 'value',
'#value' => '',
];
$form['basic']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Search'),
];
$form['help_link'] = [
'#type' => 'link',
'#url' => new Url('search.help_' . $this->entity->id()),
'#title' => $this->t('About searching'),
'#options' => ['attributes' => ['class' => 'search-help-link']],
];
// Allow the plugin to add to or alter the search form.
$plugin->searchFormAlter($form, $form_state);
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Redirect to the search page with keywords in the GET parameters.
// Plugins with additional search parameters will need to provide their
// own form submit handler to replace this, so they can put their values
// into the GET as well. If so, make sure to put 'keys' into the GET
// parameters so that the search results generation is triggered.
$query = $this->entity->getPlugin()->buildSearchUrlQuery($form_state);
$route = 'search.view_' . $form_state->get('search_page_id');
$form_state->setRedirect(
$route,
[],
['query' => $query]
);
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace Drupal\search\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\search\SearchPageRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a base form for search pages.
*/
abstract class SearchPageFormBase extends EntityForm {
/**
* The entity being used by this form.
*
* @var \Drupal\search\SearchPageInterface
*/
protected $entity;
/**
* The search plugin being configured.
*
* @var \Drupal\search\Plugin\SearchInterface
*/
protected $plugin;
/**
* The search page repository.
*
* @var \Drupal\search\SearchPageRepositoryInterface
*/
protected $searchPageRepository;
/**
* Constructs a new search form.
*
* @param \Drupal\search\SearchPageRepositoryInterface $search_page_repository
* The search page repository.
*/
public function __construct(SearchPageRepositoryInterface $search_page_repository) {
$this->searchPageRepository = $search_page_repository;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('search.search_page_repository')
);
}
/**
* {@inheritdoc}
*/
public function getBaseFormId() {
return 'search_entity_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$this->plugin = $this->entity->getPlugin();
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#description' => $this->t('The label for this search page.'),
'#default_value' => $this->entity->label(),
'#maxlength' => '255',
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $this->entity->id(),
'#disabled' => !$this->entity->isNew(),
'#maxlength' => 64,
'#machine_name' => [
'exists' => [$this, 'exists'],
],
];
$form['path'] = [
'#type' => 'textfield',
'#title' => $this->t('Path'),
'#field_prefix' => 'search/',
'#default_value' => $this->entity->getPath(),
'#maxlength' => '255',
'#required' => TRUE,
];
$form['plugin'] = [
'#type' => 'value',
'#value' => $this->entity->get('plugin'),
];
if ($this->plugin instanceof PluginFormInterface) {
$form += $this->plugin->buildConfigurationForm($form, $form_state);
}
return parent::form($form, $form_state);
}
/**
* Determines if the search page entity already exists.
*
* @param string $id
* The search configuration ID.
*
* @return bool
* TRUE if the search configuration exists, FALSE otherwise.
*/
public function exists($id) {
$entity = $this->entityTypeManager->getStorage('search_page')->getQuery()
->condition('id', $id)
->execute();
return (bool) $entity;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
// Ensure each path is unique.
$path = $this->entityTypeManager->getStorage('search_page')->getQuery()
->condition('path', $form_state->getValue('path'))
->condition('id', $form_state->getValue('id'), '<>')
->execute();
if ($path) {
$form_state->setErrorByName('path', $this->t('The search page path must be unique.'));
}
if ($this->plugin instanceof PluginFormInterface) {
$this->plugin->validateConfigurationForm($form, $form_state);
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
parent::submitForm($form, $form_state);
if ($this->plugin instanceof PluginFormInterface) {
$this->plugin->submitConfigurationForm($form, $form_state);
}
return $this->entity;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
$form_state->setRedirectUrl($this->entity->toUrl('collection'));
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace Drupal\search\Hook;
use Drupal\block\BlockInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for search.
*/
class SearchHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.search':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The Search module provides the ability to set up search pages based on plugins provided by other modules. In Drupal core, there are two page-type plugins: the Content page type provides keyword searching for content managed by the Node module, and the Users page type provides keyword searching for registered users. Contributed modules may provide other page-type plugins. For more information, see the <a href=":search-module">online documentation for the Search module</a>.', [':search-module' => 'https://www.drupal.org/documentation/modules/search']) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Configuring search pages') . '</dt>';
$output .= '<dd>' . $this->t('To configure search pages, visit the <a href=":search-settings">Search pages page</a>. In the Search pages section, you can add a new search page, edit the configuration of existing search pages, enable and disable search pages, and choose the default search page. Each enabled search page has a URL path starting with <em>search</em>, and each will appear as a tab or local task link on the <a href=":search-url">search page</a>; you can configure the text that is shown in the tab. In addition, some search page plugins have additional settings that you can configure for each search page.', [
':search-settings' => Url::fromRoute('entity.search_page.collection')->toString(),
':search-url' => Url::fromRoute('search.view')->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Managing the search index') . '</dt>';
$output .= '<dd>' . $this->t('Some search page plugins, such as the core Content search page, index searchable text using the Drupal core search index, and will not work unless content is indexed. Indexing is done during <em>cron</em> runs, so it requires a <a href=":cron">cron maintenance task</a> to be set up. There are also several settings affecting indexing that can be configured on the <a href=":search-settings">Search pages page</a>: the number of items to index per cron run, the minimum word length to index, and how to handle Chinese, Japanese, and Korean characters.', [
':cron' => Url::fromRoute('system.cron_settings')->toString(),
':search-settings' => Url::fromRoute('entity.search_page.collection')->toString(),
]) . '</dd>';
$output .= '<dd>' . $this->t('Modules providing search page plugins generally ensure that content-related actions on your site (creating, editing, or deleting content and comments) automatically cause affected content items to be marked for indexing or reindexing at the next cron run. When content is marked for reindexing, the previous content remains in the index until cron runs, at which time it is replaced by the new content. However, there are some actions related to the structure of your site that do not cause affected content to be marked for reindexing. Examples of structure-related actions that affect content include deleting or editing taxonomy terms, installing or uninstalling modules that add text to content (such as Taxonomy, Comment, and field-providing modules), and modifying the fields or display parameters of your content types. If you take one of these actions and you want to ensure that the search index is updated to reflect your changed site structure, you can mark all content for reindexing by clicking the "Re-index site" button on the <a href=":search-settings">Search pages page</a>. If you have a lot of content on your site, it may take several cron runs for the content to be reindexed.', [
':search-settings' => Url::fromRoute('entity.search_page.collection')->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Displaying the Search block') . '</dt>';
$output .= '<dd>' . $this->t('The Search module includes a block, which can be enabled and configured on the <a href=":blocks">Block layout page</a>, if you have the Block module installed; the default block title is Search, and it is the Search form block in the Forms category, if you wish to add another instance. The block is available to users with the <a href=":search_permission">Use search</a> permission, and it performs a search using the configured default search page.', [
':blocks' => \Drupal::moduleHandler()->moduleExists('block') ? Url::fromRoute('block.admin_display')->toString() : '#',
':search_permission' => Url::fromRoute('user.admin_permissions.module', [
'modules' => 'search',
])->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Searching your site') . '</dt>';
$output .= '<dd>' . $this->t('Users with <a href=":search_permission">Use search</a> permission can use the Search block and <a href=":search">Search page</a>. Users with the <a href=":node_permission">View published content</a> permission can use configured search pages of type <em>Content</em> to search for content containing exact keywords; in addition, users with <a href=":search_permission">Use advanced search</a> permission can use more complex search filtering. Users with the <a href=":user_permission">View user information</a> permission can use configured search pages of type <em>Users</em> to search for active users containing the keyword anywhere in the username, and users with the <a href=":user_permission">Administer users</a> permission can search for active and blocked users, by email address or username keyword.', [
':search' => Url::fromRoute('search.view')->toString(),
':search_permission' => Url::fromRoute('user.admin_permissions.module', [
'modules' => 'search',
])->toString(),
':node_permission' => Url::fromRoute('user.admin_permissions.module', [
'modules' => 'node',
])->toString(),
':user_permission' => Url::fromRoute('user.admin_permissions.module', [
'modules' => 'user',
])->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Extending the Search module') . '</dt>';
$output .= '<dd>' . $this->t('By default, the Search module only supports exact keyword matching in content searches. You can modify this behavior by installing a language-specific stemming module for your language (such as <a href=":porterstemmer_url">Porter Stemmer</a> for American English), which allows words such as walk, walking, and walked to be matched in the Search module. Another approach is to use a third-party search technology with stemming or partial word matching features built in, such as <a href=":solr_url">Apache Solr</a> or <a href=":sphinx_url">Sphinx</a>. There are also contributed modules that provide additional search pages. These and other <a href=":contrib-search">search-related contributed modules</a> can be downloaded by visiting Drupal.org.', [
':contrib-search' => 'https://www.drupal.org/project/project_module?f[2]=im_vid_3%3A105',
':porterstemmer_url' => 'https://www.drupal.org/project/porterstemmer',
':solr_url' => 'https://www.drupal.org/project/apachesolr',
':sphinx_url' => 'https://www.drupal.org/project/sphinx',
]) . '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_theme().
*/
#[Hook('theme')]
public function theme() : array {
return [
'search_result' => [
'variables' => [
'result' => NULL,
'plugin_id' => NULL,
],
'file' => 'search.pages.inc',
],
];
}
/**
* Implements hook_cron().
*
* Fires updateIndex() in the plugins for all indexable active search pages,
* and cleans up dirty words.
*/
#[Hook('cron')]
public function cron(): void {
/** @var \Drupal\search\SearchPageRepositoryInterface $search_page_repository */
$search_page_repository = \Drupal::service('search.search_page_repository');
foreach ($search_page_repository->getIndexableSearchPages() as $entity) {
$entity->getPlugin()->updateIndex();
}
}
/**
* Implements hook_form_FORM_ID_alter() for the search_block_form form.
*
* Since the exposed form is a GET form, we don't want it to send the form
* tokens. However, you cannot make this happen in the form builder function
* itself, because the tokens are added to the form after the builder function
* is called. So, we have to do it in a form_alter.
*
* @see \Drupal\search\Form\SearchBlockForm
*/
#[Hook('form_search_block_form_alter')]
public function formSearchBlockFormAlter(&$form, FormStateInterface $form_state) : void {
$form['form_build_id']['#access'] = FALSE;
$form['form_token']['#access'] = FALSE;
$form['form_id']['#access'] = FALSE;
}
/**
* Implements hook_ENTITY_TYPE_presave() for block entities.
*/
#[Hook('block_presave')]
public function blockPresave(BlockInterface $block): void {
// @see \Drupal\search\Plugin\Block\SearchBlock
if ($block->getPluginId() === 'search_form_block') {
$settings = $block->get('settings');
if ($settings['page_id'] === '') {
@trigger_error('Saving a search block with an empty page ID is deprecated in drupal:11.1.0 and removed in drupal:12.0.0. To use the default search page, use NULL. See https://www.drupal.org/node/3463132', E_USER_DEPRECATED);
$settings['page_id'] = NULL;
$block->set('settings', $settings);
}
}
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\search\Hook;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\search\SearchPageRepositoryInterface;
/**
* Requirements for the Search module.
*/
class SearchRequirements {
use StringTranslationTrait;
public function __construct(
protected readonly SearchPageRepositoryInterface $searchPageRepository,
) {}
/**
* Implements hook_runtime_requirements().
*
* For the Status Report, return information about search index status.
*/
#[Hook('runtime_requirements')]
public function runtime(): array {
$requirements = [];
$remaining = 0;
$total = 0;
foreach ($this->searchPageRepository->getIndexableSearchPages() as $entity) {
$status = $entity->getPlugin()->indexStatus();
$remaining += $status['remaining'];
$total += $status['total'];
}
$done = $total - $remaining;
// Use floor() to calculate the percentage, so if it is not quite 100%, it
// will show as 99%, to indicate "almost done".
$percent = ($total > 0 ? floor(100 * $done / $total) : 100);
$requirements['search_status'] = [
'title' => $this->t('Search index progress'),
'value' => $this->t('@percent% (@remaining remaining)', ['@percent' => $percent, '@remaining' => $remaining]),
'severity' => RequirementSeverity::Info,
];
return $requirements;
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace Drupal\search\Plugin\Block;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\search\Form\SearchBlockForm;
use Drupal\search\SearchPageRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a 'Search form' block.
*/
#[Block(
id: "search_form_block",
admin_label: new TranslatableMarkup("Search form"),
category: new TranslatableMarkup("Forms"),
)]
class SearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* The form builder.
*
* @var \Drupal\Core\Form\FormBuilderInterface
*/
protected $formBuilder;
/**
* The search page repository.
*
* @var \Drupal\search\SearchPageRepositoryInterface
*/
protected $searchPageRepository;
/**
* Constructs a new SearchLocalTask.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
* @param \Drupal\search\SearchPageRepositoryInterface $search_page_repository
* The search page repository.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FormBuilderInterface $form_builder, SearchPageRepositoryInterface $search_page_repository) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->formBuilder = $form_builder;
$this->searchPageRepository = $search_page_repository;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition,
$container->get('form_builder'),
$container->get('search.search_page_repository')
);
}
/**
* {@inheritdoc}
*/
protected function blockAccess(AccountInterface $account) {
return AccessResult::allowedIfHasPermission($account, 'search content');
}
/**
* {@inheritdoc}
*/
public function build() {
$page = $this->configuration['page_id'] ?? NULL;
return $this->formBuilder->getForm(SearchBlockForm::class, $page);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'page_id' => NULL,
];
}
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state) {
// The configuration for this block is which search page to connect the
// form to. Options are all configured/active search pages.
$options = [];
$active_search_pages = $this->searchPageRepository->getActiveSearchPages();
foreach ($this->searchPageRepository->sortSearchPages($active_search_pages) as $entity_id => $entity) {
$options[$entity_id] = $entity->label();
}
$form['page_id'] = [
'#type' => 'select',
'#title' => $this->t('Search page'),
'#description' => $this->t('The search page that the form submits to, or Default for the default search page.'),
'#default_value' => $this->configuration['page_id'],
'#options' => $options,
'#empty_option' => $this->t('Default'),
'#empty_value' => '',
];
return $form;
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state) {
// Handle the #empty_value: using the default requires specifying `null` in
// the config.
// @see search.schema.yml
// @see \Drupal\search\Form\SearchBlockForm::buildForm()
$this->configuration['page_id'] = $form_state->getValue('page_id') ?: NULL;
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Drupal\search\Plugin;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a base implementation for a configurable Search plugin.
*/
abstract class ConfigurableSearchPluginBase extends SearchPluginBase implements ConfigurableSearchPluginInterface {
/**
* The unique ID for the search page using this plugin.
*
* @var string
*/
protected $searchPageId;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->setConfiguration($configuration);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [];
}
/**
* {@inheritdoc}
*/
public function getConfiguration() {
return $this->configuration;
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
$this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $configuration);
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
return [];
}
/**
* {@inheritdoc}
*/
public function setSearchPageId($search_page_id) {
$this->searchPageId = $search_page_id;
return $this;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Drupal\search\Plugin;
use Drupal\Component\Plugin\ConfigurableInterface;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Core\Plugin\PluginFormInterface;
/**
* Provides an interface for a configurable Search plugin.
*/
interface ConfigurableSearchPluginInterface extends ConfigurableInterface, DependentPluginInterface, PluginFormInterface, SearchInterface {
/**
* Sets the ID for the search page using this plugin.
*
* @param string $search_page_id
* The search page ID.
*
* @return static
*/
public function setSearchPageId($search_page_id);
}

View File

@ -0,0 +1,61 @@
<?php
namespace Drupal\search\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\search\SearchPageRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides local tasks for each search page.
*/
class SearchLocalTask extends DeriverBase implements ContainerDeriverInterface {
/**
* The search page repository.
*
* @var \Drupal\search\SearchPageRepositoryInterface
*/
protected $searchPageRepository;
/**
* Constructs a new SearchLocalTask.
*
* @param \Drupal\search\SearchPageRepositoryInterface $search_page_repository
* The search page repository.
*/
public function __construct(SearchPageRepositoryInterface $search_page_repository) {
$this->searchPageRepository = $search_page_repository;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get('search.search_page_repository')
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
$this->derivatives = [];
if ($this->searchPageRepository->getDefaultSearchPage()) {
$active_search_pages = $this->searchPageRepository->getActiveSearchPages();
foreach ($this->searchPageRepository->sortSearchPages($active_search_pages) as $entity_id => $entity) {
$this->derivatives[$entity_id] = [
'title' => $entity->label(),
'route_name' => 'search.view_' . $entity_id,
'base_route' => 'search.view',
'weight' => $entity->getWeight(),
];
}
}
return $this->derivatives;
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace Drupal\search\Plugin;
/**
* Defines an optional interface for SearchPlugin objects using an index.
*
* Plugins implementing this interface will have these methods invoked during
* search_cron() and via the search module administration form. Plugins not
* implementing this interface are assumed to be using their own methods for
* searching, not involving separate index tables.
*
* The user interface for managing search pages displays the indexing status for
* search pages implementing this interface. It also allows users to configure
* default settings for indexing, and refers to the "default search index". If
* your search page plugin uses its own indexing mechanism instead of the
* default search index, or overrides the default indexing settings, you should
* make this clear on the settings page or other documentation for your plugin.
*
* Multiple search pages can be created for each search plugin, so you will need
* to choose whether these search pages should share an index (in which case
* they must not use any search page-specific configuration while indexing) or
* they will have separate indexes (which will use additional server resources).
*/
interface SearchIndexingInterface {
/**
* Updates the search index for this plugin.
*
* This method is called every cron run if the plugin has been set as
* an active search module on the Search settings page
* (admin/config/search/pages). It allows your module to add items to the
* built-in search index by calling the index() method on the search.index
* service class, or to add them to your module's own indexing mechanism.
*
* When implementing this method, your module should index content items that
* were modified or added since the last run. There is a time limit for cron,
* so it is advisable to limit how many items you index per run using
* config('search.settings')->get('index.cron_limit') or with your own
* setting. And since the cron run could time out and abort in the middle of
* your run, you should update any needed internal bookkeeping on when items
* have last been indexed as you go rather than waiting to the end of
* indexing.
*/
public function updateIndex();
/**
* Clears the search index for this plugin.
*
* When a request is made to clear all items from the search index related to
* this plugin, this method will be called. If this plugin uses the default
* search index, this method can call clear($type) method on the search.index
* service class to remove indexed items from the search database.
*
* @see \Drupal\search\SearchIndexInterface::clear()
*/
public function indexClear();
/**
* Marks the search index for reindexing for this plugin.
*
* When a request is made to mark all items from the search index related to
* this plugin for reindexing, this method will be called. If this plugin uses
* the default search index, this method can call markForReindex($type) method
* on the search.index service class to mark the items in the search database
* for reindexing.
*
* @see \Drupal\search\SearchIndexInterface::markForReindex()
*/
public function markForReindex();
/**
* Reports the status of indexing.
*
* The core search module only invokes this method on active module plugins.
* Implementing modules do not need to check whether they are active when
* calculating their return values.
*
* @return array
* An associative array with the key-value pairs:
* - remaining: The number of items left to index.
* - total: The total number of items to index.
*/
public function indexStatus();
}

View File

@ -0,0 +1,159 @@
<?php
namespace Drupal\search\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Defines a common interface for all SearchPlugin objects.
*/
interface SearchInterface extends PluginInspectionInterface {
/**
* Sets the keywords, parameters, and attributes to be used by execute().
*
* @param string $keywords
* The keywords to use in a search.
* @param array $parameters
* Array of parameters as an associative array. This is expected to
* be the query string from the current request.
* @param array $attributes
* Array of attributes, usually from the current request object.
*
* @return $this
* A search plugin object for chaining.
*/
public function setSearch($keywords, array $parameters, array $attributes);
/**
* Returns the currently set keywords of the plugin instance.
*
* @return string
* The keywords.
*/
public function getKeywords();
/**
* Returns the current parameters set using setSearch().
*
* @return array
* The parameters.
*/
public function getParameters();
/**
* Returns the currently set attributes (from the request).
*
* @return array
* The attributes.
*/
public function getAttributes();
/**
* Verifies if the values set via setSearch() are valid and sufficient.
*
* @return bool
* TRUE if the search settings are valid and sufficient to execute a search,
* and FALSE if not.
*/
public function isSearchExecutable();
/**
* Returns the search index type this plugin uses.
*
* @return string|null
* The type used by this search plugin in the search index, or NULL if this
* plugin does not use the search index.
*
* @see \Drupal\search\SearchIndexInterface::index()
* @see \Drupal\search\SearchIndexInterface::clear()
*/
public function getType();
/**
* Executes the search.
*
* @return array
* A structured list of search results.
*/
public function execute();
/**
* Executes the search and builds render arrays for the result items.
*
* @return array
* An array of render arrays of search result items (generally each item
* has '#theme' set to 'search_result'), or an empty array if there are no
* results.
*/
public function buildResults();
/**
* Provides a suggested title for a page of search results.
*
* @return string
* The translated suggested page title.
*/
public function suggestedTitle();
/**
* Returns the searching help.
*
* @return array
* Render array for the searching help.
*/
public function getHelp();
/**
* Alters the search form when being built for a given plugin.
*
* The core search module only invokes this method on active module plugins
* when building a form for them in
* \Drupal\search\Form\SearchPageForm::buildForm(). A plugin implementing this
* will also need to implement the buildSearchUrlQuery() method.
*
* @param array $form
* Nested array of form elements that comprise the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form. The arguments that
* \Drupal::formBuilder()->getForm() was originally called with are
* available in the array $form_state->getBuildInfo()['args'].
*
* @see SearchInterface::buildSearchUrlQuery()
*/
public function searchFormAlter(array &$form, FormStateInterface $form_state);
/**
* Builds the URL GET query parameters array for search.
*
* When the search form is submitted, a redirect is generated with the
* search input as GET query parameters. Plugins using the searchFormAlter()
* method to add form elements to the search form will need to override this
* method to gather the form input and add it to the GET query parameters.
*
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state, with submitted form information.
*
* @return array
* An array of GET query parameters containing all relevant form values
* to process the search. The 'keys' element must be present in order to
* trigger generation of search results, even if it is empty or unused by
* the search plugin.
*
* @see SearchInterface::searchFormAlter()
*/
public function buildSearchUrlQuery(FormStateInterface $form_state);
/**
* Returns whether or not search results should be displayed in admin theme.
*
* @return bool
* TRUE if search results should be displayed in the admin theme, and FALSE
* otherwise.
*
* @see \Drupal\search\Annotation\SearchPlugin::$use_admin_theme
*/
public function usesAdminTheme();
}

View File

@ -0,0 +1,172 @@
<?php
namespace Drupal\search\Plugin;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Component\Utility\Unicode;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a base class for plugins wishing to support search.
*/
abstract class SearchPluginBase extends PluginBase implements ContainerFactoryPluginInterface, SearchInterface, RefinableCacheableDependencyInterface {
use RefinableCacheableDependencyTrait;
/**
* The keywords to use in a search.
*
* @var string
*/
protected $keywords;
/**
* Array of parameters from the query string from the request.
*
* @var array
*/
protected $searchParameters;
/**
* Array of attributes - usually from the request object.
*
* @var array
*/
protected $searchAttributes;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public function setSearch($keywords, array $parameters, array $attributes) {
$this->keywords = (string) $keywords;
$this->searchParameters = $parameters;
$this->searchAttributes = $attributes;
return $this;
}
/**
* {@inheritdoc}
*/
public function getKeywords() {
return $this->keywords;
}
/**
* {@inheritdoc}
*/
public function getParameters() {
return $this->searchParameters;
}
/**
* {@inheritdoc}
*/
public function getAttributes() {
return $this->searchAttributes;
}
/**
* {@inheritdoc}
*/
public function isSearchExecutable() {
// Default implementation suitable for plugins that only use keywords.
return !empty($this->keywords);
}
/**
* {@inheritdoc}
*/
public function getType() {
return NULL;
}
/**
* {@inheritdoc}
*/
public function buildResults() {
$results = $this->execute();
$built = [];
foreach ($results as $result) {
$built[] = [
'#theme' => 'search_result',
'#result' => $result,
'#plugin_id' => $this->getPluginId(),
];
}
return $built;
}
/**
* {@inheritdoc}
*/
public function searchFormAlter(array &$form, FormStateInterface $form_state) {
// Empty default implementation.
}
/**
* {@inheritdoc}
*/
public function suggestedTitle() {
// If the user entered a search string, truncate it and append it to the
// title.
if (!empty($this->keywords)) {
return $this->t('Search for @keywords', ['@keywords' => Unicode::truncate($this->keywords, 60, TRUE, TRUE)]);
}
// Use the default 'Search' title.
return $this->t('Search');
}
/**
* {@inheritdoc}
*/
public function buildSearchUrlQuery(FormStateInterface $form_state) {
// Grab the keywords entered in the form and put them as 'keys' in the GET.
$keys = trim($form_state->getValue('keys'));
$query = ['keys' => $keys];
return $query;
}
/**
* {@inheritdoc}
*/
public function getHelp() {
// This default search help is appropriate for plugins like NodeSearch
// that use the SearchQuery class.
$help = [
'list' => [
'#theme' => 'item_list',
'#items' => [
$this->t('Search looks for exact, case-insensitive keywords; keywords shorter than a minimum length are ignored.'),
$this->t('Use upper-case OR to get more results. Example: cat OR dog (content contains either "cat" or "dog").'),
$this->t('You can use upper-case AND to require all words, but this is the same as the default behavior. Example: cat AND dog (same as cat dog, content must contain both "cat" and "dog").'),
$this->t('Use quotes to search for a phrase. Example: "the cat eats mice".'),
$this->t('You can precede keywords by - to exclude them; you must still have at least one "positive" keyword. Example: cat -dog (content must contain cat and cannot contain dog).'),
],
],
];
return $help;
}
/**
* {@inheritdoc}
*/
public function usesAdminTheme() {
return $this->pluginDefinition['use_admin_theme'];
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Drupal\search\Plugin;
use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection;
use Drupal\Component\Plugin\PluginManagerInterface;
/**
* Provides a container for lazily loading search plugins.
*/
class SearchPluginCollection extends DefaultSingleLazyPluginCollection {
/**
* The unique ID for the search page using this plugin collection.
*
* @var string
*/
protected $searchPageId;
/**
* Constructs a new SearchPluginCollection.
*
* @param \Drupal\Component\Plugin\PluginManagerInterface $manager
* The manager to be used for instantiating plugins.
* @param string $instance_id
* The ID of the plugin instance.
* @param array $configuration
* An array of configuration.
* @param string $search_page_id
* The unique ID of the search page using this plugin.
*/
public function __construct(PluginManagerInterface $manager, $instance_id, array $configuration, $search_page_id) {
parent::__construct($manager, $instance_id, $configuration);
$this->searchPageId = $search_page_id;
}
/**
* {@inheritdoc}
*
* @return \Drupal\search\Plugin\SearchInterface
* The search plugin instance associated with the given instance ID.
*/
public function &get($instance_id) {
return parent::get($instance_id);
}
/**
* {@inheritdoc}
*/
protected function initializePlugin($instance_id) {
parent::initializePlugin($instance_id);
$plugin_instance = $this->pluginInstances[$instance_id];
if ($plugin_instance instanceof ConfigurableSearchPluginInterface) {
$plugin_instance->setSearchPageId($this->searchPageId);
}
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace Drupal\search\Plugin\migrate\destination;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\migrate\Attribute\MigrateDestination;
use Drupal\migrate\MigrateException;
use Drupal\migrate\Plugin\MigrateIdMapInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Plugin\migrate\destination\EntityConfigBase;
use Drupal\migrate\Row;
use Drupal\search\Plugin\ConfigurableSearchPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Migrate destination for search page.
*/
#[MigrateDestination('entity:search_page')]
class EntitySearchPage extends EntityConfigBase {
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Constructs a new EntitySearchPage.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\migrate\plugin\MigrationInterface $migration
* The migration.
* @param \Drupal\Core\Entity\EntityStorageInterface $storage
* The storage for this entity type.
* @param array $bundles
* The list of bundles this entity type has.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The configuration factory.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, EntityStorageInterface $storage, array $bundles, LanguageManagerInterface $language_manager, ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $storage, $bundles, $language_manager, $config_factory);
$this->moduleHandler = $module_handler;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, ?MigrationInterface $migration = NULL) {
$entity_type_id = static::getEntityTypeId($plugin_id);
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
$container->get('entity_type.manager')->getStorage($entity_type_id),
array_keys($container->get('entity_type.bundle.info')->getBundleInfo($entity_type_id)),
$container->get('language_manager'),
$container->get('config.factory'),
$container->get('module_handler')
);
}
/**
* {@inheritdoc}
*/
public function import(Row $row, array $old_destination_id_values = []) {
// The search page settings may be for a module not enabled on the
// destination so make sure it is enabled for updating search page settings.
if ($this->moduleHandler->moduleExists($row->getDestinationProperty('module'))) {
return parent::import($row, $old_destination_id_values);
}
$msg = sprintf("Search module '%s' is not enabled on this site.", $row->getDestinationProperty('module'));
throw new MigrateException($msg, 0, NULL, MigrationInterface::MESSAGE_INFORMATIONAL, MigrateIdMapInterface::STATUS_IGNORED);
}
/**
* {@inheritdoc}
*/
protected function updateEntity(EntityInterface $entity, Row $row) {
parent::updateEntity($entity, $row);
$entity->setPlugin($row->getDestinationProperty('plugin'));
// The user_search plugin does not have a setConfiguration() method.
$plugin = $entity->getPlugin();
if ($plugin instanceof ConfigurableSearchPluginBase) {
$plugin->setConfiguration($row->getDestinationProperty('configuration'));
}
return $entity;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Drupal\search\Plugin\migrate\process;
use Drupal\migrate\Attribute\MigrateProcess;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
/**
* Generate configuration rankings.
*/
#[MigrateProcess('search_configuration_rankings')]
class SearchConfigurationRankings extends ProcessPluginBase {
/**
* {@inheritdoc}
*
* Generate the configuration rankings.
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$return = NULL;
foreach ($row->getSource() as $name => $rank) {
if (str_starts_with($name, 'node_rank_') && is_numeric($rank)) {
$return[substr($name, 10)] = $rank;
}
}
return $return;
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Drupal\search\Plugin\migrate\source\d6;
use Drupal\migrate_drupal\Plugin\migrate\source\Variable;
/**
* Drupal 6 node search rankings for core modules source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate_drupal\Plugin\migrate\source\Variable
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "d6_search_page",
* source_module = "search"
* )
*/
class SearchPage extends Variable {
/**
* {@inheritdoc}
*/
protected function values() {
// Add a module key to identify the source search provider, node. This value
// is used in the EntitySearchPage destination plugin.
return array_merge(['module' => 'node'], parent::values());
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'module' => $this->t('The module providing a search page.'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['module']['type'] = 'string';
return $ids;
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Drupal\search\Plugin\migrate\source\d7;
use Drupal\migrate\Row;
use Drupal\migrate_drupal\Plugin\migrate\source\Variable;
/**
* Drupal 7 search active core modules and rankings source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate_drupal\Plugin\migrate\source\Variable
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "d7_search_page",
* source_module = "search"
* )
*/
class SearchPage extends Variable {
/**
* {@inheritdoc}
*/
protected function initializeIterator() {
return new \ArrayIterator($this->values());
}
/**
* {@inheritdoc}
*/
protected function values() {
$search_active_modules = $this->variableGet('search_active_modules', '');
$values = [];
foreach (['node', 'user'] as $module) {
if (isset($search_active_modules[$module])) {
// Add a module key to identify the source search provider. This value
// is used in the EntitySearchPage destination plugin.
$tmp = [
'module' => $module,
'status' => $search_active_modules[$module],
];
// Add the node_rank_* variables (only relevant to the node module).
if ($module === 'node') {
$tmp = array_merge($tmp, parent::values());
}
$values[] = $tmp;
}
}
return $values;
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'module' => $this->t('The module providing a search page.'),
'status' => $this->t('Whether or not this module is enabled for search.'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['module']['type'] = 'string';
return $ids;
}
/**
* {@inheritdoc}
*/
protected function doCount() {
return $this->initializeIterator()->count();
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
$exists = $this->moduleExists($row->getSourceProperty('module'));
$row->setSourceProperty('module_exists', $exists);
return parent::prepareRow($row);
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace Drupal\search\Plugin\views\argument;
use Drupal\search\ViewsSearchQuery;
use Drupal\views\Attribute\ViewsArgument;
use Drupal\views\Plugin\views\argument\ArgumentPluginBase;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\ViewExecutable;
use Drupal\views\Views;
/**
* Argument handler for search keywords.
*
* @ingroup views_argument_handlers
*/
#[ViewsArgument(
id: 'search',
)]
class Search extends ArgumentPluginBase {
/**
* A search query to use for parsing search keywords.
*
* @var \Drupal\search\ViewsSearchQuery
*/
protected $searchQuery = NULL;
/**
* The search type name (value of {search_index}.type in the database).
*
* @var string
*/
protected $searchType;
/**
* The search score.
*/
// phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing
public string $search_score;
/**
* {@inheritdoc}
*/
public function init(ViewExecutable $view, DisplayPluginBase $display, ?array &$options = NULL) {
parent::init($view, $display, $options);
$this->searchType = $this->definition['search_type'];
}
/**
* Sets up and parses the search query.
*
* @param string $input
* The search keywords entered by the user.
*/
protected function queryParseSearchExpression($input) {
if (!isset($this->searchQuery)) {
$this->searchQuery = \Drupal::service('database.replica')->select('search_index', 'i')->extend(ViewsSearchQuery::class);
$this->searchQuery->searchExpression($input, $this->searchType);
$this->searchQuery->publicParseSearchExpression();
}
}
/**
* {@inheritdoc}
*/
public function query($group_by = FALSE) {
$required = FALSE;
$this->queryParseSearchExpression($this->argument);
if (!isset($this->searchQuery)) {
$required = TRUE;
}
else {
$words = $this->searchQuery->words();
if (empty($words)) {
$required = TRUE;
}
}
if ($required) {
if ($this->operator == 'required') {
$this->query->addWhere(0, 'FALSE');
}
}
else {
$search_index = $this->ensureMyTable();
$search_condition = $this->view->query->getConnection()->condition('AND');
// Create a new join to relate the 'search_total' table to our current
// 'search_index' table.
$definition = [
'table' => 'search_total',
'field' => 'word',
'left_table' => $search_index,
'left_field' => 'word',
];
$join = Views::pluginManager('join')->createInstance('standard', $definition);
$search_total = $this->query->addRelationship('search_total', $join, $search_index);
// Add the search score field to the query.
$this->search_score = $this->query->addField('', "$search_index.score * $search_total.count", 'score', ['function' => 'sum']);
// Add the conditions set up by the search query to the views query.
$search_condition->condition("$search_index.type", $this->searchType);
$search_dataset = $this->query->addTable('node_search_dataset');
$conditions = $this->searchQuery->conditions();
$condition_conditions =& $conditions->conditions();
foreach ($condition_conditions as $key => &$condition) {
// Make sure we just look at real conditions.
if (is_numeric($key)) {
// Replace the conditions with the table alias of views.
$this->searchQuery->conditionReplaceString('d.', "$search_dataset.", $condition);
}
}
$search_conditions =& $search_condition->conditions();
$search_conditions = array_merge($search_conditions, $condition_conditions);
// Add the keyword conditions, as is done in
// SearchQuery::prepareAndNormalize(), but simplified because we are
// only concerned with relevance ranking so we do not need to normalize.
$or = $this->view->query->getConnection()->condition('OR');
foreach ($words as $word) {
$or->condition("$search_index.word", $word);
}
$search_condition->condition($or);
// Add the GROUP BY and HAVING expressions to the query.
$this->query->addWhere(0, $search_condition);
$this->query->addGroupBy("$search_index.sid");
$matches = $this->searchQuery->matches();
$placeholder = $this->placeholder();
$this->query->addHavingExpression(0, "COUNT(*) >= $placeholder", [$placeholder => $matches]);
}
// Set to NULL to prevent PDO exception when views object is cached
// and to clear out memory.
$this->searchQuery = NULL;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace Drupal\search\Plugin\views\field;
use Drupal\views\Attribute\ViewsField;
use Drupal\views\Plugin\views\field\NumericField;
use Drupal\views\ResultRow;
/**
* Field handler for search score.
*
* @ingroup views_field_handlers
*/
#[ViewsField("search_score")]
class Score extends NumericField {
/**
* {@inheritdoc}
*/
public function query() {
// Check to see if the search filter added 'score' to the table.
// Our filter stores it as $handler->search_score -- and we also
// need to check its relationship to make sure that we're using the same
// one or obviously this won't work.
foreach ($this->view->filter as $handler) {
if (isset($handler->search_score) && ($handler->relationship == $this->relationship)) {
$this->field_alias = $handler->search_score;
$this->tableAlias = $handler->tableAlias;
return;
}
}
// Hide this field if no search filter is in place.
$this->options['exclude'] = TRUE;
}
/**
* {@inheritdoc}
*/
public function render(ResultRow $values) {
// Only render if we exist.
if (isset($this->tableAlias)) {
return parent::render($values);
}
}
}

View File

@ -0,0 +1,214 @@
<?php
namespace Drupal\search\Plugin\views\filter;
use Drupal\Core\Form\FormStateInterface;
use Drupal\search\ViewsSearchQuery;
use Drupal\views\Attribute\ViewsFilter;
use Drupal\views\Plugin\views\filter\FilterPluginBase;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\ViewExecutable;
use Drupal\views\Views;
/**
* Filter handler for search keywords.
*
* @ingroup views_filter_handlers
*/
#[ViewsFilter("search_keywords")]
class Search extends FilterPluginBase {
/**
* This filter is always considered multiple-valued.
*
* @var bool
*/
protected $alwaysMultiple = TRUE;
/**
* A search query to use for parsing search keywords.
*
* @var \Drupal\search\ViewsSearchQuery
*/
protected $searchQuery = NULL;
/**
* TRUE if the search query has been parsed.
*
* @var bool
*/
protected $parsed = FALSE;
/**
* The search type name (value of {search_index}.type in the database).
*
* @var string
*/
protected $searchType;
/**
* The search score.
*/
// phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing
public string $search_score;
/**
* {@inheritdoc}
*/
public function init(ViewExecutable $view, DisplayPluginBase $display, ?array &$options = NULL) {
parent::init($view, $display, $options);
$this->searchType = $this->definition['search_type'];
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['operator']['default'] = 'optional';
return $options;
}
/**
* {@inheritdoc}
*/
protected function operatorForm(&$form, FormStateInterface $form_state) {
$form['operator'] = [
'#type' => 'radios',
'#title' => $this->t('On empty input'),
'#default_value' => $this->operator,
'#options' => [
'optional' => $this->t('Show All'),
'required' => $this->t('Show None'),
],
];
}
/**
* {@inheritdoc}
*/
protected function valueForm(&$form, FormStateInterface $form_state) {
$form['value'] = [
'#type' => 'textfield',
'#size' => 15,
'#default_value' => $this->value,
'#attributes' => ['title' => $this->t('Search keywords')],
'#title' => !$form_state->get('exposed') ? $this->t('Keywords') : '',
];
}
/**
* {@inheritdoc}
*/
public function validateExposed(&$form, FormStateInterface $form_state) {
if (!isset($this->options['expose']['identifier'])) {
return;
}
$key = $this->options['expose']['identifier'];
if (!$form_state->isValueEmpty($key)) {
$this->queryParseSearchExpression($form_state->getValue($key));
if (count($this->searchQuery->words()) == 0) {
$form_state->setErrorByName($key, $this->formatPlural(\Drupal::config('search.settings')->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.'));
}
}
}
/**
* Sets up and parses the search query.
*
* @param string $input
* The search keywords entered by the user.
*/
protected function queryParseSearchExpression($input) {
if (!isset($this->searchQuery)) {
$this->parsed = TRUE;
$this->searchQuery = \Drupal::service('database.replica')->select('search_index', 'i')->extend(ViewsSearchQuery::class);
$this->searchQuery->searchExpression($input, $this->searchType);
$this->searchQuery->publicParseSearchExpression();
}
}
/**
* {@inheritdoc}
*/
public function query() {
// Since attachment views don't validate the exposed input, parse the search
// expression if required.
if (!$this->parsed) {
$this->queryParseSearchExpression($this->value);
}
$required = FALSE;
if (!isset($this->searchQuery)) {
$required = TRUE;
}
else {
$words = $this->searchQuery->words();
if (empty($words)) {
$required = TRUE;
}
}
if ($required) {
if ($this->operator == 'required') {
$this->query->addWhere($this->options['group'], 'FALSE');
}
}
else {
$search_index = $this->ensureMyTable();
$search_condition = $this->view->query->getConnection()->condition('AND');
// Create a new join to relate the 'search_total' table to our current
// 'search_index' table.
$definition = [
'table' => 'search_total',
'field' => 'word',
'left_table' => $search_index,
'left_field' => 'word',
];
$join = Views::pluginManager('join')->createInstance('standard', $definition);
$search_total = $this->query->addRelationship('search_total', $join, $search_index);
// Add the search score field to the query.
$this->search_score = $this->query->addField('', "$search_index.score * $search_total.count", 'score', ['function' => 'sum']);
// Add the conditions set up by the search query to the views query.
$search_condition->condition("$search_index.type", $this->searchType);
$search_dataset = $this->query->addTable('node_search_dataset');
$conditions = $this->searchQuery->conditions();
$condition_conditions =& $conditions->conditions();
foreach ($condition_conditions as $key => &$condition) {
// Make sure we just look at real conditions.
if (is_numeric($key)) {
// Replace the conditions with the table alias of views.
$this->searchQuery->conditionReplaceString('d.', "$search_dataset.", $condition);
}
}
$search_conditions =& $search_condition->conditions();
$search_conditions = array_merge($search_conditions, $condition_conditions);
// Add the keyword conditions, as is done in
// SearchQuery::prepareAndNormalize(), but simplified because we are
// only concerned with relevance ranking so we do not need to normalize.
$or = $this->view->query->getConnection()->condition('OR');
foreach ($words as $word) {
$or->condition("$search_index.word", $word);
}
$search_condition->condition($or);
$this->query->addWhere($this->options['group'], $search_condition);
// Add the GROUP BY and HAVING expressions to the query.
$this->query->addGroupBy("$search_index.sid");
$matches = $this->searchQuery->matches();
$placeholder = $this->placeholder();
$this->query->addHavingExpression($this->options['group'], "COUNT(*) >= $placeholder", [$placeholder => $matches]);
}
// Set to NULL to prevent PDO exception when views object is cached.
$this->searchQuery = NULL;
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Drupal\search\Plugin\views\row;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\views\Attribute\ViewsRow;
use Drupal\views\Plugin\views\row\RowPluginBase;
/**
* Row handler plugin for displaying search results.
*/
#[ViewsRow(
id: "search_view",
title: new TranslatableMarkup("Search results"),
help: new TranslatableMarkup("Provides a row plugin to display search results.")
)]
class SearchRow extends RowPluginBase {
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['score'] = ['default' => TRUE];
return $options;
}
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
$form['score'] = [
'#type' => 'checkbox',
'#title' => $this->t('Display score'),
'#default_value' => $this->options['score'],
];
}
/**
* {@inheritdoc}
*/
public function render($row) {
return [
'#theme' => $this->themeFunctions(),
'#view' => $this->view,
'#options' => $this->options,
'#row' => $row,
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Drupal\search\Plugin\views\sort;
use Drupal\views\Attribute\ViewsSort;
use Drupal\views\Plugin\views\sort\SortPluginBase;
/**
* Sort handler for sorting by search score.
*
* @ingroup views_sort_handlers
*/
#[ViewsSort("search_score")]
class Score extends SortPluginBase {
/**
* {@inheritdoc}
*/
public function query() {
// Check to see if the search filter/argument added 'score' to the table.
// Our filter stores it as $handler->search_score -- and we also
// need to check its relationship to make sure that we're using the same
// one or obviously this won't work.
foreach (['filter', 'argument'] as $type) {
foreach ($this->view->{$type} as $handler) {
if (isset($handler->search_score) && $handler->relationship == $this->relationship) {
$this->query->addOrderBy(NULL, NULL, $this->options['order'], $handler->search_score);
$this->tableAlias = $handler->tableAlias;
return;
}
}
}
// Do nothing if there is no filter/argument in place. There is no way
// to sort on scores.
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace Drupal\search\Routing;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\search\SearchPageRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
/**
* Provides dynamic routes for search.
*/
class SearchPageRoutes implements ContainerInjectionInterface {
/**
* The search page repository.
*
* @var \Drupal\search\SearchPageRepositoryInterface
*/
protected $searchPageRepository;
/**
* Constructs a new search route subscriber.
*
* @param \Drupal\search\SearchPageRepositoryInterface $search_page_repository
* The search page repository.
*/
public function __construct(SearchPageRepositoryInterface $search_page_repository) {
$this->searchPageRepository = $search_page_repository;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('search.search_page_repository')
);
}
/**
* Returns an array of route objects.
*
* @return \Symfony\Component\Routing\Route[]
* An array of route objects.
*/
public function routes() {
$routes = [];
// @todo Decide if /search should continue to redirect to /search/$default,
// or just perform the appropriate search.
if ($default_page = $this->searchPageRepository->getDefaultSearchPage()) {
$routes['search.view'] = new Route(
'/search',
[
'_controller' => 'Drupal\search\Controller\SearchController::redirectSearchPage',
'_title' => 'Search',
'entity' => $default_page,
],
[
'_entity_access' => 'entity.view',
'_permission' => 'search content',
],
[
'parameters' => [
'entity' => [
'type' => 'entity:search_page',
],
],
]
);
}
$active_pages = $this->searchPageRepository->getActiveSearchPages();
foreach ($active_pages as $entity_id => $entity) {
$routes["search.view_$entity_id"] = new Route(
'/search/' . $entity->getPath(),
[
'_controller' => 'Drupal\search\Controller\SearchController::view',
'_title' => 'Search',
'entity' => $entity_id,
],
[
'_entity_access' => 'entity.view',
'_permission' => 'search content',
],
[
'parameters' => [
'entity' => [
'type' => 'entity:search_page',
],
],
]
);
$routes["search.help_$entity_id"] = new Route(
'/search/' . $entity->getPath() . '/help',
[
'_controller' => 'Drupal\search\Controller\SearchController::searchHelp',
'_title' => 'About searching',
'entity' => $entity_id,
],
[
'_entity_access' => 'entity.view',
'_permission' => 'search content',
],
[
'parameters' => [
'entity' => [
'type' => 'entity:search_page',
],
],
]
);
if ($entity->getPlugin()->usesAdminTheme()) {
$routes["search.view_$entity_id"]->setOption('_admin_route', TRUE);
$routes["search.help_$entity_id"]->setOption('_admin_route', TRUE);
}
}
return $routes;
}
}

View File

@ -0,0 +1,296 @@
<?php
namespace Drupal\search;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\search\Exception\SearchIndexException;
/**
* Provides search index management functions.
*/
class SearchIndex implements SearchIndexInterface {
/**
* SearchIndex constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
* @param \Drupal\Core\Database\Connection $connection
* The database connection.
* @param \Drupal\Core\Database\Connection $replica
* The database replica connection.
* @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cacheTagsInvalidator
* The cache tags invalidator.
* @param \Drupal\search\SearchTextProcessorInterface $textProcessor
* The text processor.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected Connection $connection,
protected Connection $replica,
protected CacheTagsInvalidatorInterface $cacheTagsInvalidator,
protected SearchTextProcessorInterface $textProcessor,
protected TimeInterface $time ,
) {
}
/**
* {@inheritdoc}
*/
public function index($type, $sid, $langcode, $text, $update_weights = TRUE) {
$settings = $this->configFactory->get('search.settings');
$minimum_word_size = $settings->get('index.minimum_word_size');
// Keep track of the words that need to have their weights updated.
$current_words = [];
// Multipliers for scores of words inside certain HTML tags. The weights are
// stored in config so that modules can overwrite the default weights.
// Note: 'a' must be included for link ranking to work.
$tags = $settings->get('index.tag_weights');
// Strip off all ignored tags to speed up processing, but insert space
// before and after them to keep word boundaries.
$text = str_replace(['<', '>'], [' <', '> '], $text);
$text = strip_tags($text, '<' . implode('><', array_keys($tags)) . '>');
// Split HTML tags from plain text.
$split = preg_split('/\s*<([^>]+?)>\s*/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
// Note: PHP ensures the array consists of alternating delimiters and
// literals and begins and ends with a literal (inserting $null as
// required).
// Odd/even counter. Tag or no tag.
$tag = FALSE;
// Starting score per word.
$score = 1;
// Accumulator for cleaned up data.
$accumulator = ' ';
// Stack with open tags.
$tag_stack = [];
// Counter for consecutive words.
$tag_words = 0;
// Focus state.
$focus = 1;
// Accumulator for words for index.
$scored_words = [];
foreach ($split as $value) {
if ($tag) {
// Increase or decrease score per word based on tag.
[$tagname] = explode(' ', $value, 2);
$tagname = mb_strtolower($tagname);
// Closing or opening tag?
if ($tagname[0] == '/') {
$tagname = substr($tagname, 1);
// If we encounter unexpected tags, reset score to avoid incorrect
// boosting.
if (!count($tag_stack) || $tag_stack[0] != $tagname) {
$tag_stack = [];
$score = 1;
}
else {
// Remove from tag stack and decrement score.
$score = max(1, $score - $tags[array_shift($tag_stack)]);
}
}
else {
if (isset($tag_stack[0]) && $tag_stack[0] == $tagname) {
// None of the tags we look for make sense when nested identically.
// If they are, it's probably broken HTML.
$tag_stack = [];
$score = 1;
}
else {
// Add to open tag stack and increment score.
array_unshift($tag_stack, $tagname);
$score += $tags[$tagname];
}
}
// A tag change occurred, reset counter.
$tag_words = 0;
}
else {
// Note: use of PREG_SPLIT_DELIM_CAPTURE above will introduce empty
// values.
if ($value != '') {
$words = $this->textProcessor->process($value, $langcode);
foreach ($words as $word) {
// Add word to accumulator.
$accumulator .= $word . ' ';
// Check word length.
if (is_numeric($word) || mb_strlen($word) >= $minimum_word_size) {
if (!isset($scored_words[$word])) {
$scored_words[$word] = 0;
}
$scored_words[$word] += $score * $focus;
// Focus is a decaying value in terms of the amount of unique
// words up to this point. From 100 words and more, it decays, to
// e.g. 0.5 at 500 words and 0.3 at 1000 words.
$focus = min(1, .01 + 3.5 / (2 + count($scored_words) * .015));
}
$tag_words++;
// Too many words inside a single tag probably mean a tag was
// accidentally left open.
if (count($tag_stack) && $tag_words >= 15) {
$tag_stack = [];
$score = 1;
}
}
}
}
$tag = !$tag;
}
// Remove the item $sid from the search index, and invalidate the relevant
// cache tags.
$this->clear($type, $sid, $langcode);
try {
// Insert cleaned up data into dataset.
$this->connection->insert('search_dataset')
->fields([
'sid' => $sid,
'langcode' => $langcode,
'type' => $type,
'data' => $accumulator,
'reindex' => 0,
])
->execute();
// Insert results into search index.
foreach ($scored_words as $word => $score) {
// If a word already exists in the database, its score gets increased
// appropriately. If not, we create a new record with the appropriate
// starting score.
$this->connection->merge('search_index')
->keys([
'word' => $word,
'sid' => $sid,
'langcode' => $langcode,
'type' => $type,
])
->fields(['score' => $score])
->expression('score', '[score] + :score', [':score' => $score])
->execute();
$current_words[$word] = TRUE;
}
}
catch (\Exception $e) {
throw new SearchIndexException("Failed to insert dataset in index for type '$type', sid '$sid' and langcode '$langcode'", 0, $e);
}
finally {
if ($update_weights) {
$this->updateWordWeights($current_words);
}
}
return $current_words;
}
/**
* {@inheritdoc}
*/
public function clear($type = NULL, $sid = NULL, $langcode = NULL) {
try {
$query_index = $this->connection->delete('search_index');
$query_dataset = $this->connection->delete('search_dataset');
if ($type) {
$query_index->condition('type', $type);
$query_dataset->condition('type', $type);
if ($sid) {
$query_index->condition('sid', $sid);
$query_dataset->condition('sid', $sid);
if ($langcode) {
$query_index->condition('langcode', $langcode);
$query_dataset->condition('langcode', $langcode);
}
}
}
$query_index->execute();
$query_dataset->execute();
}
catch (\Exception $e) {
throw new SearchIndexException("Failed to clear index for type '$type', sid '$sid' and langcode '$langcode'", 0, $e);
}
if ($type) {
// Invalidate all render cache items that contain data from this index.
$this->cacheTagsInvalidator->invalidateTags(['search_index:' . $type]);
}
else {
// Invalidate all render cache items that contain data from any index.
$this->cacheTagsInvalidator->invalidateTags(['search_index']);
}
}
/**
* {@inheritdoc}
*/
public function markForReindex($type = NULL, $sid = NULL, $langcode = NULL) {
try {
$query = $this->connection->update('search_dataset')
->fields(['reindex' => $this->time->getRequestTime()])
// Only mark items that were not previously marked for reindex, so that
// marked items maintain their priority by request time.
->condition('reindex', 0);
if ($type) {
$query->condition('type', $type);
if ($sid) {
$query->condition('sid', $sid);
if ($langcode) {
$query->condition('langcode', $langcode);
}
}
}
$query->execute();
}
catch (\Exception $e) {
throw new SearchIndexException("Failed to mark index for re-indexing for type '$type', sid '$sid' and langcode '$langcode'", 0, $e);
}
}
/**
* {@inheritdoc}
*/
public function updateWordWeights(array $words) {
try {
// Update word IDF (Inverse Document Frequency) counts for new/changed
// words.
$words = array_keys($words);
foreach ($words as $word) {
// Get total count.
$total = $this->replica->query("SELECT SUM([score]) FROM {search_index} WHERE [word] = :word", [':word' => $word])
->fetchField();
// Apply Zipf's law to equalize the probability distribution.
$total = log10(1 + 1 / (max(1, $total)));
$this->connection->merge('search_total')
->key('word', $word)
->fields(['count' => $total])
->execute();
}
// Find words that were deleted from search_index, but are still in
// search_total. We use a LEFT JOIN between the two tables and keep only
// the rows which fail to join.
$result = $this->replica->query("SELECT [t].[word] AS [realword], [i].[word] FROM {search_total} [t] LEFT JOIN {search_index} [i] ON [t].[word] = [i].[word] WHERE [i].[word] IS NULL");
$or = $this->replica->condition('OR');
foreach ($result as $word) {
$or->condition('word', $word->realword);
}
if (count($or) > 0) {
$this->connection->delete('search_total')
->condition($or)
->execute();
}
}
catch (\Exception $e) {
throw new SearchIndexException("Failed to update totals for index words.", 0, $e);
}
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace Drupal\search;
/**
* Provides search index management functions.
*
* @ingroup search
*/
interface SearchIndexInterface {
/**
* Updates the full-text search index for a particular item.
*
* @param string $type
* The plugin ID or other machine-readable type of this item,
* which should be less than 64 bytes.
* @param int $sid
* An ID number identifying this particular item (e.g., node ID).
* @param string $langcode
* Language code for the language of the text being indexed.
* @param string $text
* The content of this item. Must be a piece of HTML or plain text.
* @param bool $update_weights
* (optional) TRUE if word weights should be updated. FALSE otherwise;
* defaults to TRUE. If you pass in FALSE, then you need to have your
* calls to this method in a try/finally block, and at the end of your
* index run in the finally clause, you will need to call
* self::updateWordWeights(), passing in all of the returned words, to
* update the word weights.
*
* @return string[]
* The words to be updated.
*
* @throws \Drupal\search\Exception\SearchIndexException
* If there is an error indexing the text.
*/
public function index($type, $sid, $langcode, $text, $update_weights = TRUE);
/**
* Clears either a part of, or the entire search index.
*
* This function is meant for use by search page plugins, or for building a
* user interface that lets users clear all or parts of the search index.
*
* @param string|null $type
* (optional) The plugin ID or other machine-readable type for the items to
* remove from the search index. If omitted, $sid and $langcode are ignored
* and the entire search index is cleared.
* @param int|array|null $sid
* (optional) The ID or array of IDs of the items to remove from the search
* index. If omitted, all items matching $type are cleared, and $langcode
* is ignored.
* @param string|null $langcode
* (optional) Language code of the item to remove from the search index. If
* omitted, all items matching $sid and $type are cleared.
*
* @throws \Drupal\search\Exception\SearchIndexException
* If there is an error clearing the index.
*/
public function clear($type = NULL, $sid = NULL, $langcode = NULL);
/**
* Changes the timestamp on indexed items to 'now' to force reindexing.
*
* This function is meant for use by search page plugins, or for building a
* user interface that lets users mark all or parts of the search index for
* reindexing.
*
* @param string $type
* (optional) The plugin ID or other machine-readable type of this item. If
* omitted, the entire search index is marked for reindexing, and $sid and
* $langcode are ignored.
* @param int $sid
* (optional) An ID number identifying this particular item (e.g., node ID).
* If omitted, everything matching $type is marked, and $langcode is
* ignored.
* @param string $langcode
* (optional) The language code to mark. If omitted, everything matching
* $type and $sid is marked.
*
* @throws \Drupal\search\Exception\SearchIndexException
* If there is an error marking the index for re-indexing.
*/
public function markForReindex($type = NULL, $sid = NULL, $langcode = NULL);
/**
* Updates the {search_total} database table.
*
* @param array $words
* An array whose keys are words from self::index() whose total weights
* need to be updated.
*
* @throws \Drupal\search\Exception\SearchIndexException
* If there is an error updating the totals.
*/
public function updateWordWeights(array $words);
}

View File

@ -0,0 +1,44 @@
<?php
namespace Drupal\search;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessibleInterface;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Defines the access control handler for the search page entity type.
*
* @see \Drupal\search\Entity\SearchPage
*/
class SearchPageAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var \Drupal\search\SearchPageInterface $entity */
if (in_array($operation, ['delete', 'disable'])) {
if ($entity->isDefaultSearch()) {
return AccessResult::forbidden()->addCacheableDependency($entity);
}
else {
return parent::checkAccess($entity, $operation, $account)->addCacheableDependency($entity);
}
}
if ($operation == 'view') {
if (!$entity->status()) {
return AccessResult::forbidden()->addCacheableDependency($entity);
}
$plugin = $entity->getPlugin();
if ($plugin instanceof AccessibleInterface) {
return $plugin->access($operation, $account, TRUE)->addCacheableDependency($entity);
}
return AccessResult::allowed()->addCacheableDependency($entity);
}
return parent::checkAccess($entity, $operation, $account);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Drupal\search;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Provides an interface defining a search page entity.
*/
interface SearchPageInterface extends ConfigEntityInterface {
/**
* Returns the search plugin.
*
* @return \Drupal\search\Plugin\SearchInterface
* The search plugin used by this search page entity.
*/
public function getPlugin();
/**
* Sets the search plugin.
*
* @param string $plugin_id
* The search plugin ID.
*/
public function setPlugin($plugin_id);
/**
* Determines if this search page entity is currently the default search.
*
* @return bool
* TRUE if this search page entity is the default search, FALSE otherwise.
*/
public function isDefaultSearch();
/**
* Determines if this search page entity is indexable.
*
* @return bool
* TRUE if this search page entity is indexable, FALSE otherwise.
*/
public function isIndexable();
/**
* Returns the path for the search.
*
* @return string
* The part of the path for this search page that comes after 'search'.
*/
public function getPath();
/**
* Returns the weight for the page.
*
* @return int
* The page weight.
*/
public function getWeight();
}

View File

@ -0,0 +1,396 @@
<?php
namespace Drupal\search;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\Entity\DraggableListBuilder;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\ConfigFormBaseTrait;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class to build a listing of search page entities.
*
* @see \Drupal\search\Entity\SearchPage
*/
class SearchPageListBuilder extends DraggableListBuilder implements FormInterface {
use ConfigFormBaseTrait;
/**
* The entities being listed.
*
* @var \Drupal\search\SearchPageInterface[]
*/
protected $entities = [];
/**
* Stores the configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The search manager.
*
* @var \Drupal\search\SearchPluginManager
*/
protected $searchManager;
/**
* The search index.
*
* @var \Drupal\search\SearchIndexInterface
*/
protected $searchIndex;
/**
* The messenger.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Constructs a new SearchPageListBuilder object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Core\Entity\EntityStorageInterface $storage
* The entity storage class.
* @param \Drupal\search\SearchPluginManager $search_manager
* The search plugin manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The factory for configuration objects.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger.
* @param \Drupal\search\SearchIndexInterface $search_index
* The search index.
*/
public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, SearchPluginManager $search_manager, ConfigFactoryInterface $config_factory, MessengerInterface $messenger, SearchIndexInterface $search_index) {
parent::__construct($entity_type, $storage);
$this->configFactory = $config_factory;
$this->searchManager = $search_manager;
$this->messenger = $messenger;
$this->searchIndex = $search_index;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('entity_type.manager')->getStorage($entity_type->id()),
$container->get('plugin.manager.search'),
$container->get('config.factory'),
$container->get('messenger'),
$container->get('search.index')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'search_admin_settings';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['search.settings'];
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = [
'data' => $this->t('Label'),
];
$header['url'] = [
'data' => $this->t('URL'),
'class' => [RESPONSIVE_PRIORITY_LOW],
];
$header['plugin'] = [
'data' => $this->t('Type'),
'class' => [RESPONSIVE_PRIORITY_LOW],
];
$header['status'] = [
'data' => $this->t('Status'),
];
$header['progress'] = [
'data' => $this->t('Indexing progress'),
'class' => [RESPONSIVE_PRIORITY_MEDIUM],
];
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
/** @var \Drupal\search\SearchPageInterface $entity */
$row['label'] = $entity->label();
$row['url']['#markup'] = 'search/' . $entity->getPath();
// If the search page is active, link to it.
if ($entity->status()) {
$row['url'] = [
'#type' => 'link',
'#title' => $row['url'],
'#url' => Url::fromRoute('search.view_' . $entity->id()),
];
}
$definition = $entity->getPlugin()->getPluginDefinition();
$row['plugin']['#markup'] = $definition['title'];
if ($entity->isDefaultSearch()) {
$status = $this->t('Default');
}
elseif ($entity->status()) {
$status = $this->t('Enabled');
}
else {
$status = $this->t('Disabled');
}
$row['status']['#markup'] = $status;
if ($entity->isIndexable()) {
$status = $entity->getPlugin()->indexStatus();
$row['progress']['#markup'] = $this->t('%num_indexed of %num_total indexed', [
'%num_indexed' => $status['total'] - $status['remaining'],
'%num_total' => $status['total'],
]);
}
else {
$row['progress']['#markup'] = $this->t('Does not use index');
}
return $row + parent::buildRow($entity);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$search_settings = $this->config('search.settings');
// Collect some stats.
$remaining = 0;
$total = 0;
foreach ($this->entities as $entity) {
if ($entity->isIndexable() && $status = $entity->getPlugin()->indexStatus()) {
$remaining += $status['remaining'];
$total += $status['total'];
}
}
$this->moduleHandler->loadAllIncludes('admin.inc');
$count = $this->formatPlural($remaining, 'There is 1 item left to index.', 'There are @count items left to index.');
$done = $total - $remaining;
// Use floor() to calculate the percentage, so if it is not quite 100%, it
// will show as 99%, to indicate "almost done".
$percentage = $total > 0 ? floor(100 * $done / $total) : 100;
$percentage .= '%';
$status = '<p><strong>' . $this->t('%percentage of the site has been indexed.', ['%percentage' => $percentage]) . ' ' . $count . '</strong></p>';
$form['status'] = [
'#type' => 'details',
'#title' => $this->t('Indexing progress'),
'#open' => TRUE,
'#description' => $this->t('Only items in the index will appear in search results. To build and maintain the index, a correctly configured <a href=":cron">cron maintenance task</a> is required.', [':cron' => Url::fromRoute('system.cron_settings')->toString()]),
];
$form['status']['status'] = ['#markup' => $status];
$form['status']['wipe'] = [
'#type' => 'submit',
'#value' => $this->t('Re-index site'),
'#submit' => ['::searchAdminReindexSubmit'],
];
$items = [10, 20, 50, 100, 200, 500];
$items = array_combine($items, $items);
// Indexing throttle:
$form['indexing_throttle'] = [
'#type' => 'details',
'#title' => $this->t('Indexing throttle'),
'#open' => TRUE,
];
$form['indexing_throttle']['cron_limit'] = [
'#type' => 'select',
'#title' => $this->t('Number of items to index per run'),
'#default_value' => $search_settings->get('index.cron_limit'),
'#options' => $items,
'#description' => $this->t('The maximum number of items processed per indexing run. If necessary, reduce the number of items to prevent timeouts and memory errors while indexing. Some search page types may have their own setting for this.'),
];
// Indexing settings:
$form['indexing_settings'] = [
'#type' => 'details',
'#title' => $this->t('Default indexing settings'),
'#open' => TRUE,
'#description' => $this->t('Changing these settings will cause the default search index to be rebuilt to reflect the new settings. Searching will continue to work, based on the existing index, but new content will not be indexed until all existing content has been re-indexed.'),
];
$form['indexing_settings']['minimum_word_size'] = [
'#type' => 'number',
'#title' => $this->t('Minimum word length to index'),
'#default_value' => $search_settings->get('index.minimum_word_size'),
'#min' => 1,
'#max' => 1000,
'#description' => $this->t('The minimum character length for a word to be added to the index. Searches must include a keyword of at least this length.'),
];
$form['indexing_settings']['overlap_cjk'] = [
'#type' => 'checkbox',
'#title' => $this->t('Simple CJK handling'),
'#default_value' => $search_settings->get('index.overlap_cjk'),
'#description' => $this->t('Whether to apply a simple Chinese/Japanese/Korean tokenizer based on overlapping sequences. Turn this off if you want to use an external preprocessor for this instead. Does not affect other languages.'),
];
// Indexing settings:
$form['logging'] = [
'#type' => 'details',
'#title' => $this->t('Logging'),
'#open' => TRUE,
];
$form['logging']['logging'] = [
'#type' => 'checkbox',
'#title' => $this->t('Log searches'),
'#default_value' => $search_settings->get('logging'),
'#description' => $this->t('If checked, all searches will be logged. Uncheck to skip logging. Logging may affect performance.'),
];
$form['search_pages'] = [
'#type' => 'details',
'#title' => $this->t('Search pages'),
'#open' => TRUE,
];
$form['search_pages']['add_page'] = [
'#type' => 'container',
'#attributes' => [
'class' => ['container-inline'],
],
];
// In order to prevent validation errors for the parent form, this cannot be
// required, see self::validateAddSearchPage().
$form['search_pages']['add_page']['search_type'] = [
'#type' => 'select',
'#title' => $this->t('Search page type'),
'#empty_option' => $this->t('- Choose page type -'),
'#options' => array_map(function ($definition) {
return $definition['title'];
}, $this->searchManager->getDefinitions()),
];
$form['search_pages']['add_page']['add_search_submit'] = [
'#type' => 'submit',
'#value' => $this->t('Add search page'),
'#validate' => ['::validateAddSearchPage'],
'#submit' => ['::submitAddSearchPage'],
'#limit_validation_errors' => [['search_type']],
];
// Move the listing into the search_pages element.
$form['search_pages'][$this->entitiesKey] = $form[$this->entitiesKey];
$form['search_pages'][$this->entitiesKey]['#empty'] = $this->t('No search pages have been configured.');
unset($form[$this->entitiesKey]);
$form['actions']['#type'] = 'actions';
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save configuration'),
'#button_type' => 'primary',
];
return $form;
}
/**
* {@inheritdoc}
*/
public function getDefaultOperations(EntityInterface $entity) {
/** @var \Drupal\search\SearchPageInterface $entity */
$operations = parent::getDefaultOperations($entity);
// Prevent the default search from being disabled or deleted.
if ($entity->isDefaultSearch()) {
unset($operations['disable'], $operations['delete']);
}
else {
$operations['default'] = [
'title' => $this->t('Set as default'),
'url' => Url::fromRoute('entity.search_page.set_default', [
'search_page' => $entity->id(),
]),
'weight' => 50,
];
}
return $operations;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
parent::submitForm($form, $form_state);
$search_settings = $this->config('search.settings');
// If these settings change, the default index needs to be rebuilt.
if (($search_settings->get('index.minimum_word_size') != $form_state->getValue('minimum_word_size')) || ($search_settings->get('index.overlap_cjk') != $form_state->getValue('overlap_cjk'))) {
$search_settings->set('index.minimum_word_size', $form_state->getValue('minimum_word_size'));
$search_settings->set('index.overlap_cjk', $form_state->getValue('overlap_cjk'));
// Specifically mark items in the default index for reindexing, since
// these settings are used in the SearchIndex::index() function.
$this->messenger->addStatus($this->t('The default search index will be rebuilt.'));
$this->searchIndex->markForReindex();
}
$search_settings
->set('index.cron_limit', $form_state->getValue('cron_limit'))
->set('logging', $form_state->getValue('logging'))
->save();
$this->messenger->addStatus($this->t('The configuration options have been saved.'));
}
/**
* Form submission handler for reindex button on search admin settings form.
*/
public function searchAdminReindexSubmit(array &$form, FormStateInterface $form_state) {
// Send the user to the confirmation page.
$form_state->setRedirect('search.reindex_confirm');
}
/**
* Form validation handler for adding a new search page.
*/
public function validateAddSearchPage(array &$form, FormStateInterface $form_state) {
if ($form_state->isValueEmpty('search_type')) {
$form_state->setErrorByName('search_type', $this->t('You must select the new search page type.'));
}
}
/**
* Form submission handler for adding a new search page.
*/
public function submitAddSearchPage(array &$form, FormStateInterface $form_state) {
$form_state->setRedirect(
'search.add_type',
['search_plugin_id' => $form_state->getValue('search_type')]
);
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Drupal\search;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Provides a repository for Search Page config entities.
*/
class SearchPageRepository implements SearchPageRepositoryInterface {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The search page storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $storage;
/**
* Constructs a new SearchPageRepository.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager) {
$this->configFactory = $config_factory;
$this->storage = $entity_type_manager->getStorage('search_page');
}
/**
* {@inheritdoc}
*/
public function getActiveSearchPages() {
$ids = $this->getQuery()
->condition('status', TRUE)
->execute();
return $this->storage->loadMultiple($ids);
}
/**
* {@inheritdoc}
*/
public function isSearchActive() {
return (bool) $this->getQuery()
->condition('status', TRUE)
->range(0, 1)
->execute();
}
/**
* {@inheritdoc}
*/
public function getIndexableSearchPages() {
return array_filter($this->getActiveSearchPages(), function (SearchPageInterface $search) {
return $search->isIndexable();
});
}
/**
* {@inheritdoc}
*/
public function getDefaultSearchPage() {
// Find all active search pages (without loading them).
$search_pages = $this->getQuery()
->condition('status', TRUE)
->execute();
// If the default page is active, return it.
$default = $this->configFactory->get('search.settings')->get('default_page');
if (isset($search_pages[$default])) {
return $default;
}
// Otherwise, use the first active search page.
return is_array($search_pages) ? reset($search_pages) : FALSE;
}
/**
* {@inheritdoc}
*/
public function clearDefaultSearchPage() {
$this->configFactory->getEditable('search.settings')->clear('default_page')->save();
}
/**
* {@inheritdoc}
*/
public function setDefaultSearchPage(SearchPageInterface $search_page) {
$this->configFactory->getEditable('search.settings')->set('default_page', $search_page->id())->save();
$search_page->enable()->save();
}
/**
* {@inheritdoc}
*/
public function sortSearchPages($search_pages) {
$entity_type = $this->storage->getEntityType();
uasort($search_pages, [$entity_type->getClass(), 'sort']);
return $search_pages;
}
/**
* Returns an entity query instance.
*
* @return \Drupal\Core\Entity\Query\QueryInterface
* The query instance.
*/
protected function getQuery() {
return $this->storage->getQuery();
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace Drupal\search;
/**
* Provides the interface for a repository Search Page entities.
*/
interface SearchPageRepositoryInterface {
/**
* Returns all active search page entities.
*
* @return \Drupal\search\SearchPageInterface[]
* An array of active search page entities.
*/
public function getActiveSearchPages();
/**
* Returns whether search is active.
*
* @return bool
* TRUE if at least one search is active, FALSE otherwise.
*/
public function isSearchActive();
/**
* Returns all active, indexable search page entities.
*
* @return \Drupal\search\SearchPageInterface[]
* An array of indexable search page entities.
*/
public function getIndexableSearchPages();
/**
* Returns the default search page.
*
* @return string|false
* The default search page entity ID, or FALSE if no pages are active.
*/
public function getDefaultSearchPage();
/**
* Sets a given search page as the default.
*
* @param \Drupal\search\SearchPageInterface $search_page
* The search page entity.
*
* @return static
*/
public function setDefaultSearchPage(SearchPageInterface $search_page);
/**
* Clears the default search page.
*/
public function clearDefaultSearchPage();
/**
* Sorts a list of search pages.
*
* @param \Drupal\search\SearchPageInterface[] $search_pages
* The unsorted list of search pages.
*
* @return \Drupal\search\SearchPageInterface[]
* The sorted list of search pages.
*/
public function sortSearchPages($search_pages);
}

View File

@ -0,0 +1,33 @@
<?php
namespace Drupal\search;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\search\Attribute\Search;
use Drupal\search\Plugin\SearchInterface;
/**
* SearchExecute plugin manager.
*/
class SearchPluginManager extends DefaultPluginManager {
/**
* Constructs SearchPluginManager.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Search', $namespaces, $module_handler, SearchInterface::class, Search::class, 'Drupal\search\Annotation\SearchPlugin');
$this->setCacheBackend($cache_backend, 'search_plugins');
$this->alterInfo('search_plugin');
}
}

View File

@ -0,0 +1,649 @@
<?php
namespace Drupal\search;
use Drupal\Core\Database\Query\SelectExtender;
use Drupal\Core\Database\Query\SelectInterface;
/**
* Search query extender and helper functions.
*
* Performs a query on the full-text search index for a word or words.
*
* This query is used by search plugins that use the search index (not all
* search plugins do, as some use a different searching mechanism). It
* assumes you have set up a query on the {search_index} table with alias 'i',
* and will only work if the user is searching for at least one "positive"
* keyword or phrase.
*
* For efficiency, users of this query can run the prepareAndNormalize()
* method to figure out if there are any search results, before fully setting
* up and calling execute() to execute the query. The scoring expressions are
* not needed until the execute() step. However, it's not really necessary
* to do this, because this class's execute() method does that anyway.
*
* During both the prepareAndNormalize() and execute() steps, there can be
* problems. Call getStatus() to figure out if the query is OK or not.
*
* The query object is given the tag 'search_$type' and can be further
* extended with hook_query_alter().
*/
class SearchQuery extends SelectExtender {
/**
* Indicates no positive keywords were in the search expression.
*
* Positive keywords are words that are searched for, as opposed to negative
* keywords, which are words that are excluded. To count as a keyword, a
* word must be at least
* \Drupal::config('search.settings')->get('index.minimum_word_size')
* characters.
*
* @see SearchQuery::getStatus()
*/
const NO_POSITIVE_KEYWORDS = 1;
/**
* Indicates that part of the search expression was ignored.
*
* To prevent Denial of Service attacks, only
* \Drupal::config('search.settings')->get('and_or_limit') expressions
* (positive keywords, phrases, negative keywords) are allowed; this flag
* indicates that expressions existed past that limit and they were removed.
*
* @see SearchQuery::getStatus()
*/
const EXPRESSIONS_IGNORED = 2;
/**
* Indicates that lower-case "or" was in the search expression.
*
* The word "or" in lower case was found in the search expression. This
* probably means someone was trying to do an OR search but used lower-case
* instead of upper-case.
*
* @see SearchQuery::getStatus()
*/
const LOWER_CASE_OR = 4;
/**
* Indicates that no positive keyword matches were found.
*
* @see SearchQuery::getStatus()
*/
const NO_KEYWORD_MATCHES = 8;
/**
* The keywords and advanced search options that are entered by the user.
*
* @var string
*/
protected $searchExpression;
/**
* The type of search (search type).
*
* This maps to the value of the type column in search_index, and is usually
* equal to the machine-readable name of the plugin or the search page.
*
* @var string
*/
protected $type;
/**
* Parsed-out positive and negative search keys.
*
* @var array
*/
protected $keys = ['positive' => [], 'negative' => []];
/**
* Indicates whether the query conditions are simple or complex (LIKE).
*
* @var bool
*/
protected $simple = TRUE;
/**
* Conditions that are used for exact searches.
*
* This is always used for the second step in the query, but is not part of
* the preparation step unless $this->simple is FALSE.
*
* @var \Drupal\Core\Database\Query\ConditionInterface[]
*/
protected $conditions;
/**
* Indicates how many matches for a search query are necessary.
*
* @var int
*/
protected $matches = 0;
/**
* Array of positive search words.
*
* These words have to match against {search_index}.word.
*
* @var array
*/
protected $words = [];
/**
* Multiplier to normalize the keyword score.
*
* This value is calculated by the preparation step, and is used as a
* multiplier of the word scores to make sure they are between 0 and 1.
*
* @var float
*/
protected $normalize = 0;
/**
* Indicates whether the preparation step has been executed.
*
* @var bool
*/
protected $executedPrepare = FALSE;
/**
* A bitmap of status conditions, described in getStatus().
*
* @var int
*
* @see SearchQuery::getStatus()
*/
protected $status = 0;
/**
* The word score expressions.
*
* @var array
*
* @see SearchQuery::addScore()
*/
protected $scores = [];
/**
* Arguments for the score expressions.
*
* @var array
*/
protected $scoresArguments = [];
/**
* The number of 'i.relevance' occurrences in score expressions.
*
* @var int
*/
// phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing
protected $relevance_count = 0;
/**
* Multipliers for score expressions.
*
* @var array
*/
protected $multiply = [];
/**
* Sets the search query expression.
*
* @param string $expression
* A search string, which can contain keywords and options.
* @param string $type
* The search type. This maps to {search_index}.type in the database.
*
* @return $this
*/
public function searchExpression($expression, $type) {
$this->searchExpression = $expression;
$this->type = $type;
// Add query tag.
$this->addTag('search_' . $type);
// Initialize conditions and status.
$this->conditions = $this->connection->condition('AND');
$this->status = 0;
return $this;
}
/**
* Parses the search query into SQL conditions.
*
* Sets up the following variables:
* - $this->keys
* - $this->words
* - $this->conditions
* - $this->simple
* - $this->matches
*/
protected function parseSearchExpression() {
// Matches words optionally prefixed by a - sign. A word in this case is
// something between two spaces, optionally quoted.
preg_match_all('/ (-?)("[^"]+"|[^" ]+)/i', ' ' . $this->searchExpression, $keywords, PREG_SET_ORDER);
if (count($keywords) == 0) {
return;
}
// Classify tokens.
$in_or = FALSE;
$limit_combinations = \Drupal::config('search.settings')->get('and_or_limit');
/** @var \Drupal\search\SearchTextProcessorInterface $text_processor */
$text_processor = \Drupal::service('search.text_processor');
// The first search expression does not count as AND.
$and_count = -1;
$or_count = 0;
foreach ($keywords as $match) {
if ($or_count && $and_count + $or_count >= $limit_combinations) {
// Ignore all further search expressions to prevent Denial-of-Service
// attacks using a high number of AND/OR combinations.
$this->status |= SearchQuery::EXPRESSIONS_IGNORED;
break;
}
// Strip off phrase quotes.
$phrase = FALSE;
if ($match[2][0] == '"') {
$match[2] = substr($match[2], 1, -1);
$phrase = TRUE;
$this->simple = FALSE;
}
// Simplify keyword according to indexing rules and external
// preprocessors. Use same process as during search indexing, so it
// will match search index.
$words = $text_processor->analyze($match[2]);
// Re-explode in case simplification added more words, except when
// matching a phrase.
$words = $phrase ? [$words] : preg_split('/ /', $words, -1, PREG_SPLIT_NO_EMPTY);
// Negative matches.
if ($match[1] == '-') {
$this->keys['negative'] = array_merge($this->keys['negative'], $words);
}
// OR operator: instead of a single keyword, we store an array of all
// ORed keywords.
elseif ($match[2] == 'OR' && count($this->keys['positive'])) {
$last = array_pop($this->keys['positive']);
// Starting a new OR?
if (!is_array($last)) {
$last = [$last];
}
$this->keys['positive'][] = $last;
$in_or = TRUE;
$or_count++;
continue;
}
// AND operator: implied, so just ignore it.
elseif ($match[2] == 'AND' || $match[2] == 'and') {
continue;
}
// Plain keyword.
else {
if ($match[2] == 'or') {
// Lower-case "or" instead of "OR" is a warning condition.
$this->status |= SearchQuery::LOWER_CASE_OR;
}
if ($in_or) {
// Add to last element (which is an array).
$this->keys['positive'][count($this->keys['positive']) - 1] = array_merge($this->keys['positive'][count($this->keys['positive']) - 1], $words);
}
else {
$this->keys['positive'] = array_merge($this->keys['positive'], $words);
$and_count++;
}
}
$in_or = FALSE;
}
// Convert keywords into SQL statements.
$has_and = FALSE;
$has_or = FALSE;
// Positive matches.
foreach ($this->keys['positive'] as $key) {
// Group of ORed terms.
if (is_array($key) && count($key)) {
// If we had already found one OR, this is another one ANDed with the
// first, meaning it is not a simple query.
if ($has_or) {
$this->simple = FALSE;
}
$has_or = TRUE;
$has_new_scores = FALSE;
$query_or = $this->connection->condition('OR');
foreach ($key as $or) {
[$num_new_scores] = $this->parseWord($or);
$has_new_scores |= $num_new_scores;
$query_or->condition('d.data', "% $or %", 'LIKE');
}
if (count($query_or)) {
$this->conditions->condition($query_or);
// A group of OR keywords only needs to match once.
$this->matches += ($has_new_scores > 0);
}
}
// Single ANDed term.
else {
$has_and = TRUE;
[$num_new_scores, $num_valid_words] = $this->parseWord($key);
$this->conditions->condition('d.data', "% $key %", 'LIKE');
if (!$num_valid_words) {
$this->simple = FALSE;
}
// Each AND keyword needs to match at least once.
$this->matches += $num_new_scores;
}
}
if ($has_and && $has_or) {
$this->simple = FALSE;
}
// Negative matches.
foreach ($this->keys['negative'] as $key) {
$this->conditions->condition('d.data', "% $key %", 'NOT LIKE');
$this->simple = FALSE;
}
}
/**
* Parses a word or phrase for parseQuery().
*
* Splits a phrase into words. Adds its words to $this->words, if it is not
* already there. Returns a list containing the number of new words found,
* and the total number of words in the phrase.
*/
protected function parseWord($word) {
$num_new_scores = 0;
$num_valid_words = 0;
// Determine the scorewords of this word/phrase.
$split = explode(' ', $word);
foreach ($split as $s) {
$num = is_numeric($s);
if ($num || mb_strlen($s) >= \Drupal::config('search.settings')->get('index.minimum_word_size')) {
if (!isset($this->words[$s])) {
$this->words[$s] = $s;
$num_new_scores++;
}
$num_valid_words++;
}
}
// Return matching snippet and number of added words.
return [$num_new_scores, $num_valid_words];
}
/**
* Prepares the query and calculates the normalization factor.
*
* After the query is normalized the keywords are weighted to give the results
* a relevancy score. The query is ready for execution after this.
*
* Error and warning conditions can apply. Call getStatus() after calling
* this method to retrieve them.
*
* @return bool
* TRUE if at least one keyword matched the search index; FALSE if not.
*/
public function prepareAndNormalize() {
$this->parseSearchExpression();
$this->executedPrepare = TRUE;
if (count($this->words) == 0) {
// Although the query could proceed, there is no point in joining
// with other tables and attempting to normalize if there are no
// keywords present.
$this->status |= SearchQuery::NO_POSITIVE_KEYWORDS;
return FALSE;
}
// Build the basic search query: match the entered keywords.
$or = $this->connection->condition('OR');
foreach ($this->words as $word) {
$or->condition('i.word', $word);
}
$this->condition($or);
// Add keyword normalization information to the query.
$this->join('search_total', 't', '[i].[word] = [t].[word]');
$this
->condition('i.type', $this->type)
->groupBy('i.type')
->groupBy('i.sid');
// If the query is simple, we should have calculated the number of
// matching words we need to find, so impose that criterion. For non-
// simple queries, this condition could lead to incorrectly deciding not
// to continue with the full query.
if ($this->simple) {
$this->having('COUNT(*) >= :matches', [':matches' => $this->matches]);
}
// Clone the query object to calculate normalization.
$normalize_query = clone $this->query;
// For complex search queries, add the LIKE conditions; if the query is
// simple, we do not need them for normalization.
if (!$this->simple) {
$normalize_query->join('search_dataset', 'd', '[i].[sid] = [d].[sid] AND [i].[type] = [d].[type] AND [i].[langcode] = [d].[langcode]');
if (count($this->conditions)) {
$normalize_query->condition($this->conditions);
}
}
// Calculate normalization, which is the max of all the search scores for
// positive keywords in the query. And note that the query could have other
// fields added to it by the user of this extension.
$normalize_query->addExpression('SUM([i].[score] * [t].[count])', 'calculated_score');
$result = $normalize_query
->range(0, 1)
->orderBy('calculated_score', 'DESC')
->execute()
->fetchObject();
if (isset($result->calculated_score)) {
$this->normalize = (float) $result->calculated_score;
}
if ($this->normalize) {
return TRUE;
}
// If the normalization value was zero, that indicates there were no
// matches to the supplied positive keywords.
$this->status |= SearchQuery::NO_KEYWORD_MATCHES;
return FALSE;
}
/**
* {@inheritdoc}
*/
public function preExecute(?SelectInterface $query = NULL) {
if (!$this->executedPrepare) {
$this->prepareAndNormalize();
}
if (!$this->normalize) {
return FALSE;
}
return parent::preExecute($query);
}
/**
* Adds a custom score expression to the search query.
*
* Score expressions are used to order search results. If no calls to
* addScore() have taken place, a default keyword relevance score will be
* used. However, if at least one call to addScore() has taken place, the
* keyword relevance score is not automatically added.
*
* Note that you must use this method to add ordering to your searches, and
* not call orderBy() directly, when using the SearchQuery extender. This is
* because of the two-pass system the SearchQuery class uses to normalize
* scores.
*
* @param string $score
* The score expression, which should evaluate to a number between 0 and 1.
* The string 'i.relevance' in a score expression will be replaced by a
* measure of keyword relevance between 0 and 1.
* @param array $arguments
* Query arguments needed to provide values to the score expression.
* @param float $multiply
* If set, the score is multiplied with this value. However, all scores
* with multipliers are then divided by the total of all multipliers, so
* that overall, the normalization is maintained.
*
* @return $this
*/
public function addScore($score, $arguments = [], $multiply = FALSE) {
if ($multiply) {
$i = count($this->multiply);
// Modify the score expression so it is multiplied by the multiplier,
// with a divisor to renormalize. Note that the ROUND here is necessary
// for PostgreSQL and SQLite in order to ensure that the :multiply_* and
// :total_* arguments are treated as a numeric type, because the
// PostgreSQL PDO driver sometimes puts values in as strings instead of
// numbers in complex expressions like this.
$score = "(ROUND(:multiply_$i, 4)) * COALESCE(($score), 0) / (ROUND(:total_$i, 4))";
// Add an argument for the multiplier. The :total_$i argument is taken
// care of in the execute() method, which is when the total divisor is
// calculated.
$arguments[':multiply_' . $i] = $multiply;
$this->multiply[] = $multiply;
}
// Search scoring needs a way to include a keyword relevance in the score.
// For historical reasons, this is done by putting 'i.relevance' into the
// search expression. So, use string replacement to change this to a
// calculated query expression, counting the number of occurrences so
// in the execute() method we can add arguments.
while (str_contains($score, 'i.relevance')) {
$pieces = explode('i.relevance', $score, 2);
$score = implode('((ROUND(:normalization_' . $this->relevance_count . ', 4)) * i.score * t.count)', $pieces);
$this->relevance_count++;
}
$this->scores[] = $score;
$this->scoresArguments += $arguments;
return $this;
}
/**
* Executes the search.
*
* The complex conditions are applied to the query including score
* expressions and ordering.
*
* Error and warning conditions can apply. Call getStatus() after calling
* this method to retrieve them.
*
* @return \Drupal\Core\Database\StatementInterface|null
* A query result set containing the results of the query.
*/
public function execute() {
if (!$this->preExecute($this)) {
return NULL;
}
// Add conditions to the query.
$this->join('search_dataset', 'd', '[i].[sid] = [d].[sid] AND [i].[type] = [d].[type] AND [i].[langcode] = [d].[langcode]');
if (count($this->conditions)) {
$this->condition($this->conditions);
}
// Add default score (keyword relevance) if there are not any defined.
if (empty($this->scores)) {
$this->addScore('i.relevance');
}
if (count($this->multiply)) {
// Re-normalize scores with multipliers by dividing by the total of all
// multipliers. The expressions were altered in addScore(), so here just
// add the arguments for the total.
$sum = array_sum($this->multiply);
for ($i = 0; $i < count($this->multiply); $i++) {
$this->scoresArguments[':total_' . $i] = $sum;
}
}
// Add arguments for the keyword relevance normalization number.
$normalization = 1.0 / $this->normalize;
for ($i = 0; $i < $this->relevance_count; $i++) {
$this->scoresArguments[':normalization_' . $i] = $normalization;
}
// Add all scores together to form a query field.
$this->addExpression('SUM(' . implode(' + ', $this->scores) . ')', 'calculated_score', $this->scoresArguments);
// If an order has not yet been set for this query, add a default order
// that sorts by the calculated sum of scores.
if (count($this->getOrderBy()) == 0) {
$this->orderBy('calculated_score', 'DESC');
}
// Add query metadata.
$this
->addMetaData('normalize', $this->normalize)
->fields('i', ['type', 'sid']);
return $this->query->execute();
}
/**
* Builds the default count query for SearchQuery.
*
* Since SearchQuery always uses GROUP BY, we can default to a subquery. We
* also add the same conditions as execute() because countQuery() is called
* first.
*/
public function countQuery() {
if (!$this->executedPrepare) {
$this->prepareAndNormalize();
}
// Clone the inner query.
$inner = clone $this->query;
// Add conditions to query.
$inner->join('search_dataset', 'd', '[i].[sid] = [d].[sid] AND [i].[type] = [d].[type]');
if (count($this->conditions)) {
$inner->condition($this->conditions);
}
// Remove existing fields and expressions, they are not needed for a count
// query.
$fields =& $inner->getFields();
$fields = [];
$expressions =& $inner->getExpressions();
$expressions = [];
// Add sid as the only field and count them as a subquery.
$count = $this->connection->select($inner->fields('i', ['sid']), NULL);
// Add the COUNT() expression.
$count->addExpression('COUNT(*)');
return $count;
}
/**
* Returns the query status bitmap.
*
* @return int
* A bitmap indicating query status. Zero indicates there were no problems.
* A non-zero value is a combination of one or more of the following flags:
* - SearchQuery::NO_POSITIVE_KEYWORDS
* - SearchQuery::EXPRESSIONS_IGNORED
* - SearchQuery::LOWER_CASE_OR
* - SearchQuery::NO_KEYWORD_MATCHES
*/
public function getStatus() {
return $this->status;
}
}

View File

@ -0,0 +1,190 @@
<?php
namespace Drupal\search;
use Drupal\Component\Transliteration\TransliterationInterface;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
/**
* Processes search text for indexing.
*/
class SearchTextProcessor implements SearchTextProcessorInterface {
/**
* The transliteration service.
*
* @var \Drupal\Component\Transliteration\TransliterationInterface
*/
protected $transliteration;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* SearchTextProcessor constructor.
*
* @param \Drupal\Component\Transliteration\TransliterationInterface $transliteration
* The transliteration service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public function __construct(TransliterationInterface $transliteration, ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler) {
$this->transliteration = $transliteration;
$this->configFactory = $config_factory;
$this->moduleHandler = $module_handler;
}
/**
* {@inheritdoc}
*/
public function process(string $text, ?string $langcode = NULL): array {
$text = $this->analyze($text, $langcode);
return explode(' ', $text);
}
/**
* {@inheritdoc}
*/
public function analyze(string $text, ?string $langcode = NULL): string {
// Decode entities to UTF-8.
$text = Html::decodeEntities($text);
// Lowercase.
$text = mb_strtolower($text);
// Remove diacritics.
$text = $this->transliteration->removeDiacritics($text);
// Call an external processor for word handling.
$this->invokePreprocess($text, $langcode);
// Simple CJK handling.
if ($this->configFactory->get('search.settings')->get('index.overlap_cjk')) {
$text = preg_replace_callback('/[' . self::PREG_CLASS_CJK . ']+/u', [$this, 'expandCjk'], $text);
}
// To improve searching for numerical data such as dates, IP addresses
// or version numbers, we consider a group of numerical characters
// separated only by punctuation characters to be one piece.
// This also means that searching for e.g. '20/03/1984' also returns
// results with '20-03-1984' in them.
// Readable regexp: ([number]+)[punctuation]+(?=[number])
$text = preg_replace('/([' . self::PREG_CLASS_NUMBERS . ']+)[' . self::PREG_CLASS_PUNCTUATION . ']+(?=[' . self::PREG_CLASS_NUMBERS . '])/u', '\1', $text);
// Multiple dot and dash groups are word boundaries and replaced with space.
// No need to use the unicode modifier here because 0-127 ASCII characters
// can't match higher UTF-8 characters as the leftmost bit of those are 1.
$text = preg_replace('/[.-]{2,}/', ' ', $text);
// The dot, underscore and dash are simply removed. This allows meaningful
// search behavior with acronyms and URLs. See unicode note directly above.
$text = preg_replace('/[._-]+/', '', $text);
// With the exception of the rules above, we consider all punctuation,
// marks, spacers, etc, to be a word boundary.
$text = preg_replace('/[' . Unicode::PREG_CLASS_WORD_BOUNDARY . ']+/u', ' ', $text);
// Truncate everything to 50 characters.
$words = explode(' ', $text);
array_walk($words, [$this, 'truncate']);
$text = implode(' ', $words);
return $text;
}
/**
* Invokes hook_search_preprocess() to simplify text.
*
* @param string $text
* Text to preprocess, passed by reference and altered in place.
* @param string|null $langcode
* Language code for the language of $text, if known.
*/
protected function invokePreprocess(string &$text, ?string $langcode = NULL): void {
$this->moduleHandler->invokeAllWith(
'search_preprocess',
function (callable $hook, string $module) use (&$text, &$langcode) {
$text = $hook($text, $langcode);
}
);
}
/**
* Splits CJK (Chinese, Japanese, Korean) text into tokens.
*
* The Search module matches exact words, where a word is defined to be a
* sequence of characters delimited by spaces or punctuation. CJK languages
* are written in long strings of characters, though, not split up into words.
* So in order to allow search matching, we split up CJK text into tokens
* consisting of consecutive, overlapping sequences of characters whose length
* is equal to the 'minimum_word_size' variable. This tokenizing is only done
* if the 'overlap_cjk' variable is TRUE.
*
* @param array $matches
* This function is a callback for preg_replace_callback(), which is called
* from self::analyze(). So, $matches is an array of regular expression
* matches, which means that $matches[0] contains the matched text -- a
* string of CJK characters to tokenize.
*
* @return string
* Tokenized text, starting and ending with a space character.
*/
protected function expandCjk(array $matches): string {
$min = $this->configFactory->get('search.settings')->get('index.minimum_word_size');
$str = $matches[0];
$length = mb_strlen($str);
// If the text is shorter than the minimum word size, don't tokenize it.
if ($length <= $min) {
return ' ' . $str . ' ';
}
$tokens = ' ';
// Build a FIFO queue of characters.
$chars = [];
for ($i = 0; $i < $length; $i++) {
// Add the next character off the beginning of the string to the queue.
$current = mb_substr($str, 0, 1);
$str = substr($str, strlen($current));
$chars[] = $current;
if ($i >= $min - 1) {
// Make a token of $min characters, and add it to the token string.
$tokens .= implode('', $chars) . ' ';
// Shift out the first character in the queue.
array_shift($chars);
}
}
return $tokens;
}
/**
* Helper function for array_walk in ::analyze().
*
* @param string $text
* The text to be truncated.
*/
protected function truncate(string &$text): void {
if (is_numeric($text)) {
$text = ltrim($text, '0');
}
if (mb_strlen($text) <= 50) {
return;
}
$text = mb_substr($text, 0, 50);
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace Drupal\search;
/**
* Processes search text for indexing.
*/
interface SearchTextProcessorInterface {
/**
* Matches all 'N' Unicode character classes (numbers)
*/
const PREG_CLASS_NUMBERS =
'\x{30}-\x{39}\x{b2}\x{b3}\x{b9}\x{bc}-\x{be}\x{660}-\x{669}\x{6f0}-\x{6f9}' .
'\x{966}-\x{96f}\x{9e6}-\x{9ef}\x{9f4}-\x{9f9}\x{a66}-\x{a6f}\x{ae6}-\x{aef}' .
'\x{b66}-\x{b6f}\x{be7}-\x{bf2}\x{c66}-\x{c6f}\x{ce6}-\x{cef}\x{d66}-\x{d6f}' .
'\x{e50}-\x{e59}\x{ed0}-\x{ed9}\x{f20}-\x{f33}\x{1040}-\x{1049}\x{1369}-' .
'\x{137c}\x{16ee}-\x{16f0}\x{17e0}-\x{17e9}\x{17f0}-\x{17f9}\x{1810}-\x{1819}' .
'\x{1946}-\x{194f}\x{2070}\x{2074}-\x{2079}\x{2080}-\x{2089}\x{2153}-\x{2183}' .
'\x{2460}-\x{249b}\x{24ea}-\x{24ff}\x{2776}-\x{2793}\x{3007}\x{3021}-\x{3029}' .
'\x{3038}-\x{303a}\x{3192}-\x{3195}\x{3220}-\x{3229}\x{3251}-\x{325f}\x{3280}-' .
'\x{3289}\x{32b1}-\x{32bf}\x{ff10}-\x{ff19}';
/**
* Matches all 'P' Unicode character classes (punctuation)
*/
const PREG_CLASS_PUNCTUATION =
'\x{21}-\x{23}\x{25}-\x{2a}\x{2c}-\x{2f}\x{3a}\x{3b}\x{3f}\x{40}\x{5b}-\x{5d}' .
'\x{5f}\x{7b}\x{7d}\x{a1}\x{ab}\x{b7}\x{bb}\x{bf}\x{37e}\x{387}\x{55a}-\x{55f}' .
'\x{589}\x{58a}\x{5be}\x{5c0}\x{5c3}\x{5f3}\x{5f4}\x{60c}\x{60d}\x{61b}\x{61f}' .
'\x{66a}-\x{66d}\x{6d4}\x{700}-\x{70d}\x{964}\x{965}\x{970}\x{df4}\x{e4f}' .
'\x{e5a}\x{e5b}\x{f04}-\x{f12}\x{f3a}-\x{f3d}\x{f85}\x{104a}-\x{104f}\x{10fb}' .
'\x{1361}-\x{1368}\x{166d}\x{166e}\x{169b}\x{169c}\x{16eb}-\x{16ed}\x{1735}' .
'\x{1736}\x{17d4}-\x{17d6}\x{17d8}-\x{17da}\x{1800}-\x{180a}\x{1944}\x{1945}' .
'\x{2010}-\x{2027}\x{2030}-\x{2043}\x{2045}-\x{2051}\x{2053}\x{2054}\x{2057}' .
'\x{207d}\x{207e}\x{208d}\x{208e}\x{2329}\x{232a}\x{23b4}-\x{23b6}\x{2768}-' .
'\x{2775}\x{27e6}-\x{27eb}\x{2983}-\x{2998}\x{29d8}-\x{29db}\x{29fc}\x{29fd}' .
'\x{3001}-\x{3003}\x{3008}-\x{3011}\x{3014}-\x{301f}\x{3030}\x{303d}\x{30a0}' .
'\x{30fb}\x{fd3e}\x{fd3f}\x{fe30}-\x{fe52}\x{fe54}-\x{fe61}\x{fe63}\x{fe68}' .
'\x{fe6a}\x{fe6b}\x{ff01}-\x{ff03}\x{ff05}-\x{ff0a}\x{ff0c}-\x{ff0f}\x{ff1a}' .
'\x{ff1b}\x{ff1f}\x{ff20}\x{ff3b}-\x{ff3d}\x{ff3f}\x{ff5b}\x{ff5d}\x{ff5f}-' .
'\x{ff65}';
/**
* Matches CJK (Chinese, Japanese, Korean) letter-like characters.
*
* This list is derived from the "East Asian Scripts" section of
* http://www.unicode.org/charts/index.html, as well as a comment on
* http://unicode.org/reports/tr11/tr11-11.html listing some character
* ranges that are reserved for additional CJK ideographs.
*
* The character ranges do not include numbers, punctuation, or symbols, since
* these are handled separately in search. Note that radicals and strokes are
* considered symbols. (See
* http://www.unicode.org/Public/UNIDATA/extracted/DerivedGeneralCategory.txt)
*
* @see \Drupal\search\SearchTextProcessor::expandCjk()
*/
const PREG_CLASS_CJK =
'\x{1100}-\x{11FF}\x{3040}-\x{309F}\x{30A1}-\x{318E}' .
'\x{31A0}-\x{31B7}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FCF}' .
'\x{A000}-\x{A48F}\x{A4D0}-\x{A4FD}\x{A960}-\x{A97F}\x{AC00}-\x{D7FF}' .
'\x{F900}-\x{FAFF}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF66}-\x{FFDC}' .
'\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}';
/**
* Processes text into words for indexing.
*
* @param string $text
* Text to process.
* @param string|null $langcode
* Language code for the language of $text, if known.
*
* @return array
* Array of words in the simplified, preprocessed text.
*
* @see \Drupal\search\SearchTextProcessorInterface::analyze()
*/
public function process(string $text, ?string $langcode = NULL): array;
/**
* Runs the text through character analyzers in preparation for indexing.
*
* Processing steps:
* - Entities are decoded.
* - Text is lower-cased and diacritics (accents) are removed.
* - hook_search_preprocess() is invoked.
* - CJK (Chinese, Japanese, Korean) characters are processed, depending on
* the search settings.
* - Punctuation is processed (removed or replaced with spaces, depending on
* where it is; see code for details).
* - Words are truncated to 50 characters maximum.
*
* @param string $text
* Text to simplify.
* @param string|null $langcode
* (optional) Language code for the language of $text, if known.
*
* @return string
* Simplified and processed text.
*
* @see hook_search_preprocess()
*/
public function analyze(string $text, ?string $langcode = NULL): string;
}

View File

@ -0,0 +1,88 @@
<?php
namespace Drupal\search;
use Drupal\Core\Database\Query\ConditionInterface;
/**
* Extends the core SearchQuery to be able to gets its protected values.
*/
class ViewsSearchQuery extends SearchQuery {
/**
* Returns the conditions property.
*
* @return array
* The query conditions.
*/
public function &conditions() {
return $this->conditions;
}
/**
* Returns the words property.
*
* @return array
* The positive search keywords.
*/
public function words() {
return $this->words;
}
/**
* Returns the simple property.
*
* @return bool
* TRUE if it is a simple query, and FALSE if it is complicated (phrases
* or LIKE).
*/
public function simple() {
return $this->simple;
}
/**
* Returns the matches property.
*
* @return int
* The number of matches needed.
*/
public function matches() {
return $this->matches;
}
/**
* Executes and returns the protected parseSearchExpression method.
*/
public function publicParseSearchExpression() {
return $this->parseSearchExpression();
}
/**
* Replaces the original condition with a custom one from views recursively.
*
* @param string $search
* The searched value.
* @param string $replace
* The value which replaces the search value.
* @param array $condition
* The query conditions array in which the string is replaced. This is an
* item from a \Drupal\Core\Database\Query\Condition::conditions array,
* which must have a 'field' element.
*/
public function conditionReplaceString($search, $replace, &$condition) {
if ($condition['field'] instanceof ConditionInterface) {
$conditions =& $condition['field']->conditions();
foreach ($conditions as $key => &$subcondition) {
if (is_numeric($key)) {
// As conditions can be nested, the function has to be called
// recursively.
$this->conditionReplaceString($search, $replace, $subcondition);
}
}
}
else {
$condition['field'] = str_replace($search, $replace, $condition['field']);
}
}
}