first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
---
label: 'Configuring search pages'
related:
- search.overview
- search.index
---
{% set search_settings_link_text %}{% trans %}Search pages{% endtrans %}{% endset %}
{% set search_settings_link = render_var(help_route_link(search_settings_link_text, 'entity.search_page.collection')) %}
{% set search_index_topic = render_var(help_topic_link('search.index')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Configure one or more search pages.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>Search and Metadata</em> &gt; <em>{{ search_settings_link }}</em>.{% endtrans %}</li>
<li>{% trans %}Scroll down to the <em>Search pages</em> section. You will see a list of the already-configured search pages on your site.{% endtrans %}</li>
<li>{% trans %}To configure an existing search page, click <em>Edit</em>. Or, to add a new search page, select the <em>Search page type</em> and click <em>Add search page</em>.{% endtrans %}</li>
<li>{% trans %}Enter the desired <em>Label</em> name and URL <em>Path</em> for the search page.{% endtrans %}</li>
<li>{% trans %}For <em>Content</em> search pages, select the desired level of influence in ranking search results of the available <em>Content ranking</em> factors.{% endtrans %}</li>
<li>{% trans %}Click <em>Save</em>. You will be returned to the <em>Search pages</em> page.{% endtrans %}</li>
<li>{% trans %}Verify that the correct search page is listed as <em>Default</em> in the <em>Status</em> column. If not, click <em>Set as default</em> in the <em>Operations</em> list for the correct search page.{% endtrans %}</li>
<li>{% trans %}Optionally, disable or delete any search pages that you do not want to have available on the site (disabling is temporary, while deleting is permanent).{% endtrans %}</li>
<li>{% trans %}Follow the steps in {{ search_index_topic }} to make sure that the search index is updated.{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,24 @@
---
label: 'Managing the search index'
related:
- search.overview
- search.configuring
---
{% set cron_topic = render_var(help_topic_link('core.cron')) %}
{% set search_settings_link_text %}{% trans %}Search pages{% endtrans %}{% endset %}
{% set search_settings_link = render_var(help_route_link(search_settings_link_text, 'entity.search_page.collection')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Manage the search index, and make sure that the site is fully indexed for searching.{% endtrans %}</p>
<h2>{% trans %}What is the search index?{% endtrans %}</h2>
<p>{% trans %}The <em>Content</em> and <em>Help</em> search types provided by the core software pre-index their content and store the results in several database tables that are collectively called the <em>search index</em>. The process of indexing renders the content and breaks it up into words, which can then be matched more efficiently with keyword queries when users perform searches. Search indexing happens during cron runs; see {{ cron_topic }} for more information about cron.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>Search and Metadata</em> &gt; <em>{{ search_settings_link }}</em>.{% endtrans %}</li>
<li>{% trans %}Under <em>Indexing throttle</em>, select the <em>Number of items to index per cron run</em>. A smaller number will make cron faster and reduce the possibility of timeout; a larger number will make sure more of your site is indexed in fewer cron runs.{% endtrans %}</li>
<li>{% trans %}Under <em>Default indexing settings</em>, enter the desired <em>Minimum word length to index</em>. Words smaller than this length will be dropped from both keywords when searching and content when indexing.{% endtrans %}</li>
<li>{% trans %}If your site uses Chinese, Japanese, or Korean languages, optionally check <em>Simple CJK handling</em> under <em>Default indexing settings</em> to provide some support for these languages.{% endtrans %}</li>
<li>{% trans %}Click <em>Save configuration</em>, and you should be returned to the <em>Search pages</em> page.{% endtrans %}</li>
<li>{% trans %}Click <em>Re-index site</em> if you have changed the indexing configuration, or later on, if you believe that the search index has been corrupted.{% endtrans %}</li>
<li>{% trans %}Follow the steps in {{ cron_topic }} to make sure a cron task has been configured.{% endtrans %}</li>
<li>{% trans %}After waiting for cron to run several times, verify that the content has been fully indexed for searching. The overall status is listed under <em>Indexing progress</em>, and the status for each search page is shown in the <em>Indexing progress</em> column of the <em>Search pages</em> section.{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,36 @@
---
label: 'Configuring site search'
top_level: true
related:
- user.permissions
- block.place
---
{% set search_link_text %}{% trans %}Search{% endtrans %}{% endset %}
{% set search_link = render_var(help_route_link(search_link_text, 'search.view')) %}
{% set user_overview_topic = render_var(help_topic_link('user.overview')) %}
<h2>{% trans %}What are search pages?{% endtrans %}</h2>
<p>{% trans %}The core Search module organizes site search into <em>pages</em>. Each page allows users to search a particular type of content with a particular configuration. The configuration includes specifying a URL that starts with <em>search</em>, a name for the page, and additional options for some search page types.{% endtrans %}</p>
<p>{% trans %}When users visit the main search page (see {{ search_link }}), they will see the configured search pages that they have access to. Each search page has a search form on it, and the page will display search results after the user enters keywords into the form and clicks the search button.{% endtrans %}</p>
<h2>{% trans %}What modules provide site search?{% endtrans %}</h2>
<p>{% trans %}The core Search module provides the ability to configure search pages; search page types are provided by both core and contributed modules. The core modules that provide search page types are:{% endtrans %}</p>
<ul>
<li>{% trans %}The Node module, for searching content pages{% endtrans %}</li>
<li>{% trans %}The User module, for searching user profiles{% endtrans %}</li>
<li>{% trans %}The Help module, for searching help topics{% endtrans %}</li>
</ul>
<p>{% trans %}As an alternative to the core Search module's system of search pages, you can use contributed modules to provide site search. For example, the <a href="https://www.drupal.org/project/apachesolr">Apache Solr</a> and <a href="https://www.drupal.org/project/sphinx">Sphinx</a> contributed modules use third-party technology to provide site search.{% endtrans %}</p>
<h2>{% trans %}What are the limitations of the core Search module?{% endtrans %}</h2>
<p>{% trans %}There are two main limitations of the core Search module. First, it is not appropriate for very large sites -- if you have a large site, look into other search technologies like Apache Solr. Second, the Node search page type only supports exact keyword matching, which is not the behavior that most users will expect. You can improve this by installing a language-specific stemming module for your language (such as <a href="https://www.drupal.org/project/porterstemmer">Porter Stemmer</a> for American English), which makes it so that, for example, a search for the word walk would match pages containing the words walk, walking, and walked.{% endtrans %}</p>
<h2>{% trans %}What are the search permissions?{% endtrans %}</h2>
<ul>
<li>{% trans %}Users with <em>Use search</em> permission can use the <em>Search form</em> block and <em>Search</em> page; this permission is required for any search configured in the core Search module.{% endtrans %}</li>
<li>{% trans %}In addition to <em>Use search</em>, <em>View user information</em> permission is needed for searching users.{% endtrans %}</li>
<li>{% trans %}In addition to <em>Use search</em>, <em>View published content</em> permission is needed for searching content.{% endtrans %}</li>
<li>{% trans %}Users with <em>Use advanced search</em> permission can use more complex search filtering when performing content searches.{% endtrans %}</li>
</ul>
<h2>{% trans %}Configuring site search overview{% endtrans %}</h2>
<p>{% trans %}In order to configure site search using the core Search module, you will need to configure one or more search pages. You will also need to verify or alter permissions so that the desired user roles can search the site. (See {{ user_overview_topic }} for more information about roles and permissions.) For content search, you will also need to make sure that the search index is configured and that the site is fully indexed. Finally, you may wish to place the <em>Search form</em> block on pages of your site, or add the search page to a navigation menu, to give users easy access to search. See the related topics listed below for specific tasks.{% endtrans %}</p>
<h2>{% trans %}Additional resources{% endtrans %}</h2>
<ul>
<li>{% trans %}<a href="https://www.drupal.org/documentation/modules/search">Online documentation for the Search module</a>{% endtrans %}</li>
</ul>

View File

@@ -0,0 +1,22 @@
id: d6_search_settings
label: Search configuration
migration_tags:
- Drupal 6
- Configuration
source:
plugin: variable
constants:
status: true
variables:
- minimum_word_size
- overlap_cjk
- search_cron_limit
source_module: search
process:
'index/minimum_word_size': minimum_word_size
'index/overlap_cjk': overlap_cjk
'index/cron_limit': search_cron_limit
logging: 'constants/status'
destination:
plugin: config
config_name: search.settings

View File

@@ -0,0 +1,50 @@
id: d7_search_page
label: Search page configuration
migration_tags:
- Drupal 7
- Configuration
source:
plugin: d7_search_page
variables:
- node_rank_comments
- node_rank_promote
- node_rank_relevance
- node_rank_sticky
- node_rank_views
constants:
suffix: _search
process:
module: module
module_exists:
-
plugin: skip_on_empty
method: row
source: module_exists
status:
-
plugin: static_map
source: status
map:
node: true
user: true
default_value: false
id:
-
plugin: concat
source:
- module
- 'constants/suffix'
plugin:
-
plugin: concat
source:
- module
- 'constants/suffix'
path: module
configuration:
plugin: default_value
default_value: [ ]
'configuration/rankings':
plugin: search_configuration_rankings
destination:
plugin: entity:search_page

View File

@@ -0,0 +1,34 @@
id: d7_search_settings
label: Search configuration
migration_tags:
- Drupal 7
- Configuration
source:
plugin: variable
constants:
status: true
variables_no_row_if_missing:
- minimum_word_size
- overlap_cjk
- search_cron_limit
- search_tag_weights
- search_and_or_limit
- search_default_module
source_module: search
process:
'index/minimum_word_size': minimum_word_size
'index/overlap_cjk': overlap_cjk
'index/cron_limit': search_cron_limit
'index/tag_weights': search_tag_weights
and_or_limit: search_and_or_limit
logging: 'constants/status'
default_page:
plugin: static_map
source:
- search_default_module
map:
node: node_search
user: user_search
destination:
plugin: config
config_name: search.settings

View File

@@ -0,0 +1,30 @@
id: search_page
label: Search page configuration
migration_tags:
- Drupal 6
- Configuration
source:
plugin: d6_search_page
variables:
- node_rank_comments
- node_rank_promote
- node_rank_recent
- node_rank_relevance
- node_rank_sticky
- node_rank_views
constants:
id: node_search
path: node
plugin: node_search
process:
module: module
id: 'constants/id'
path: 'constants/path'
plugin: 'constants/plugin'
configuration:
plugin: default_value
default_value: [ ]
'configuration/rankings':
plugin: search_configuration_rankings
destination:
plugin: entity:search_page

View File

@@ -0,0 +1,5 @@
finished:
6:
search: search
7:
search: search

View File

@@ -0,0 +1,84 @@
<?php
/**
* @file
* Hooks provided by the Search module.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Preprocess text for search.
*
* This hook is called to preprocess both the text added to the search index
* and the keywords users have submitted for searching. The same processing
* needs to be applied to both so that searches will find matches.
*
* Possible uses:
* - Adding spaces between words of Chinese or Japanese text.
* - Stemming words down to their root words to allow matches between, for
* instance, walk, walked, walking, and walks in searching.
* - Expanding abbreviations and acronyms that occur in text.
*
* @param string $text
* The text to preprocess. This is a single piece of plain text extracted
* from between two HTML tags or from the search query. It will not contain
* any HTML entities or HTML tags.
* @param string|null $langcode
* The language code for the language the text is in, if known. When this hook
* is invoked during search indexing, the language will most likely be known
* and passed in. This is left up to the search plugin;
* \Drupal\node\Plugin\Search\NodeSearch does pass in the node
* language. However, when this hook is invoked during searching, in order to
* let a module apply the same preprocessing to the search keywords and
* indexed text so they will match, $langcode will be NULL. A hook
* implementation can call the getCurrentLanguage() method on the
* 'language_manager' service to determine the current language and act
* accordingly.
*
* @return string
* The text after preprocessing. Note that if your module decides not to
* alter the text, it should return the original text. Also, after
* preprocessing, words in the text should be separated by a space.
*
* @ingroup search
*/
function hook_search_preprocess($text, $langcode = NULL) {
// If the language is not set, get it from the language manager.
if (!isset($langcode)) {
$langcode = \Drupal::languageManager()->getCurrentLanguage()->getId();
}
// If the langcode is set to 'en' then add variations of the word "testing"
// which can also be found during English language searches.
if ($langcode == 'en') {
// Add the alternate verb forms for the word "testing".
if ($text == 'we are testing') {
$text .= ' test tested';
}
}
return $text;
}
/**
* Alter search plugin definitions.
*
* @param array $definitions
* The array of search plugin definitions, keyed by plugin ID.
*
* @see \Drupal\search\Annotation\SearchPlugin
* @see \Drupal\search\SearchPluginManager
*/
function hook_search_plugin_alter(array &$definitions) {
if (isset($definitions['node_search'])) {
$definitions['node_search']['title'] = t('Nodes');
}
}
/**
* @} End of "addtogroup hooks".
*/

View File

@@ -0,0 +1,11 @@
name: Search
type: module
description: 'Allows users to create search pages based on plugins provided by other modules.'
package: Core
# version: VERSION
configure: entity.search_page.collection
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,156 @@
<?php
/**
* @file
* Install, update, and uninstall functions for the Search module.
*/
/**
* Implements hook_schema().
*/
function search_schema() {
$schema['search_dataset'] = [
'description' => 'Stores items that will be searched.',
'fields' => [
'sid' => [
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'description' => 'Search item ID, e.g. node ID for nodes.',
],
'langcode' => [
'type' => 'varchar_ascii',
'length' => '12',
'not null' => TRUE,
'description' => 'The {languages}.langcode of the item variant.',
'default' => '',
],
'type' => [
'type' => 'varchar_ascii',
'length' => 64,
'not null' => TRUE,
'description' => 'Type of item, e.g. node.',
],
'data' => [
'type' => 'text',
'not null' => TRUE,
'size' => 'big',
'description' => 'List of space-separated words from the item.',
],
'reindex' => [
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'description' => 'Set to force node reindexing.',
],
],
'primary key' => ['sid', 'langcode', 'type'],
];
$schema['search_index'] = [
'description' => 'Stores the search index, associating words, items and scores.',
'fields' => [
'word' => [
'type' => 'varchar',
'length' => 50,
'not null' => TRUE,
'default' => '',
'description' => 'The {search_total}.word that is associated with the search item.',
],
'sid' => [
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'description' => 'The {search_dataset}.sid of the searchable item to which the word belongs.',
],
'langcode' => [
'type' => 'varchar_ascii',
'length' => '12',
'not null' => TRUE,
'description' => 'The {languages}.langcode of the item variant.',
'default' => '',
],
'type' => [
'type' => 'varchar_ascii',
'length' => 64,
'not null' => TRUE,
'description' => 'The {search_dataset}.type of the searchable item to which the word belongs.',
],
'score' => [
'type' => 'float',
'not null' => FALSE,
'description' => 'The numeric score of the word, higher being more important.',
],
],
'indexes' => [
'sid_type' => ['sid', 'langcode', 'type'],
],
'foreign keys' => [
'search_dataset' => [
'table' => 'search_dataset',
'columns' => [
'sid' => 'sid',
'langcode' => 'langcode',
'type' => 'type',
],
],
],
'primary key' => ['word', 'sid', 'langcode', 'type'],
];
$schema['search_total'] = [
'description' => 'Stores search totals for words.',
'fields' => [
'word' => [
'description' => 'Primary Key: Unique word in the search index.',
'type' => 'varchar',
'length' => 50,
'not null' => TRUE,
'default' => '',
],
'count' => [
'description' => "The count of the word in the index using Zipf's law to equalize the probability distribution.",
'type' => 'float',
'not null' => FALSE,
],
],
'primary key' => ['word'],
];
return $schema;
}
/**
* Implements hook_requirements().
*
* For the Status Report, return information about search index status.
*/
function search_requirements($phase) {
$requirements = [];
if ($phase == 'runtime') {
$remaining = 0;
$total = 0;
$search_page_repository = \Drupal::service('search.search_page_repository');
foreach ($search_page_repository->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' => t('Search index progress'),
'value' => t('@percent% (@remaining remaining)', ['@percent' => $percent, '@remaining' => $remaining]),
'severity' => REQUIREMENT_INFO,
];
}
return $requirements;
}

View File

@@ -0,0 +1,10 @@
search.view:
title: Search
route_name: search.view
enabled: 0
entity.search_page.collection:
title: 'Search pages'
parent: system.admin_config_search
description: 'Configure search pages and search indexing options.'
route_name: entity.search_page.collection
weight: -10

View File

@@ -0,0 +1,3 @@
search.plugins:
class: \Drupal\Core\Menu\LocalTaskDefault
deriver: \Drupal\search\Plugin\Derivative\SearchLocalTask

420
core/modules/search/search.module Executable file
View File

@@ -0,0 +1,420 @@
<?php
/**
* @file
* Enables site-wide keyword searching.
*/
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Drupal\search\SearchTextProcessorInterface;
/**
* Implements hook_help().
*/
function search_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.search':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . 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>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Configuring search pages') . '</dt>';
$output .= '<dd>' . 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>' . t('Managing the search index') . '</dt>';
$output .= '<dd>' . 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>' . 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>' . t('Displaying the Search block') . '</dt>';
$output .= '<dd>' . 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>' . t('Searching your site') . '</dt>';
$output .= '<dd>' . 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>' . t('Extending the Search module') . '</dt>';
$output .= '<dd>' . 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;
}
}
/**
* Implements hook_theme().
*/
function search_theme() {
return [
'search_result' => [
'variables' => ['result' => NULL, 'plugin_id' => NULL],
'file' => 'search.pages.inc',
],
];
}
/**
* Implements hook_theme_suggestions_HOOK().
*/
function search_theme_suggestions_search_result(array $variables) {
return ['search_result__' . $variables['plugin_id']];
}
/**
* Implements hook_preprocess_HOOK() for block templates.
*/
function search_preprocess_block(&$variables) {
if ($variables['plugin_id'] == 'search_form_block') {
$variables['attributes']['role'] = 'search';
}
}
/**
* Implements hook_cron().
*
* Fires updateIndex() in the plugins for all indexable active search pages,
* and cleans up dirty words.
*/
function search_cron() {
/** @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();
}
}
/**
* @defgroup search Search interface
* @{
* The Drupal search interface manages a global search mechanism.
*
* Modules may plug into this system to provide searches of different types of
* data. Most of the system is handled by the Search module, so this must be
* enabled for all of the search features to work.
*
* There are two ways to interact with the search system:
* - Specifically for searching nodes, you can implement
* hook_node_update_index() and hook_node_search_result(). However, note that
* the search system already indexes all visible output of a node; i.e.,
* everything displayed normally during node viewing. This is
* usually sufficient. You should only use this mechanism if you want
* additional, non-visible data to be indexed.
* - Define a plugin implementing \Drupal\search\Plugin\SearchInterface and
* annotated as \Drupal\search\Annotation\SearchPlugin. This will create a
* search page type that users can use to set up one or more search pages.
* Each of these corresponds to a tab on the /search page, which can be
* used to perform searches. You will also need to implement the execute()
* method from the interface to perform the search. A base class is provided
* in \Drupal\search\Plugin\SearchPluginBase. For more information about
* plugins, see the @link plugin_api Plugin API topic. @endlink
*
* If your module needs to provide a more complicated search form, then you
* need to implement it yourself. In that case, you may wish to define it as a
* local task (tab) under the /search page (e.g. /search/my_module) so that users
* can easily find it.
*
* @see plugin_api
* @see annotation
*/
/**
* Returns snippets from a piece of text, with search keywords highlighted.
*
* Used for formatting search results. All HTML tags will be stripped from
* $text.
*
* @param string $keys
* A string containing a search query.
* @param string $text
* The text to extract fragments from.
* @param string|null $langcode
* Language code for the language of $text, if known.
*
* @return array
* A render array containing HTML for the excerpt.
*/
function search_excerpt($keys, $text, $langcode = NULL) {
// We highlight around non-indexable or CJK characters.
$boundary_character = '[' . Unicode::PREG_CLASS_WORD_BOUNDARY . SearchTextProcessorInterface::PREG_CLASS_CJK . ']';
$preceded_by_boundary = '(?<=' . $boundary_character . ')';
$followed_by_boundary = '(?=' . $boundary_character . ')';
// Extract positive keywords and phrases.
preg_match_all('/ ("([^"]+)"|(?!OR)([^" ]+))/', ' ' . $keys, $matches);
$keys = array_merge($matches[2], $matches[3]);
// Prepare text by stripping HTML tags and decoding HTML entities.
$text = strip_tags(str_replace(['<', '>'], [' <', '> '], $text));
$text = Html::decodeEntities($text);
$text_length = strlen($text);
// Make a list of unique keywords that are actually found in the text,
// which could be items in $keys or replacements that are equivalent through
// \Drupal\search\SearchTextProcessorInterface::analyze().
$temp_keys = [];
foreach ($keys as $key) {
$key = _search_find_match_with_simplify($key, $text, $boundary_character, $langcode);
if (isset($key)) {
// Quote slashes so they can be used in regular expressions.
$temp_keys[] = preg_quote($key, '/');
}
}
// Several keywords could have simplified down to the same thing, so pick
// out the unique ones.
$keys = array_unique($temp_keys);
// Extract fragments of about 60 characters around keywords, bounded by word
// boundary characters. Try to reach 256 characters, using second occurrences
// if necessary.
$ranges = [];
$length = 0;
$look_start = [];
$remaining_keys = $keys;
while ($length < 256 && !empty($remaining_keys)) {
$found_keys = [];
foreach ($remaining_keys as $key) {
if ($length >= 256) {
break;
}
// Remember where we last found $key, in case we are coming through a
// second time.
if (!isset($look_start[$key])) {
$look_start[$key] = 0;
}
// See if we can find $key after where we found it the last time. Since
// we are requiring a match on a word boundary, make sure $text starts
// and ends with a space.
$matches = [];
if (preg_match('/' . $preceded_by_boundary . $key . $followed_by_boundary . '/iu', ' ' . $text . ' ', $matches, PREG_OFFSET_CAPTURE, $look_start[$key])) {
$found_position = $matches[0][1];
$look_start[$key] = $found_position + 1;
// Keep track of which keys we found this time, in case we need to
// pass through again to find more text.
$found_keys[] = $key;
// Locate a space before and after this match, leaving about 60
// characters of context on each end.
$before = strpos(' ' . $text, ' ', max(0, $found_position - 61));
if ($before !== FALSE && $before <= $found_position) {
if ($text_length > $found_position + 60) {
$after = strrpos(substr($text, 0, $found_position + 60), ' ', $found_position);
}
else {
$after = $text_length;
}
if ($after !== FALSE && $after > $found_position) {
// Account for the spaces we added.
$before = max($before - 1, 0);
if ($before < $after) {
// Save this range.
$ranges[$before] = $after;
$length += $after - $before;
}
}
}
}
}
// Next time through this loop, only look for keys we found this time,
// if any.
$remaining_keys = $found_keys;
}
if (empty($ranges)) {
// We didn't find any keyword matches, so just return the first part of the
// text. We also need to re-encode any HTML special characters that we
// entity-decoded above.
return [
'#plain_text' => Unicode::truncate($text, 256, TRUE, TRUE),
];
}
// Sort the text ranges by starting position.
ksort($ranges);
// Collapse overlapping text ranges into one. The sorting makes it O(n).
$new_ranges = [];
$max_end = 0;
foreach ($ranges as $this_from => $this_to) {
$max_end = max($max_end, $this_to);
if (!isset($working_from)) {
// This is the first time through this loop: initialize.
$working_from = $this_from;
$working_to = $this_to;
continue;
}
if ($this_from <= $working_to) {
// The ranges overlap: combine them.
$working_to = max($working_to, $this_to);
}
else {
// The ranges do not overlap: save the working range and start a new one.
$new_ranges[$working_from] = $working_to;
$working_from = $this_from;
$working_to = $this_to;
}
}
// Save the remaining working range.
$new_ranges[$working_from] = $working_to;
// Fetch text within the combined ranges we found.
$out = [];
foreach ($new_ranges as $from => $to) {
$out[] = substr($text, $from, $to - $from);
}
// Combine the text chunks with "…" separators. The "…" needs to be
// translated. Let translators have the … separator text as one chunk.
$ellipses = explode('@excerpt', t('… @excerpt … @excerpt …'));
$text = (isset($new_ranges[0]) ? '' : $ellipses[0]) . implode($ellipses[1], $out) . (($max_end < strlen($text) - 1) ? $ellipses[2] : '');
$text = Html::escape($text);
// Highlight keywords. Must be done at once to prevent conflicts ('strong'
// and '<strong>').
$text = trim(preg_replace('/' . $preceded_by_boundary . '(?:' . implode('|', $keys) . ')' . $followed_by_boundary . '/iu', '<strong>\0</strong>', ' ' . $text . ' '));
return [
'#markup' => $text,
'#allowed_tags' => ['strong'],
];
}
/**
* @} End of "defgroup search".
*/
/**
* Finds an appropriate keyword in text.
*
* @param string $key
* The keyword to find.
* @param string $text
* The text to search for the keyword.
* @param string $boundary
* Regular expression for the boundary character class (characters that
* indicate spaces between words).
* @param string|null $langcode
* Language code for the language of $text, if known.
*
* @return string|null
* A segment of $text that is between word boundary characters that either
* matches $key directly, or matches $key when both this text segment and
* $key are processed by
* \Drupal\search\SearchTextProcessorInterface::analyze(). If a matching text
* segment is not located, NULL is returned.
*/
function _search_find_match_with_simplify($key, $text, $boundary, $langcode = NULL) {
$preceded_by_boundary = '(?<=' . $boundary . ')';
$followed_by_boundary = '(?=' . $boundary . ')';
// See if $key appears as-is. When testing, make sure $text starts/ends with
// a space, because we require $key to be surrounded by word boundary
// characters.
$temp = trim($key);
if ($temp == '') {
return NULL;
}
if (preg_match('/' . $preceded_by_boundary . preg_quote($temp, '/') . $followed_by_boundary . '/iu', ' ' . $text . ' ')) {
return $temp;
}
// See if there is a match after lower-casing and removing diacritics in
// both, which should preserve the string length.
$new_text = mb_strtolower($text);
$new_text = \Drupal::service('transliteration')->removeDiacritics($new_text);
$new_key = mb_strtolower($temp);
$new_key = \Drupal::service('transliteration')->removeDiacritics($new_key);
if (preg_match('/' . $preceded_by_boundary . preg_quote($new_key, '/') . $followed_by_boundary . '/u', ' ' . $new_text . ' ')) {
$position = mb_strpos($new_text, $new_key);
return mb_substr($text, $position, mb_strlen($new_key));
}
// Run both text and key through text processor.
/** @var \Drupal\search\SearchTextProcessorInterface $text_processor */
$text_processor = \Drupal::service('search.text_processor');
$simplified_key = trim($text_processor->analyze($key, $langcode));
$simplified_text = trim($text_processor->analyze($text, $langcode));
if ($simplified_key == '' || $simplified_text == '' || !str_contains($simplified_text, $simplified_key)) {
// The simplified keyword and text do not match at all, or are empty.
return NULL;
}
// Split $text into words, keeping track of where the word boundaries are.
$words = preg_split('/' . $boundary . '+/u', $text, -1, PREG_SPLIT_OFFSET_CAPTURE);
// Add an entry pointing to the end of the string, for the loop below.
$words[] = ['', strlen($text)];
// Using a binary search, find the earliest possible ending position in
// $text where it will still match the keyword after applying
// \Drupal\search\SearchTextProcessorInterface::analyze().
$start_index = 0;
$start_pos = $words[$start_index][1];
$min_end_index = 1;
$max_end_index = count($words) - 1;
while ($max_end_index > $min_end_index) {
// Check the index half way between min and max. See if we ended there,
// if we would still have a match.
$proposed_end_index = floor(($max_end_index + $min_end_index) / 2);
$proposed_end_pos = $words[$proposed_end_index][1];
// Since the split was done with preg_split(), the positions are byte counts
// not character counts, so use substr() not mb_substr() here.
$trial_text = trim($text_processor->analyze(substr($text, $start_pos, $proposed_end_pos - $start_pos), $langcode));
if (str_contains($trial_text, $simplified_key)) {
// The proposed endpoint is fine, text still matches.
$max_end_index = $proposed_end_index;
}
else {
// The proposed endpoint index is too early, so the earliest possible
// OK ending point would be the next index.
$min_end_index = $proposed_end_index + 1;
}
}
// Now do the same for the starting position: using a binary search, find the
// latest possible starting position in $text where it will still match the
// keyword after applying
// \Drupal\search\SearchTextProcessorInterface::analyze().
$end_index = $min_end_index;
$end_pos = $words[$end_index][1];
$min_start_index = 0;
$max_start_index = $end_index - 1;
while ($max_start_index > $min_start_index) {
// Check the index half way between min and max. See if we started there,
// if we would still have a match.
$proposed_start_index = ceil(($max_start_index + $min_start_index) / 2);
$proposed_start_pos = $words[$proposed_start_index][1];
// Since the split was done with preg_split(), the positions are byte counts
// not character counts, so use substr() not mb_substr() here.
$trial_text = trim($text_processor->analyze(substr($text, $proposed_start_pos, $end_pos - $proposed_start_pos), $langcode));
if (str_contains($trial_text, $simplified_key)) {
// The proposed start point is fine, text still matches.
$min_start_index = $proposed_start_index;
}
else {
// The proposed start point index is too late, so the latest possible
// OK starting point would be the previous index.
$max_start_index = $proposed_start_index - 1;
}
}
$start_index = $max_start_index;
// Return the matching text. We need to use substr() here and not the
// mb_substr() function, because the indices in $words came from preg_split(),
// so they are Unicode-safe byte positions, not character positions.
return trim(substr($text, $words[$start_index][1], $words[$end_index][1] - $words[$start_index][1]));
}
/**
* 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
*/
function search_form_search_block_form_alter(&$form, FormStateInterface $form_state) {
$form['form_build_id']['#access'] = FALSE;
$form['form_token']['#access'] = FALSE;
$form['form_id']['#access'] = FALSE;
}

View File

@@ -0,0 +1,60 @@
<?php
/**
* @file
* User page callbacks for the Search module.
*/
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Language\LanguageInterface;
/**
* Prepares variables for individual search result templates.
*
* Default template: search-result.html.twig
*
* @param array $variables
* An array with the following elements:
* - result: Individual search result.
* - plugin_id: Plugin the search results came from.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the main title tag that appears in the template.
* - title_attributes: HTML attributes for the title.
* - content_attributes: HTML attributes for the content.
*/
function template_preprocess_search_result(&$variables) {
$language_interface = \Drupal::languageManager()->getCurrentLanguage();
$result = $variables['result'];
$variables['url'] = UrlHelper::stripDangerousProtocols($result['link']);
$variables['title'] = $result['title'];
if (isset($result['langcode']) && $result['langcode'] != $language_interface->getId() && $result['langcode'] != LanguageInterface::LANGCODE_NOT_SPECIFIED) {
$variables['title_attributes']['lang'] = $result['langcode'];
$variables['content_attributes']['lang'] = $result['langcode'];
}
$info = [];
if (!empty($result['plugin_id'])) {
$info['plugin_id'] = $result['plugin_id'];
}
if (!empty($result['user'])) {
$info['user'] = $result['user'];
}
if (!empty($result['date'])) {
$info['date'] = \Drupal::service('date.formatter')->format($result['date'], 'short');
}
if (isset($result['extra']) && is_array($result['extra'])) {
$info = array_merge($info, $result['extra']);
}
// Check for existence. User search does not include snippets.
$variables['snippet'] = $result['snippet'] ?? '';
// Provide separated and grouped meta information..
$variables['info_split'] = $info;
$variables['info'] = [
'#type' => 'inline_template',
'#template' => '{{ info|safe_join(" - ") }}',
'#context' => ['info' => $info],
];
}

View File

@@ -0,0 +1,6 @@
administer search:
title: 'Administer search'
search content:
title: 'Use search'
use advanced search:
title: 'Use advanced search'

View File

@@ -0,0 +1,16 @@
<?php
/**
* @file
* Post update functions for Search module.
*/
/**
* Implements hook_removed_post_updates().
*/
function search_removed_post_updates() {
return [
'search_post_update_block_page' => '9.0.0',
'search_post_update_reindex_after_diacritics_rule_change' => '10.0.0',
];
}

View File

@@ -0,0 +1,68 @@
entity.search_page.collection:
path: '/admin/config/search/pages'
defaults:
_entity_list: 'search_page'
_title: 'Search pages'
requirements:
_permission: 'administer search'
search.reindex_confirm:
path: '/admin/config/search/pages/reindex'
defaults:
_form: '\Drupal\search\Form\ReindexConfirm'
_title: 'Clear index'
requirements:
_permission: 'administer search'
search.add_type:
path: '/admin/config/search/pages/add/{search_plugin_id}'
defaults:
_entity_form: 'search_page.add'
_title: 'Add new search page'
requirements:
_entity_create_access: 'search_page'
entity.search_page.edit_form:
path: '/admin/config/search/pages/manage/{search_page}'
defaults:
_entity_form: 'search_page.edit'
_title_callback: '\Drupal\search\Controller\SearchController::editTitle'
requirements:
_entity_access: 'search_page.update'
entity.search_page.enable:
path: '/admin/config/search/pages/manage/{search_page}/enable'
defaults:
_controller: '\Drupal\search\Controller\SearchController::performOperation'
op: 'enable'
requirements:
_entity_access: 'search_page.update'
_csrf_token: 'TRUE'
entity.search_page.disable:
path: '/admin/config/search/pages/manage/{search_page}/disable'
defaults:
_controller: '\Drupal\search\Controller\SearchController::performOperation'
op: 'disable'
requirements:
_entity_access: 'search_page.disable'
_csrf_token: 'TRUE'
entity.search_page.set_default:
path: '/admin/config/search/pages/manage/{search_page}/set-default'
defaults:
_controller: '\Drupal\search\Controller\SearchController::setAsDefault'
requirements:
_entity_access: 'search_page.update'
_csrf_token: 'TRUE'
entity.search_page.delete_form:
path: '/admin/config/search/pages/manage/{search_page}/delete'
defaults:
_entity_form: 'search_page.delete'
_title: 'Delete'
requirements:
_entity_access: 'search_page.delete'
route_callbacks:
- '\Drupal\search\Routing\SearchPageRoutes::routes'

View File

@@ -0,0 +1,21 @@
services:
plugin.manager.search:
class: Drupal\search\SearchPluginManager
parent: default_plugin_manager
search.search_page_repository:
class: Drupal\search\SearchPageRepository
arguments: ['@config.factory', '@entity_type.manager']
Drupal\search\SearchPageRepositoryInterface: '@search.search_page_repository'
search.index:
class: Drupal\search\SearchIndex
arguments: ['@config.factory', '@database','@database.replica', '@cache_tags.invalidator', '@search.text_processor', '@datetime.time']
tags:
- { name: backend_overridable }
Drupal\search\SearchIndexInterface: '@search.index'
search.text_processor:
class: Drupal\search\SearchTextProcessor
arguments: ['@transliteration', '@config.factory', '@module_handler']
Drupal\search\SearchTextProcessorInterface: '@search.text_processor'

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.
*
* @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
*
* @var \Drupal\Core\Annotation\Translation
*/
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,264 @@
<?php
namespace Drupal\search\Entity;
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\Plugin\SearchIndexingInterface;
use Drupal\search\Plugin\SearchPluginCollection;
use Drupal\search\SearchPageInterface;
/**
* Defines a configured search page.
*
* @ConfigEntityType(
* id = "search_page",
* label = @Translation("Search page"),
* label_collection = @Translation("Search pages"),
* label_singular = @Translation("search page"),
* label_plural = @Translation("search pages"),
* label_count = @PluralTranslation(
* singular = "@count search page",
* plural = "@count search pages",
* ),
* handlers = {
* "access" = "Drupal\search\SearchPageAccessControlHandler",
* "list_builder" = "Drupal\search\SearchPageListBuilder",
* "form" = {
* "add" = "Drupal\search\Form\SearchPageAddForm",
* "edit" = "Drupal\search\Form\SearchPageEditForm",
* "delete" = "Drupal\Core\Entity\EntityDeleteForm"
* }
* },
* admin_permission = "administer search",
* 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",
* },
* config_prefix = "page",
* entity_keys = {
* "id" = "id",
* "label" = "label",
* "weight" = "weight",
* "status" = "status"
* },
* 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();
}
}
/**
* Helper callback for uasort() to sort search page entities by status, weight and label.
*/
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,127 @@
<?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' => '',
];
}
/**
* {@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) {
$this->configuration['page_id'] = $form_state->getValue('page_id');
}
}

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,59 @@
<?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
*/
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,140 @@
<?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
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
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,340 @@
<?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 {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* The database replica connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $replica;
/**
* The cache tags invalidator.
*
* @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
*/
protected $cacheTagsInvalidator;
/**
* The text processor.
*
* @var \Drupal\search\SearchTextProcessorInterface
*/
protected $textProcessor;
/**
* SearchIndex constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* 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 $cache_tags_invalidator
* The cache tags invalidator.
* @param \Drupal\search\SearchTextProcessorInterface $text_processor
* The text processor.
* @param \Drupal\Component\Datetime\TimeInterface|null $time
* The time service
*/
public function __construct(
ConfigFactoryInterface $config_factory,
Connection $connection,
Connection $replica,
CacheTagsInvalidatorInterface $cache_tags_invalidator,
SearchTextProcessorInterface $text_processor,
protected ?TimeInterface $time = NULL,
) {
$this->configFactory = $config_factory;
$this->connection = $connection;
$this->replica = $replica;
$this->cacheTagsInvalidator = $cache_tags_invalidator;
$this->textProcessor = $text_processor;
if (!$time) {
@trigger_error('Calling ' . __METHOD__ . '() without the $time argument is deprecated in drupal:10.3.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3387233', E_USER_DEPRECATED);
$this->time = \Drupal::service(TimeInterface::class);
}
}
/**
* {@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,32 @@
<?php
namespace Drupal\search;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\search\Attribute\Search;
/**
* 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, 'Drupal\search\Plugin\SearchInterface', 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
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']);
}
}
}

View File

@@ -0,0 +1,71 @@
{#
/**
* @file
* Default theme implementation for displaying a single search result.
*
* This template renders a single search result. The list of results is
* rendered using '#theme' => 'item_list', with suggestions of:
* - item_list__search_results__(plugin_id)
* - item_list__search_results
*
* Available variables:
* - url: URL of the result.
* - title: Title of the result.
* - snippet: A small preview of the result. Does not apply to user searches.
* - info: String of all the meta information ready for print. Does not apply
* to user searches.
* - plugin_id: The machine-readable name of the plugin being executed,such
* as "node_search" or "user_search".
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the main title tag that appears in the template.
* - info_split: Contains same data as info, but split into separate parts.
* - info_split.type: Node type (or item type string supplied by module).
* - info_split.user: Author of the node linked to users profile. Depends
* on permission.
* - info_split.date: Last update of the node. Short formatted.
* - info_split.comment: Number of comments output as "% comments", %
* being the count. (Depends on comment.module).
* @todo The info variable needs to be made drillable and each of these sub
* items should instead be within info and renamed info.foo, info.bar, etc.
*
* Other variables:
* - title_attributes: HTML attributes for the title.
* - content_attributes: HTML attributes for the content.
*
* Since info_split is keyed, a direct print of the item is possible.
* This array does not apply to user searches so it is recommended to check
* for its existence before printing. The default keys of 'type', 'user' and
* 'date' always exist for node searches. Modules may provide other data.
* @code
* {% if (info_split.comment) %}
* <span class="info-comment">
* {{ info_split.comment }}
* </span>
* {% endif %}
* @endcode
*
* To check for all available data within info_split, use the code below.
* @code
* <pre>
* {{ dump(info_split) }}
* </pre>
* @endcode
*
* @see template_preprocess_search_result()
*
* @ingroup themeable
*/
#}
{{ title_prefix }}
<h3{{ title_attributes }}>
<a href="{{ url }}">{{ title }}</a>
</h3>
{{ title_suffix }}
{% if snippet %}
<p{{ content_attributes }}>{{ snippet }}</p>
{% endif %}
{% if info %}
<p>{{ info }}</p>
{% endif %}

View File

@@ -0,0 +1,333 @@
!"#$%&'()*+,-./
0123456789
:;<=>?@
ABCDEFGHIJKLMNOPQRSTUVWXYZ
[\]^_`
abcdefghijklmnopqrstuvwxyz
{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©
ª
«¬­®¯°±
²³
´
µ
¶·¸
¹º
»
¼½¾
¿
ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ
×
ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö
÷
øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴǵǶǷǸǹǺǻǼǽǾǿȀȁȂȃȄȅȆȇȈȉȊȋȌȍȎȏȐȑȒȓȔȕȖȗȘșȚțȜȝȞȟȠȡȢȣȤȥȦȧȨȩȪȫȬȭȮȯȰȱȲȳȴȵȶȷȸȹȺȻȼȽȾȿɀɁɂɃɄɅɆɇɈɉɊɋɌɍɎɏɐɑɒɓɔɕɖɗɘəɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼʽʾʿˀˁ
˂˃˄˅
ˆˇˈˉˊˋˌˍˎˏːˑ
˒˓˔˕˖˗˘˙˚˛˜˝˞˟
ˠˡˢˣˤ
˥˦˧˨˩˪˫
ˬ
˭
ˮ
˯˰˱˲˳˴˵˶˷˸˹˺˻˼˽˾˿
̴̵̶̷̸̡̢̧̨̛̖̗̘̙̜̝̞̟̠̣̤̥̦̩̪̫̬̭̮̯̰̱̲̳̹̺̻̼͇͈͉͍͎̀́̂̃̄̅̆̇̈̉̊̋̌̍̎̏̐̑̒̓̔̽̾̿̀́͂̓̈́͆͊͋͌̕̚ͅ͏͓͔͕͖͙͚͐͑͒͗͛ͣͤͥͦͧͨͩͪͫͬͭͮͯ͘͜͟͢͝͞͠͡ͰͱͲͳʹ
͵
Ͷͷͺͻͼͽ
;΄΅
Ά
·
ΈΉΊΌΎΏΐΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩΪΫάέήίΰαβγδεζηθικλμνξοπρςστυφχψωϊϋόύώϏϐϑϒϓϔϕϖϗϘϙϚϛϜϝϞϟϠϡϢϣϤϥϦϧϨϩϪϫϬϭϮϯϰϱϲϳϴϵ
϶
ϷϸϹϺϻϼϽϾϿЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяѐёђѓєѕіїјљњћќѝўџѠѡѢѣѤѥѦѧѨѩѪѫѬѭѮѯѰѱѲѳѴѵѶѷѸѹѺѻѼѽѾѿҀҁ
҂
҃҄҅҆҇҈҉ҊҋҌҍҎҏҐґҒғҔҕҖҗҘҙҚқҜҝҞҟҠҡҢңҤҥҦҧҨҩҪҫҬҭҮүҰұҲҳҴҵҶҷҸҹҺһҼҽҾҿӀӁӂӃӄӅӆӇӈӉӊӋӌӍӎӏӐӑӒӓӔӕӖӗӘәӚӛӜӝӞӟӠӡӢӣӤӥӦӧӨөӪӫӬӭӮӯӰӱӲӳӴӵӶӷӸӹӺӻӼӽӾӿԀԁԂԃԄԅԆԇԈԉԊԋԌԍԎԏԐԑԒԓԔԕԖԗԘԙԚԛԜԝԞԟԠԡԢԣԤԥԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖՙ
՚՛՜՝՞՟
աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև
։֊
ְֱֲֳִֵֶַָֹֺֻּֽ֑֖֛֢֣֤֥֦֧֪֚֭֮֒֓֔֕֗֘֙֜֝֞֟֠֡֨֩֫֬֯
־
ֿ
׀
ׁׂ
׃
ׅׄ
׆
ׇאבגדהוזחטיךכלםמןנסעףפץצקרשתװױײ
׳״؀؁؂؃؆؇؈؉؊؋،؍؎؏
ؘؙؚؐؑؒؓؔؕؖؗ
؛؞؟
ءآأؤإئابةتثجحخدذرزسشصضطظعغػؼؽؾؿـفقكلمنهوىيًٌٍَُِّْٕٖٜٓٔٗ٘ٙٚٛٝٞ٠١٢٣٤٥٦٧٨٩
٪٫٬٭
ٮٯٰٱٲٳٴٵٶٷٸٹٺٻټٽپٿڀځڂڃڄڅچڇڈډڊڋڌڍڎڏڐڑڒړڔڕږڗژڙښڛڜڝڞڟڠڡڢڣڤڥڦڧڨکڪګڬڭڮگڰڱڲڳڴڵڶڷڸڹںڻڼڽھڿۀہۂۃۄۅۆۇۈۉۊۋیۍێۏېۑےۓ
۔
ەۖۗۘۙۚۛۜ
۝
۞ۣ۟۠ۡۢۤۥۦۧۨ
۩
۪ۭ۫۬ۮۯ۰۱۲۳۴۵۶۷۸۹ۺۻۼ
۽۾
ۿ
܀܁܂܃܄܅܆܇܈܉܊܋܌܍܏
ܐܑܒܓܔܕܖܗܘܙܚܛܜܝܞܟܠܡܢܣܤܥܦܧܨܩܪܫܬܭܮܯܱܴܷܸܹܻܼܾ݂݄݆݈ܰܲܳܵܶܺܽܿ݀݁݃݅݇݉݊ݍݎݏݐݑݒݓݔݕݖݗݘݙݚݛݜݝݞݟݠݡݢݣݤݥݦݧݨݩݪݫݬݭݮݯݰݱݲݳݴݵݶݷݸݹݺݻݼݽݾݿހށނރބޅކއވމފދތލގޏސޑޒޓޔޕޖޗޘޙޚޛޜޝޞޟޠޡޢޣޤޥަާިީުޫެޭޮޯްޱ߀߁߂߃߄߅߆߇߈߉ߊߋߌߍߎߏߐߑߒߓߔߕߖߗߘߙߚߛߜߝߞߟߠߡߢߣߤߥߦߧߨߩߪ߲߫߬߭߮߯߰߱߳ߴߵ
߶߷߸߹
ߺࠀࠁࠂࠃࠄࠅࠆࠇࠈࠉࠊࠋࠌࠍࠎࠏࠐࠑࠒࠓࠔࠕࠖࠗ࠘࠙ࠚࠛࠜࠝࠞࠟࠠࠡࠢࠣࠤࠥࠦࠧࠨࠩࠪࠫࠬ࠭
࠰࠱࠲࠳࠴࠵࠶࠷࠸࠹࠺࠻࠼࠽࠾
ऀँंःऄअआइईउऊऋऌऍऎएऐऑऒओऔकखगघङचछजझञटठडढणतथदधनऩपफबभमयरऱलळऴवशषसह़ऽािीुूृॄॅॆेैॉॊोौ्ॎॐ॒॑॓॔ॕक़ख़ग़ज़ड़ढ़फ़य़ॠॡॢॣ
।॥
०१२३४५६७८९
ॱॲॹॺॻॼॽॾॿঁংঃঅআইঈউঊঋঌএঐওঔকখগঘঙচছজঝঞটঠডঢণতথদধনপফবভমযরলশষসহ়ঽািীুূৃৄেৈোৌ্ৎৗড়ঢ়য়ৠৡৢৣ০১২৩৪৫৬৭৮৯ৰৱ
৲৳
৴৵৶৷৸৹
৺৻
ਁਂਃਅਆਇਈਉਊਏਐਓਔਕਖਗਘਙਚਛਜਝਞਟਠਡਢਣਤਥਦਧਨਪਫਬਭਮਯਰਲਲ਼ਵਸ਼ਸਹ਼ਾਿੀੁੂੇੈੋੌ੍ੑਖ਼ਗ਼ਜ਼ੜਫ਼੦੧੨੩੪੫੬੭੮੯ੰੱੲੳੴੵઁંઃઅઆઇઈઉઊઋઌઍએઐઑઓઔકખગઘઙચછજઝઞટઠડઢણતથદધનપફબભમયરલળવશષસહ઼ઽાિીુૂૃૄૅેૈૉોૌ્ૐૠૡૢૣ૦૧૨૩૪૫૬૭૮૯
ଁଂଃଅଆଇଈଉଊଋଌଏଐଓଔକଖଗଘଙଚଛଜଝଞଟଠଡଢଣତଥଦଧନପଫବଭମଯରଲଳଵଶଷସହ଼ଽାିୀୁୂୃୄେୈୋୌ୍ୖୗଡ଼ଢ଼ୟୠୡୢୣ୦୧୨୩୪୫୬୭୮୯
ୱஂஃஅஆஇஈஉஊஎஏஐஒஓஔகஙசஜஞடணதநனபமயரறலளழவஶஷஸஹாிீுூெேைொோௌ்ௐௗ௦௧௨௩௪௫௬௭௮௯௰௱௲
௳௴௵௶௷௸௹௺
ఁంఃఅఆఇఈఉఊఋఌఎఏఐఒఓఔకఖగఘఙచఛజఝఞటఠడఢణతథదధనపఫబభమయరఱలళవశషసహఽాిీుూృౄెేైొోౌ్ౕౖౘౙౠౡౢౣ౦౧౨౩౪౫౬౭౮౯౸౹౺౻౼౽౾
౿
ಂಃಅಆಇಈಉಊಋಌಎಏಐಒಓಔಕಖಗಘಙಚಛಜಝಞಟಠಡಢಣತಥದಧನಪಫಬಭಮಯರಱಲಳವಶಷಸಹ಼ಽಾಿೀುೂೃೄೆೇೈೊೋೌ್ೕೖೞೠೡೢೣ೦೧೨೩೪೫೬೭೮೯
ೱೲ
ംഃഅആഇഈഉഊഋഌഎഏഐഒഓഔകഖഗഘങചഛജഝഞടഠഡഢണതഥദധനപഫബഭമയരറലളഴവശഷസഹഽാിീുൂൃൄെേൈൊോൌ്ൗൠൡൢൣ൦൧൨൩൪൫൬൭൮൯൰൱൲൳൴൵
ൺൻർൽൾൿංඃඅආඇඈඉඊඋඌඍඎඏඐඑඒඓඔඕඖකඛගඝඞඟචඡජඣඤඥඦටඨඩඪණඬතථදධනඳපඵබභමඹයරලවශෂසහළෆ්ාැෑිීුූෘෙේෛොෝෞෟෲෳ
กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรฤลฦวศษสหฬอฮฯะัาำิีึืฺุู
฿
เแโใไๅๆ็่้๊๋์ํ๎
๐๑๒๓๔๕๖๗๘๙
๚๛
ກຂຄງຈຊຍດຕຖທນບປຜຝພຟມຢຣລວສຫອຮຯະັາຳິີຶືຸູົຼຽເແໂໃໄໆ່້໊໋໌ໍ໐໑໒໓໔໕໖໗໘໙ໜໝༀ
༁༂༃༄༅༆༇༈༉༊་༌།༎༏༐༑༒༓༔༕༖༗
༘༙
༚༛༜༝༞༟
༠༡༢༣༤༥༦༧༨༩༪༫༬༭༮༯༰༱༲༳
༺༻༼༽
༾༿ཀཁགགྷངཅཆཇཉཊཋཌཌྷཎཏཐདདྷནཔཕབབྷམཙཚཛཛྷཝཞཟའཡརལཤཥསཧཨཀྵཪཫཬཱཱཱིིུུྲྀཷླྀཹེཻོཽཾཿ྄ཱྀྀྂྃ
྆྇ྈྉྊྋྐྑྒྒྷྔྕྖྗྙྚྛྜྜྷྞྟྠྡྡྷྣྤྥྦྦྷྨྩྪྫྫྷྭྮྯྰྱྲླྴྵྶྷྸྐྵྺྻྼ
྾྿࿀࿁࿂࿃࿄࿅
࿇࿈࿉࿊࿋࿌࿎࿏࿐࿑࿒࿓࿔࿕࿖࿗࿘
ကခဂဃငစဆဇဈဉညဋဌဍဎဏတထဒဓနပဖဗဘမယရလဝသဟဠအဢဣဤဥဦဧဨဩဪါာိီုူေဲဳဴဵံ့း္်ျြွှဿ၀၁၂၃၄၅၆၇၈၉
၊။၌၍၎၏
ၐၑၒၓၔၕၖၗၘၙၚၛၜၝၞၟၠၡၢၣၤၥၦၧၨၩၪၫၬၭၮၯၰၱၲၳၴၵၶၷၸၹၺၻၼၽၾၿႀႁႂႃႄႅႆႇႈႉႊႋႌႍႎႏ႐႑႒႓႔႕႖႗႘႙ႚႛႜႝ
႞႟
ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅაბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰჱჲჳჴჵჶჷჸჹჺ
ჼᄀᄁᄂᄃᄄᄅᄆᄇᄈᄉᄊᄋᄌᄍᄎᄏᄐᄑᄒᄓᄔᄕᄖᄗᄘᄙᄚᄛᄜᄝᄞᄟᄠᄡᄢᄣᄤᄥᄦᄧᄨᄩᄪᄫᄬᄭᄮᄯᄰᄱᄲᄳᄴᄵᄶᄷᄸᄹᄺᄻᄼᄽᄾᄿᅀᅁᅂᅃᅄᅅᅆᅇᅈᅉᅊᅋᅌᅍᅎᅏᅐᅑᅒᅓᅔᅕᅖᅗᅘᅙᅚᅛᅜᅝᅞᅟᅠᅡᅢᅣᅤᅥᅦᅧᅨᅩᅪᅫᅬᅭᅮᅯᅰᅱᅲᅳᅴᅵᅶᅷᅸᅹᅺᅻᅼᅽᅾᅿᆀᆁᆂᆃᆄᆅᆆᆇᆈᆉᆊᆋᆌᆍᆎᆏᆐᆑᆒᆓᆔᆕᆖᆗᆘᆙᆚᆛᆜᆝᆞᆟᆠᆡᆢᆣᆤᆥᆦᆧᆨᆩᆪᆫᆬᆭᆮᆯᆰᆱᆲᆳᆴᆵᆶᆷᆸᆹᆺᆻᆼᆽᆾᆿᇀᇁᇂᇃᇄᇅᇆᇇᇈᇉᇊᇋᇌᇍᇎᇏᇐᇑᇒᇓᇔᇕᇖᇗᇘᇙᇚᇛᇜᇝᇞᇟᇠᇡᇢᇣᇤᇥᇦᇧᇨᇩᇪᇫᇬᇭᇮᇯᇰᇱᇲᇳᇴᇵᇶᇷᇸᇹᇺᇻᇼᇽᇾᇿሀሁሂሃሄህሆሇለሉሊላሌልሎሏሐሑሒሓሔሕሖሗመሙሚማሜምሞሟሠሡሢሣሤሥሦሧረሩሪራሬርሮሯሰሱሲሳሴስሶሷሸሹሺሻሼሽሾሿቀቁቂቃቄቅቆቇቈቊቋቌቍቐቑቒቓቔቕቖቘቚቛቜቝበቡቢባቤብቦቧቨቩቪቫቬቭቮቯተቱቲታቴትቶቷቸቹቺቻቼችቾቿኀኁኂኃኄኅኆኇኈኊኋኌኍነኑኒናኔንኖኗኘኙኚኛኜኝኞኟአኡኢኣኤእኦኧከኩኪካኬክኮኯኰኲኳኴኵኸኹኺኻኼኽኾዀዂዃዄዅወዉዊዋዌውዎዏዐዑዒዓዔዕዖዘዙዚዛዜዝዞዟዠዡዢዣዤዥዦዧየዩዪያዬይዮዯደዱዲዳዴድዶዷዸዹዺዻዼዽዾዿጀጁጂጃጄጅጆጇገጉጊጋጌግጎጏጐጒጓጔጕጘጙጚጛጜጝጞጟጠጡጢጣጤጥጦጧጨጩጪጫጬጭጮጯጰጱጲጳጴጵጶጷጸጹጺጻጼጽጾጿፀፁፂፃፄፅፆፇፈፉፊፋፌፍፎፏፐፑፒፓፔፕፖፗፘፙፚ፟
፠፡።፣፤፥፦፧፨
፩፪፫፬፭፮፯፰፱፲፳፴፵፶፷፸፹፺፻፼ᎀᎁᎂᎃᎄᎅᎆᎇᎈᎉᎊᎋᎌᎍᎎᎏ
᎐᎑᎒᎓᎔᎕᎖᎗᎘᎙
ᎠᎡᎢᎣᎤᎥᎦᎧᎨᎩᎪᎫᎬᎭᎮᎯᎰᎱᎲᎳᎴᎵᎶᎷᎸᎹᎺᎻᎼᎽᎾᎿᏀᏁᏂᏃᏄᏅᏆᏇᏈᏉᏊᏋᏌᏍᏎᏏᏐᏑᏒᏓᏔᏕᏖᏗᏘᏙᏚᏛᏜᏝᏞᏟᏠᏡᏢᏣᏤᏥᏦᏧᏨᏩᏪᏫᏬᏭᏮᏯᏰᏱᏲᏳᏴ
ᐁᐂᐃᐄᐅᐆᐇᐈᐉᐊᐋᐌᐍᐎᐏᐐᐑᐒᐓᐔᐕᐖᐗᐘᐙᐚᐛᐜᐝᐞᐟᐠᐡᐢᐣᐤᐥᐦᐧᐨᐩᐪᐫᐬᐭᐮᐯᐰᐱᐲᐳᐴᐵᐶᐷᐸᐹᐺᐻᐼᐽᐾᐿᑀᑁᑂᑃᑄᑅᑆᑇᑈᑉᑊᑋᑌᑍᑎᑏᑐᑑᑒᑓᑔᑕᑖᑗᑘᑙᑚᑛᑜᑝᑞᑟᑠᑡᑢᑣᑤᑥᑦᑧᑨᑩᑪᑫᑬᑭᑮᑯᑰᑱᑲᑳᑴᑵᑶᑷᑸᑹᑺᑻᑼᑽᑾᑿᒀᒁᒂᒃᒄᒅᒆᒇᒈᒉᒊᒋᒌᒍᒎᒏᒐᒑᒒᒓᒔᒕᒖᒗᒘᒙᒚᒛᒜᒝᒞᒟᒠᒡᒢᒣᒤᒥᒦᒧᒨᒩᒪᒫᒬᒭᒮᒯᒰᒱᒲᒳᒴᒵᒶᒷᒸᒹᒺᒻᒼᒽᒾᒿᓀᓁᓂᓃᓄᓅᓆᓇᓈᓉᓊᓋᓌᓍᓎᓏᓐᓑᓒᓓᓔᓕᓖᓗᓘᓙᓚᓛᓜᓝᓞᓟᓠᓡᓢᓣᓤᓥᓦᓧᓨᓩᓪᓫᓬᓭᓮᓯᓰᓱᓲᓳᓴᓵᓶᓷᓸᓹᓺᓻᓼᓽᓾᓿᔀᔁᔂᔃᔄᔅᔆᔇᔈᔉᔊᔋᔌᔍᔎᔏᔐᔑᔒᔓᔔᔕᔖᔗᔘᔙᔚᔛᔜᔝᔞᔟᔠᔡᔢᔣᔤᔥᔦᔧᔨᔩᔪᔫᔬᔭᔮᔯᔰᔱᔲᔳᔴᔵᔶᔷᔸᔹᔺᔻᔼᔽᔾᔿᕀᕁᕂᕃᕄᕅᕆᕇᕈᕉᕊᕋᕌᕍᕎᕏᕐᕑᕒᕓᕔᕕᕖᕗᕘᕙᕚᕛᕜᕝᕞᕟᕠᕡᕢᕣᕤᕥᕦᕧᕨᕩᕪᕫᕬᕭᕮᕯᕰᕱᕲᕳᕴᕵᕶᕷᕸᕹᕺᕻᕼᕽᕾᕿᖀᖁᖂᖃᖄᖅᖆᖇᖈᖉᖊᖋᖌᖍᖎᖏᖐᖑᖒᖓᖔᖕᖖᖗᖘᖙᖚᖛᖜᖝᖞᖟᖠᖡᖢᖣᖤᖥᖦᖧᖨᖩᖪᖫᖬᖭᖮᖯᖰᖱᖲᖳᖴᖵᖶᖷᖸᖹᖺᖻᖼᖽᖾᖿᗀᗁᗂᗃᗄᗅᗆᗇᗈᗉᗊᗋᗌᗍᗎᗏᗐᗑᗒᗓᗔᗕᗖᗗᗘᗙᗚᗛᗜᗝᗞᗟᗠᗡᗢᗣᗤᗥᗦᗧᗨᗩᗪᗫᗬᗭᗮᗯᗰᗱᗲᗳᗴᗵᗶᗷᗸᗹᗺᗻᗼᗽᗾᗿᘀᘁᘂᘃᘄᘅᘆᘇᘈᘉᘊᘋᘌᘍᘎᘏᘐᘑᘒᘓᘔᘕᘖᘗᘘᘙᘚᘛᘜᘝᘞᘟᘠᘡᘢᘣᘤᘥᘦᘧᘨᘩᘪᘫᘬᘭᘮᘯᘰᘱᘲᘳᘴᘵᘶᘷᘸᘹᘺᘻᘼᘽᘾᘿᙀᙁᙂᙃᙄᙅᙆᙇᙈᙉᙊᙋᙌᙍᙎᙏᙐᙑᙒᙓᙔᙕᙖᙗᙘᙙᙚᙛᙜᙝᙞᙟᙠᙡᙢᙣᙤᙥᙦᙧᙨᙩᙪᙫᙬ
ᙯᙰᙱᙲᙳᙴᙵᙶᙷᙸᙹᙺᙻᙼᙽᙾᙿ
ᚁᚂᚃᚄᚅᚆᚇᚈᚉᚊᚋᚌᚍᚎᚏᚐᚑᚒᚓᚔᚕᚖᚗᚘᚙᚚ
᚛᚜
ᚠᚡᚢᚣᚤᚥᚦᚧᚨᚩᚪᚫᚬᚭᚮᚯᚰᚱᚲᚳᚴᚵᚶᚷᚸᚹᚺᚻᚼᚽᚾᚿᛀᛁᛂᛃᛄᛅᛆᛇᛈᛉᛊᛋᛌᛍᛎᛏᛐᛑᛒᛓᛔᛕᛖᛗᛘᛙᛚᛛᛜᛝᛞᛟᛠᛡᛢᛣᛤᛥᛦᛧᛨᛩᛪ
᛫᛬᛭
ᛮᛯᛰᜀᜁᜂᜃᜄᜅᜆᜇᜈᜉᜊᜋᜌᜎᜏᜐᜑᜒᜓ᜔ᜠᜡᜢᜣᜤᜥᜦᜧᜨᜩᜪᜫᜬᜭᜮᜯᜰᜱᜲᜳ᜴
᜵᜶
ᝀᝁᝂᝃᝄᝅᝆᝇᝈᝉᝊᝋᝌᝍᝎᝏᝐᝑᝒᝓᝠᝡᝢᝣᝤᝥᝦᝧᝨᝩᝪᝫᝬᝮᝯᝰᝲᝳកខគឃងចឆជឈញដឋឌឍណតថទធនបផពភមយរលវឝឞសហឡអឣឤឥឦឧឨឩឪឫឬឭឮឯឰឱឲឳ
ាិីឹឺុូួើឿៀេែៃោៅំះៈ៉៊់៌៍៎៏័៑្៓
។៕៖
៘៙៚៛
ៜ៝០១២៣៤៥៦៧៨៩៰៱៲៳៴៵៶៷៸៹
᠀᠁᠂᠃᠄᠅᠆᠇᠈᠉᠊
᠐᠑᠒᠓᠔᠕᠖᠗᠘᠙ᠠᠡᠢᠣᠤᠥᠦᠧᠨᠩᠪᠫᠬᠭᠮᠯᠰᠱᠲᠳᠴᠵᠶᠷᠸᠹᠺᠻᠼᠽᠾᠿᡀᡁᡂᡃᡄᡅᡆᡇᡈᡉᡊᡋᡌᡍᡎᡏᡐᡑᡒᡓᡔᡕᡖᡗᡘᡙᡚᡛᡜᡝᡞᡟᡠᡡᡢᡣᡤᡥᡦᡧᡨᡩᡪᡫᡬᡭᡮᡯᡰᡱᡲᡳᡴᡵᡶᡷᢀᢁᢂᢃᢄᢅᢆᢇᢈᢉᢊᢋᢌᢍᢎᢏᢐᢑᢒᢓᢔᢕᢖᢗᢘᢙᢚᢛᢜᢝᢞᢟᢠᢡᢢᢣᢤᢥᢦᢧᢨᢩᢪᢰᢱᢲᢳᢴᢵᢶᢷᢸᢹᢺᢻᢼᢽᢾᢿᣀᣁᣂᣃᣄᣅᣆᣇᣈᣉᣊᣋᣌᣍᣎᣏᣐᣑᣒᣓᣔᣕᣖᣗᣘᣙᣚᣛᣜᣝᣞᣟᣠᣡᣢᣣᣤᣥᣦᣧᣨᣩᣪᣫᣬᣭᣮᣯᣰᣱᣲᣳᣴᣵᤀᤁᤂᤃᤄᤅᤆᤇᤈᤉᤊᤋᤌᤍᤎᤏᤐᤑᤒᤓᤔᤕᤖᤗᤘᤙᤚᤛᤜᤠᤡᤢᤣᤤᤥᤦᤧᤨᤩᤪᤫᤰᤱᤲᤳᤴᤵᤶᤷᤸ᤻᤹᤺
᥀᥄᥅
᥆᥇᥈᥉᥊᥋᥌᥍᥎᥏ᥐᥑᥒᥓᥔᥕᥖᥗᥘᥙᥚᥛᥜᥝᥞᥟᥠᥡᥢᥣᥤᥥᥦᥧᥨᥩᥪᥫᥬᥭᥰᥱᥲᥳᥴᦀᦁᦂᦃᦄᦅᦆᦇᦈᦉᦊᦋᦌᦍᦎᦏᦐᦑᦒᦓᦔᦕᦖᦗᦘᦙᦚᦛᦜᦝᦞᦟᦠᦡᦢᦣᦤᦥᦦᦧᦨᦩᦪᦫᦰᦱᦲᦳᦴᦵᦶᦷᦸᦹᦺᦻᦼᦽᦾᦿᧀᧁᧂᧃᧄᧅᧆᧇᧈᧉ᧐᧑᧒᧓᧔᧕᧖᧗᧘᧙᧚
᧞᧟᧠᧡᧢᧣᧤᧥᧦᧧᧨᧩᧪᧫᧬᧭᧮᧯᧰᧱᧲᧳᧴᧵᧶᧷᧸᧹᧺᧻᧼᧽᧾᧿
ᨀᨁᨂᨃᨄᨅᨆᨇᨈᨉᨊᨋᨌᨍᨎᨏᨐᨑᨒᨓᨔᨕᨖᨘᨗᨙᨚᨛ
᨞᨟
ᨠᨡᨢᨣᨤᨥᨦᨧᨨᨩᨪᨫᨬᨭᨮᨯᨰᨱᨲᨳᨴᨵᨶᨷᨸᨹᨺᨻᨼᨽᨾᨿᩀᩁᩂᩃᩄᩅᩆᩇᩈᩉᩊᩋᩌᩍᩎᩏᩐᩑᩒᩓᩔᩕᩖᩗᩘᩙᩚᩛᩜᩝᩞ᩠ᩡᩢᩣᩤᩥᩦᩧᩨᩩᩪᩫᩬᩭᩮᩯᩰᩱᩲᩳᩴ᩿᩵᩶᩷᩸᩹᩺᩻᩼᪀᪁᪂᪃᪄᪅᪆᪇᪈᪉᪐᪑᪒᪓᪔᪕᪖᪗᪘᪙
᪠᪡᪢᪣᪤᪥᪦
᪨᪩᪪᪫᪬᪭
ᬀᬁᬂᬃᬄᬅᬆᬇᬈᬉᬊᬋᬌᬍᬎᬏᬐᬑᬒᬓᬔᬕᬖᬗᬘᬙᬚᬛᬜᬝᬞᬟᬠᬡᬢᬣᬤᬥᬦᬧᬨᬩᬪᬫᬬᬭᬮᬯᬰᬱᬲᬳ᬴ᬵᬶᬷᬸᬹᬺᬻᬼᬽᬾᬿᭀᭁᭂᭃ᭄ᭅᭆᭇᭈᭉᭊᭋ᭐᭑᭒᭓᭔᭕᭖᭗᭘᭙
᭚᭛᭜᭝᭞᭟᭠᭡᭢᭣᭤᭥᭦᭧᭨᭩᭪
᭬᭫᭭᭮᭯᭰᭱᭲᭳
᭴᭵᭶᭷᭸᭹᭺᭻᭼
ᮀᮁᮂᮃᮄᮅᮆᮇᮈᮉᮊᮋᮌᮍᮎᮏᮐᮑᮒᮓᮔᮕᮖᮗᮘᮙᮚᮛᮜᮝᮞᮟᮠᮡᮢᮣᮤᮥᮦᮧᮨᮩ᮪ᮮᮯ᮰᮱᮲᮳᮴᮵᮶᮷᮸᮹ᰀᰁᰂᰃᰄᰅᰆᰇᰈᰉᰊᰋᰌᰍᰎᰏᰐᰑᰒᰓᰔᰕᰖᰗᰘᰙᰚᰛᰜᰝᰞᰟᰠᰡᰢᰣᰤᰥᰦᰧᰨᰩᰪᰫᰬᰭᰮᰯᰰᰱᰲᰳᰴᰵᰶ᰷
᰻᰼᰽᰾᰿
᱀᱁᱂᱃᱄᱅᱆᱇᱈᱉ᱍᱎᱏ᱐᱑᱒᱓᱔᱕᱖᱗᱘᱙ᱚᱛᱜᱝᱞᱟᱠᱡᱢᱣᱤᱥᱦᱧᱨᱩᱪᱫᱬᱭᱮᱯᱰᱱᱲᱳᱴᱵᱶᱷᱸᱹᱺᱻᱼᱽ
᱾᱿
᳐᳑᳒
᳔᳕᳖᳗᳘᳙᳜᳝᳞᳟᳚᳛᳠᳡᳢᳣᳤᳥᳦᳧᳨ᳩᳪᳫᳬ᳭ᳮᳯᳰᳱᳲᴀᴁᴂᴃᴄᴅᴆᴇᴈᴉᴊᴋᴌᴍᴎᴏᴐᴑᴒᴓᴔᴕᴖᴗᴘᴙᴚᴛᴜᴝᴞᴟᴠᴡᴢᴣᴤᴥᴦᴧᴨᴩᴪᴫᴬᴭᴮᴯᴰᴱᴲᴳᴴᴵᴶᴷᴸᴹᴺᴻᴼᴽᴾᴿᵀᵁᵂᵃᵄᵅᵆᵇᵈᵉᵊᵋᵌᵍᵎᵏᵐᵑᵒᵓᵔᵕᵖᵗᵘᵙᵚᵛᵜᵝᵞᵟᵠᵡᵢᵣᵤᵥᵦᵧᵨᵩᵪᵫᵬᵭᵮᵯᵰᵱᵲᵳᵴᵵᵶᵷᵸᵹᵺᵻᵼᵽᵾᵿᶀᶁᶂᶃᶄᶅᶆᶇᶈᶉᶊᶋᶌᶍᶎᶏᶐᶑᶒᶓᶔᶕᶖᶗᶘᶙᶚᶛᶜᶝᶞᶟᶠᶡᶢᶣᶤᶥᶦᶧᶨᶩᶪᶫᶬᶭᶮᶯᶰᶱᶲᶳᶴᶵᶶᶷᶸᶹᶺᶻᶼᶽᶾᶿ᷐᷎᷂᷊᷏᷽᷿᷀᷁᷃᷄᷅᷆᷇᷈᷉᷋᷌᷑᷒ᷓᷔᷕᷖᷗᷘᷙᷚᷛᷜᷝᷞᷟᷠᷡᷢᷣᷤᷥᷦ᷾᷍ḀḁḂḃḄḅḆḇḈḉḊḋḌḍḎḏḐḑḒḓḔḕḖḗḘḙḚḛḜḝḞḟḠḡḢḣḤḥḦḧḨḩḪḫḬḭḮḯḰḱḲḳḴḵḶḷḸḹḺḻḼḽḾḿṀṁṂṃṄṅṆṇṈṉṊṋṌṍṎṏṐṑṒṓṔṕṖṗṘṙṚṛṜṝṞṟṠṡṢṣṤṥṦṧṨṩṪṫṬṭṮṯṰṱṲṳṴṵṶṷṸṹṺṻṼṽṾṿẀẁẂẃẄẅẆẇẈẉẊẋẌẍẎẏẐẑẒẓẔẕẖẗẘẙẚẛẜẝẞẟẠạẢảẤấẦầẨẩẪẫẬậẮắẰằẲẳẴẵẶặẸẹẺẻẼẽẾếỀềỂểỄễỆệỈỉỊịỌọỎỏỐốỒồỔổỖỗỘộỚớỜờỞởỠỡỢợỤụỦủỨứỪừỬửỮữỰựỲỳỴỵỶỷỸỹỺỻỼỽỾỿἀἁἂἃἄἅἆἇἈἉἊἋἌἍἎἏἐἑἒἓἔἕἘἙἚἛἜἝἠἡἢἣἤἥἦἧἨἩἪἫἬἭἮἯἰἱἲἳἴἵἶἷἸἹἺἻἼἽἾἿὀὁὂὃὄὅὈὉὊὋὌὍὐὑὒὓὔὕὖὗὙὛὝὟὠὡὢὣὤὥὦὧὨὩὪὫὬὭὮὯὰάὲέὴήὶίὸόὺύὼώᾀᾁᾂᾃᾄᾅᾆᾇᾈᾉᾊᾋᾌᾍᾎᾏᾐᾑᾒᾓᾔᾕᾖᾗᾘᾙᾚᾛᾜᾝᾞᾟᾠᾡᾢᾣᾤᾥᾦᾧᾨᾩᾪᾫᾬᾭᾮᾯᾰᾱᾲᾳᾴᾶᾷᾸᾹᾺΆᾼ
᾿῀῁
ῂῃῄῆῇῈΈῊΉῌ
῍῎῏
ῐῑῒΐῖῗῘῙῚΊ
῝῞῟
ῠῡῢΰῤῥῦῧῨῩῪΎῬ
῭΅`
ῲῳῴῶῷῸΌῺΏῼ
´῾           ​‌‍‎‏‐‑‒–—―‖‗‘’‚‛“”„‟†‡•‣․‥…‧

‪‫‬‭‮ ‰‱′″‴‵‶‷‸‹›※‼‽‾‿⁀⁁⁂⁃⁄⁅⁆⁇⁈⁉⁊⁋⁌⁍⁎⁏⁐⁑⁒⁓⁔⁕⁖⁗⁘⁙⁚⁛⁜⁝⁞ ⁠⁡⁢⁣⁤
⁰ⁱ⁴⁵⁶⁷⁸⁹
⁺⁻⁼⁽⁾
ⁿ₀₁₂₃₄₅₆₇₈₉
₊₋₌₍₎
ₐₑₒₓₔ
₠₡₢₣₤₥₦₧₨₩₪₫€₭₮₯₰₱₲₳₴₵₶₷₸
⃒⃓⃘⃙⃚⃐⃑⃔⃕⃖⃗⃛⃜⃝⃞⃟⃠⃡⃢⃣⃤⃥⃦⃪⃫⃨⃬⃭⃮⃯⃧⃩⃰
℀℁
℃℄℅℆
℈℉
ℊℋℌℍℎℏℐℑℒℓ
№℗℘
℞℟℠℡™℣
KÅℬℭ
ℯℰℱℲℳℴℵℶℷℸℹ
℺℻
ℼℽℾℿ
⅀⅁⅂⅃⅄
⅊⅋⅌⅍
⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞⅟ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↀↁↂↃↄↅↆↇↈ↉
←↑→↓↔↕↖↗↘↙↚↛↜↝↞↟↠↡↢↣↤↥↦↧↨↩↪↫↬↭↮↯↰↱↲↳↴↵↶↷↸↹↺↻↼↽↾↿⇀⇁⇂⇃⇄⇅⇆⇇⇈⇉⇊⇋⇌⇍⇎⇏⇐⇑⇒⇓⇔⇕⇖⇗⇘⇙⇚⇛⇜⇝⇞⇟⇠⇡⇢⇣⇤⇥⇦⇧⇨⇩⇪⇫⇬⇭⇮⇯⇰⇱⇲⇳⇴⇵⇶⇷⇸⇹⇺⇻⇼⇽⇾⇿∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅≆≇≈≉≊≋≌≍≎≏≐≑≒≓≔≕≖≗≘≙≚≛≜≝≞≟≠≡≢≣≤≥≦≧≨≩≪≫≬≭≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊌⊍⊎⊏⊐⊑⊒⊓⊔⊕⊖⊗⊘⊙⊚⊛⊜⊝⊞⊟⊠⊡⊢⊣⊤⊥⊦⊧⊨⊩⊪⊫⊬⊭⊮⊯⊰⊱⊲⊳⊴⊵⊶⊷⊸⊹⊺⊻⊼⊽⊾⊿⋀⋁⋂⋃⋄⋅⋆⋇⋈⋉⋊⋋⋌⋍⋎⋏⋐⋑⋒⋓⋔⋕⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋮⋯⋰⋱⋲⋳⋴⋵⋶⋷⋸⋹⋺⋻⋼⋽⋾⋿⌀⌁⌂⌃⌄⌅⌆⌇⌈⌉⌊⌋⌌⌍⌎⌏⌐⌑⌒⌓⌔⌕⌖⌗⌘⌙⌚⌛⌜⌝⌞⌟⌠⌡⌢⌣⌤⌥⌦⌧⌨〈〉⌫⌬⌭⌮⌯⌰⌱⌲⌳⌴⌵⌶⌷⌸⌹⌺⌻⌼⌽⌾⌿⍀⍁⍂⍃⍄⍅⍆⍇⍈⍉⍊⍋⍌⍍⍎⍏⍐⍑⍒⍓⍔⍕⍖⍗⍘⍙⍚⍛⍜⍝⍞⍟⍠⍡⍢⍣⍤⍥⍦⍧⍨⍩⍪⍫⍬⍭⍮⍯⍰⍱⍲⍳⍴⍵⍶⍷⍸⍹⍺⍻⍼⍽⍾⍿⎀⎁⎂⎃⎄⎅⎆⎇⎈⎉⎊⎋⎌⎍⎎⎏⎐⎑⎒⎓⎔⎕⎖⎗⎘⎙⎚⎛⎜⎝⎞⎟⎠⎡⎢⎣⎤⎥⎦⎧⎨⎩⎪⎫⎬⎭⎮⎯⎰⎱⎲⎳⎴⎵⎶⎷⎸⎹⎺⎻⎼⎽⎾⎿⏀⏁⏂⏃⏄⏅⏆⏇⏈⏉⏊⏋⏌⏍⏎⏏⏐⏑⏒⏓⏔⏕⏖⏗⏘⏙⏚⏛⏜⏝⏞⏟⏠⏡⏢⏣⏤⏥⏦⏧⏨␀␁␂␃␄␅␆␇␈␉␊␋␌␍␎␏␐␑␒␓␔␕␖␗␘␙␚␛␜␝␞␟␠␡␢␣␤␥␦⑀⑁⑂⑃⑄⑅⑆⑇⑈⑉⑊
①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛
⒜⒝⒞⒟⒠⒡⒢⒣⒤⒥⒦⒧⒨⒩⒪⒫⒬⒭⒮⒯⒰⒱⒲⒳⒴⒵ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ
⓪⓫⓬⓭⓮⓯⓰⓱⓲⓳⓴⓵⓶⓷⓸⓹⓺⓻⓼⓽⓾⓿
─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳╴╵╶╷╸╹╺╻╼╽╾╿▀▁▂▃▄▅▆▇█▉▊▋▌▍▎▏▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟■□▢▣▤▥▦▧▨▩▪▫▬▭▮▯▰▱▲△▴▵▶▷▸▹►▻▼▽▾▿◀◁◂◃◄◅◆◇◈◉◊○◌◍◎●◐◑◒◓◔◕◖◗◘◙◚◛◜◝◞◟◠◡◢◣◤◥◦◧◨◩◪◫◬◭◮◯◰◱◲◳◴◵◶◷◸◹◺◻◼◽◾◿☀☁☂☃☄★☆☇☈☉☊☋☌☍☎☏☐☑☒☓☔☕☖☗☘☙☚☛☜☝☞☟☠☡☢☣☤☥☦☧☨☩☪☫☬☭☮☯☰☱☲☳☴☵☶☷☸☹☺☻☼☽☾☿♀♁♂♃♄♅♆♇♈♉♊♋♌♍♎♏♐♑♒♓♔♕♖♗♘♙♚♛♜♝♞♟♠♡♢♣♤♥♦♧♨♩♪♫♬♭♮♯♰♱♲♳♴♵♶♷♸♹♺♻♼♽♾♿⚀⚁⚂⚃⚄⚅⚆⚇⚈⚉⚊⚋⚌⚍⚎⚏⚐⚑⚒⚓⚔⚕⚖⚗⚘⚙⚚⚛⚜⚝⚞⚟⚠⚡⚢⚣⚤⚥⚦⚧⚨⚩⚪⚫⚬⚭⚮⚯⚰⚱⚲⚳⚴⚵⚶⚷⚸⚹⚺⚻⚼⚽⚾⚿⛀⛁⛂⛃⛄⛅⛆⛇⛈⛉⛊⛋⛌⛍⛏⛐⛑⛒⛓⛔⛕⛖⛗⛘⛙⛚⛛⛜⛝⛞⛟⛠⛡⛣⛨⛩⛪⛫⛬⛭⛮⛯⛰⛱⛲⛳⛴⛵⛶⛷⛸⛹⛺⛻⛼⛽⛾⛿✁✂✃✄✆✇✈✉✌✍✎✏✐✑✒✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❍❏❐❑❒❖❗❘❙❚❛❜❝❞❡❢❣❤❥❦❧❨❩❪❫❬❭❮❯❰❱❲❳❴❵
❶❷❸❹❺❻❼❽❾❿➀➁➂➃➄➅➆➇➈➉➊➋➌➍➎➏➐➑➒➓
➔➘➙➚➛➜➝➞➟➠➡➢➣➤➥➦➧➨➩➪➫➬➭➮➯➱➲➳➴➵➶➷➸➹➺➻➼➽➾⟀⟁⟂⟃⟄⟅⟆⟇⟈⟉⟊⟌⟐⟑⟒⟓⟔⟕⟖⟗⟘⟙⟚⟛⟜⟝⟞⟟⟠⟡⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫⟬⟭⟮⟯⟰⟱⟲⟳⟴⟵⟶⟷⟸⟹⟺⟻⟼⟽⟾⟿⠀⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿⡀⡁⡂⡃⡄⡅⡆⡇⡈⡉⡊⡋⡌⡍⡎⡏⡐⡑⡒⡓⡔⡕⡖⡗⡘⡙⡚⡛⡜⡝⡞⡟⡠⡡⡢⡣⡤⡥⡦⡧⡨⡩⡪⡫⡬⡭⡮⡯⡰⡱⡲⡳⡴⡵⡶⡷⡸⡹⡺⡻⡼⡽⡾⡿⢀⢁⢂⢃⢄⢅⢆⢇⢈⢉⢊⢋⢌⢍⢎⢏⢐⢑⢒⢓⢔⢕⢖⢗⢘⢙⢚⢛⢜⢝⢞⢟⢠⢡⢢⢣⢤⢥⢦⢧⢨⢩⢪⢫⢬⢭⢮⢯⢰⢱⢲⢳⢴⢵⢶⢷⢸⢹⢺⢻⢼⢽⢾⢿⣀⣁⣂⣃⣄⣅⣆⣇⣈⣉⣊⣋⣌⣍⣎⣏⣐⣑⣒⣓⣔⣕⣖⣗⣘⣙⣚⣛⣜⣝⣞⣟⣠⣡⣢⣣⣤⣥⣦⣧⣨⣩⣪⣫⣬⣭⣮⣯⣰⣱⣲⣳⣴⣵⣶⣷⣸⣹⣺⣻⣼⣽⣾⣿⤀⤁⤂⤃⤄⤅⤆⤇⤈⤉⤊⤋⤌⤍⤎⤏⤐⤑⤒⤓⤔⤕⤖⤗⤘⤙⤚⤛⤜⤝⤞⤟⤠⤡⤢⤣⤤⤥⤦⤧⤨⤩⤪⤫⤬⤭⤮⤯⤰⤱⤲⤳⤴⤵⤶⤷⤸⤹⤺⤻⤼⤽⤾⤿⥀⥁⥂⥃⥄⥅⥆⥇⥈⥉⥊⥋⥌⥍⥎⥏⥐⥑⥒⥓⥔⥕⥖⥗⥘⥙⥚⥛⥜⥝⥞⥟⥠⥡⥢⥣⥤⥥⥦⥧⥨⥩⥪⥫⥬⥭⥮⥯⥰⥱⥲⥳⥴⥵⥶⥷⥸⥹⥺⥻⥼⥽⥾⥿⦀⦁⦂⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦎⦏⦐⦑⦒⦓⦔⦕⦖⦗⦘⦙⦚⦛⦜⦝⦞⦟⦠⦡⦢⦣⦤⦥⦦⦧⦨⦩⦪⦫⦬⦭⦮⦯⦰⦱⦲⦳⦴⦵⦶⦷⦸⦹⦺⦻⦼⦽⦾⦿⧀⧁⧂⧃⧄⧅⧆⧇⧈⧉⧊⧋⧌⧍⧎⧏⧐⧑⧒⧓⧔⧕⧖⧗⧘⧙⧚⧛⧜⧝⧞⧟⧠⧡⧢⧣⧤⧥⧦⧧⧨⧩⧪⧫⧬⧭⧮⧯⧰⧱⧲⧳⧴⧵⧶⧷⧸⧹⧺⧻⧼⧽⧾⧿⨀⨁⨂⨃⨄⨅⨆⨇⨈⨉⨊⨋⨌⨍⨎⨏⨐⨑⨒⨓⨔⨕⨖⨗⨘⨙⨚⨛⨜⨝⨞⨟⨠⨡⨢⨣⨤⨥⨦⨧⨨⨩⨪⨫⨬⨭⨮⨯⨰⨱⨲⨳⨴⨵⨶⨷⨸⨹⨺⨻⨼⨽⨾⨿⩀⩁⩂⩃⩄⩅⩆⩇⩈⩉⩊⩋⩌⩍⩎⩏⩐⩑⩒⩓⩔⩕⩖⩗⩘⩙⩚⩛⩜⩝⩞⩟⩠⩡⩢⩣⩤⩥⩦⩧⩨⩩⩪⩫⩬⩭⩮⩯⩰⩱⩲⩳⩴⩵⩶⩷⩸⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚⪛⪜⪝⪞⪟⪠⪡⪢⪣⪤⪥⪦⪧⪨⪩⪪⪫⪬⪭⪮⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐⫑⫒⫓⫔⫕⫖⫗⫘⫙⫚⫛⫝̸⫝⫞⫟⫠⫡⫢⫣⫤⫥⫦⫧⫨⫩⫪⫫⫬⫭⫮⫯⫰⫱⫲⫳⫴⫵⫶⫷⫸⫹⫺⫻⫼⫽⫾⫿⬀⬁⬂⬃⬄⬅⬆⬇⬈⬉⬊⬋⬌⬍⬎⬏⬐⬑⬒⬓⬔⬕⬖⬗⬘⬙⬚⬛⬜⬝⬞⬟⬠⬡⬢⬣⬤⬥⬦⬧⬨⬩⬪⬫⬬⬭⬮⬯⬰⬱⬲⬳⬴⬵⬶⬷⬸⬹⬺⬻⬼⬽⬾⬿⭀⭁⭂⭃⭄⭅⭆⭇⭈⭉⭊⭋⭌⭐⭑⭒⭓⭔⭕⭖⭗⭘⭙
ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞⱠⱡⱢⱣⱤⱥⱦⱧⱨⱩⱪⱫⱬⱭⱮⱯⱰⱱⱲⱳⱴⱵⱶⱷⱸⱹⱺⱻⱼⱽⱾⱿⲀⲁⲂⲃⲄⲅⲆⲇⲈⲉⲊⲋⲌⲍⲎⲏⲐⲑⲒⲓⲔⲕⲖⲗⲘⲙⲚⲛⲜⲝⲞⲟⲠⲡⲢⲣⲤⲥⲦⲧⲨⲩⲪⲫⲬⲭⲮⲯⲰⲱⲲⲳⲴⲵⲶⲷⲸⲹⲺⲻⲼⲽⲾⲿⳀⳁⳂⳃⳄⳅⳆⳇⳈⳉⳊⳋⳌⳍⳎⳏⳐⳑⳒⳓⳔⳕⳖⳗⳘⳙⳚⳛⳜⳝⳞⳟⳠⳡⳢⳣⳤ
⳥⳦⳧⳨⳩⳪
ⳫⳬⳭⳮ⳯⳰⳱
⳹⳺⳻⳼
⳾⳿
ⴀⴁⴂⴃⴄⴅⴆⴇⴈⴉⴊⴋⴌⴍⴎⴏⴐⴑⴒⴓⴔⴕⴖⴗⴘⴙⴚⴛⴜⴝⴞⴟⴠⴡⴢⴣⴤⴥⴰⴱⴲⴳⴴⴵⴶⴷⴸⴹⴺⴻⴼⴽⴾⴿⵀⵁⵂⵃⵄⵅⵆⵇⵈⵉⵊⵋⵌⵍⵎⵏⵐⵑⵒⵓⵔⵕⵖⵗⵘⵙⵚⵛⵜⵝⵞⵟⵠⵡⵢⵣⵤⵥⵯⶀⶁⶂⶃⶄⶅⶆⶇⶈⶉⶊⶋⶌⶍⶎⶏⶐⶑⶒⶓⶔⶕⶖⶠⶡⶢⶣⶤⶥⶦⶨⶩⶪⶫⶬⶭⶮⶰⶱⶲⶳⶴⶵⶶⶸⶹⶺⶻⶼⶽⶾⷀⷁⷂⷃⷄⷅⷆⷈⷉⷊⷋⷌⷍⷎⷐⷑⷒⷓⷔⷕⷖⷘⷙⷚⷛⷜⷝⷞⷠⷡⷢⷣⷤⷥⷦⷧⷨⷩⷪⷫⷬⷭⷮⷯⷰⷱⷲⷳⷴⷵⷶⷷⷸⷹⷺⷻⷼⷽⷾⷿ
⸀⸁⸂⸃⸄⸅⸆⸇⸈⸉⸊⸋⸌⸍⸎⸏⸐⸑⸒⸓⸔⸕⸖⸗⸘⸙⸚⸛⸜⸝⸞⸟⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⸪⸫⸬⸭⸮
⸰⸱⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠⻡⻢⻣⻤⻥⻦⻧⻨⻩⻪⻫⻬⻭⻮⻯⻰⻱⻲⻳⼀⼁⼂⼃⼄⼅⼆⼇⼈⼉⼊⼋⼌⼍⼎⼏⼐⼑⼒⼓⼔⼕⼖⼗⼘⼙⼚⼛⼜⼝⼞⼟⼠⼡⼢⼣⼤⼥⼦⼧⼨⼩⼪⼫⼬⼭⼮⼯⼰⼱⼲⼳⼴⼵⼶⼷⼸⼹⼺⼻⼼⼽⼾⼿⽀⽁⽂⽃⽄⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿⾀⾁⾂⾃⾄⾅⾆⾇⾈⾉⾊⾋⾌⾍⾎⾏⾐⾑⾒⾓⾔⾕⾖⾗⾘⾙⾚⾛⾜⾝⾞⾟⾠⾡⾢⾣⾤⾥⾦⾧⾨⾩⾪⾫⾬⾭⾮⾯⾰⾱⾲⾳⾴⾵⾶⾷⾸⾹⾺⾻⾼⾽⾾⾿⿀⿁⿂⿃⿄⿅⿆⿇⿈⿉⿊⿋⿌⿍⿎⿏⿐⿑⿒⿓⿔⿕⿰⿱⿲⿳⿴⿵⿶⿷⿸⿹⿺⿻ 、。〃〄
々〆〇
〈〉《》「」『』【】〒〓〔〕〖〗〘〙〚〛〜〝〞〟〠
〡〢〣〤〥〦〧〨〩〪〭〮〯〫〬
〱〲〳〴〵
〶〷
〸〹〺〻〼
〽〾〿
ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをんゔゕゖ゙゚
゛゜
ゝゞゟ
ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶヷヸヹヺ
ーヽヾヿㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦㄧㄨㄩㄪㄫㄬㄭㄱㄲㄳㄴㄵㄶㄷㄸㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅃㅄㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣㅤㅥㅦㅧㅨㅩㅪㅫㅬㅭㅮㅯㅰㅱㅲㅳㅴㅵㅶㅷㅸㅹㅺㅻㅼㅽㅾㅿㆀㆁㆂㆃㆄㆅㆆㆇㆈㆉㆊㆋㆌㆍㆎ
㆐㆑
㆒㆓㆔㆕
㆖㆗㆘㆙㆚㆛㆜㆝㆞㆟
ㆠㆡㆢㆣㆤㆥㆦㆧㆨㆩㆪㆫㆬㆭㆮㆯㆰㆱㆲㆳㆴㆵㆶㆷ
㇀㇁㇂㇃㇄㇅㇆㇇㇈㇉㇊㇋㇌㇍㇎㇏㇐㇑㇒㇓㇔㇕㇖㇗㇘㇙㇚㇛㇜㇝㇞㇟㇠㇡㇢㇣
ㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ
㈀㈁㈂㈃㈄㈅㈆㈇㈈㈉㈊㈋㈌㈍㈎㈏㈐㈑㈒㈓㈔㈕㈖㈗㈘㈙㈚㈛㈜㈝㈞
㈠㈡㈢㈣㈤㈥㈦㈧㈨㈩
㈪㈫㈬㈭㈮㈯㈰㈱㈲㈳㈴㈵㈶㈷㈸㈹㈺㈻㈼㈽㈾㈿㉀㉁㉂㉃㉄㉅㉆㉇㉈㉉㉊㉋㉌㉍㉎㉏㉐
㉑㉒㉓㉔㉕㉖㉗㉘㉙㉚㉛㉜㉝㉞㉟
㉠㉡㉢㉣㉤㉥㉦㉧㉨㉩㉪㉫㉬㉭㉮㉯㉰㉱㉲㉳㉴㉵㉶㉷㉸㉹㉺㉻㉼㉽㉾㉿
㊀㊁㊂㊃㊄㊅㊆㊇㊈㊉
㊊㊋㊌㊍㊎㊏㊐㊑㊒㊓㊔㊕㊖㊗㊘㊙㊚㊛㊜㊝㊞㊟㊠㊡㊢㊣㊤㊥㊦㊧㊨㊩㊪㊫㊬㊭㊮㊯㊰
㊱㊲㊳㊴㊵㊶㊷㊸㊹㊺㊻㊼㊽㊾㊿
㋀㋁㋂㋃㋄㋅㋆㋇㋈㋉㋊㋋㋌㋍㋎㋏㋐㋑㋒㋓㋔㋕㋖㋗㋘㋙㋚㋛㋜㋝㋞㋟㋠㋡㋢㋣㋤㋥㋦㋧㋨㋩㋪㋫㋬㋭㋮㋯㋰㋱㋲㋳㋴㋵㋶㋷㋸㋹㋺㋻㋼㋽㋾㌀㌁㌂㌃㌄㌅㌆㌇㌈㌉㌊㌋㌌㌍㌎㌏㌐㌑㌒㌓㌔㌕㌖㌗㌘㌙㌚㌛㌜㌝㌞㌟㌠㌡㌢㌣㌤㌥㌦㌧㌨㌩㌪㌫㌬㌭㌮㌯㌰㌱㌲㌳㌴㌵㌶㌷㌸㌹㌺㌻㌼㌽㌾㌿㍀㍁㍂㍃㍄㍅㍆㍇㍈㍉㍊㍋㍌㍍㍎㍏㍐㍑㍒㍓㍔㍕㍖㍗㍘㍙㍚㍛㍜㍝㍞㍟㍠㍡㍢㍣㍤㍥㍦㍧㍨㍩㍪㍫㍬㍭㍮㍯㍰㍱㍲㍳㍴㍵㍶㍷㍸㍹㍺㍻㍼㍽㍾㍿㎀㎁㎂㎃㎄㎅㎆㎇㎈㎉㎊㎋㎌㎍㎎㎏㎐㎑㎒㎓㎔㎕㎖㎗㎘㎙㎚㎛㎜㎝㎞㎟㎠㎡㎢㎣㎤㎥㎦㎧㎨㎩㎪㎫㎬㎭㎮㎯㎰㎱㎲㎳㎴㎵㎶㎷㎸㎹㎺㎻㎼㎽㎾㎿㏀㏁㏂㏃㏄㏅㏆㏇㏈㏉㏊㏋㏌㏍㏎㏏㏐㏑㏒㏓㏔㏕㏖㏗㏘㏙㏚㏛㏜㏝㏞㏟㏠㏡㏢㏣㏤㏥㏦㏧㏨㏩㏪㏫㏬㏭㏮㏯㏰㏱㏲㏳㏴㏵㏶㏷㏸㏹㏺㏻㏼㏽㏾㏿
㐀䶵
䷀䷁䷂䷃䷄䷅䷆䷇䷈䷉䷊䷋䷌䷍䷎䷏䷐䷑䷒䷓䷔䷕䷖䷗䷘䷙䷚䷛䷜䷝䷞䷟䷠䷡䷢䷣䷤䷥䷦䷧䷨䷩䷪䷫䷬䷭䷮䷯䷰䷱䷲䷳䷴䷵䷶䷷䷸䷹䷺䷻䷼䷽䷾䷿
一鿋ꀀꀁꀂꀃꀄꀅꀆꀇꀈꀉꀊꀋꀌꀍꀎꀏꀐꀑꀒꀓꀔꀕꀖꀗꀘꀙꀚꀛꀜꀝꀞꀟꀠꀡꀢꀣꀤꀥꀦꀧꀨꀩꀪꀫꀬꀭꀮꀯꀰꀱꀲꀳꀴꀵꀶꀷꀸꀹꀺꀻꀼꀽꀾꀿꁀꁁꁂꁃꁄꁅꁆꁇꁈꁉꁊꁋꁌꁍꁎꁏꁐꁑꁒꁓꁔꁕꁖꁗꁘꁙꁚꁛꁜꁝꁞꁟꁠꁡꁢꁣꁤꁥꁦꁧꁨꁩꁪꁫꁬꁭꁮꁯꁰꁱꁲꁳꁴꁵꁶꁷꁸꁹꁺꁻꁼꁽꁾꁿꂀꂁꂂꂃꂄꂅꂆꂇꂈꂉꂊꂋꂌꂍꂎꂏꂐꂑꂒꂓꂔꂕꂖꂗꂘꂙꂚꂛꂜꂝꂞꂟꂠꂡꂢꂣꂤꂥꂦꂧꂨꂩꂪꂫꂬꂭꂮꂯꂰꂱꂲꂳꂴꂵꂶꂷꂸꂹꂺꂻꂼꂽꂾꂿꃀꃁꃂꃃꃄꃅꃆꃇꃈꃉꃊꃋꃌꃍꃎꃏꃐꃑꃒꃓꃔꃕꃖꃗꃘꃙꃚꃛꃜꃝꃞꃟꃠꃡꃢꃣꃤꃥꃦꃧꃨꃩꃪꃫꃬꃭꃮꃯꃰꃱꃲꃳꃴꃵꃶꃷꃸꃹꃺꃻꃼꃽꃾꃿꄀꄁꄂꄃꄄꄅꄆꄇꄈꄉꄊꄋꄌꄍꄎꄏꄐꄑꄒꄓꄔꄕꄖꄗꄘꄙꄚꄛꄜꄝꄞꄟꄠꄡꄢꄣꄤꄥꄦꄧꄨꄩꄪꄫꄬꄭꄮꄯꄰꄱꄲꄳꄴꄵꄶꄷꄸꄹꄺꄻꄼꄽꄾꄿꅀꅁꅂꅃꅄꅅꅆꅇꅈꅉꅊꅋꅌꅍꅎꅏꅐꅑꅒꅓꅔꅕꅖꅗꅘꅙꅚꅛꅜꅝꅞꅟꅠꅡꅢꅣꅤꅥꅦꅧꅨꅩꅪꅫꅬꅭꅮꅯꅰꅱꅲꅳꅴꅵꅶꅷꅸꅹꅺꅻꅼꅽꅾꅿꆀꆁꆂꆃꆄꆅꆆꆇꆈꆉꆊꆋꆌꆍꆎꆏꆐꆑꆒꆓꆔꆕꆖꆗꆘꆙꆚꆛꆜꆝꆞꆟꆠꆡꆢꆣꆤꆥꆦꆧꆨꆩꆪꆫꆬꆭꆮꆯꆰꆱꆲꆳꆴꆵꆶꆷꆸꆹꆺꆻꆼꆽꆾꆿꇀꇁꇂꇃꇄꇅꇆꇇꇈꇉꇊꇋꇌꇍꇎꇏꇐꇑꇒꇓꇔꇕꇖꇗꇘꇙꇚꇛꇜꇝꇞꇟꇠꇡꇢꇣꇤꇥꇦꇧꇨꇩꇪꇫꇬꇭꇮꇯꇰꇱꇲꇳꇴꇵꇶꇷꇸꇹꇺꇻꇼꇽꇾꇿꈀꈁꈂꈃꈄꈅꈆꈇꈈꈉꈊꈋꈌꈍꈎꈏꈐꈑꈒꈓꈔꈕꈖꈗꈘꈙꈚꈛꈜꈝꈞꈟꈠꈡꈢꈣꈤꈥꈦꈧꈨꈩꈪꈫꈬꈭꈮꈯꈰꈱꈲꈳꈴꈵꈶꈷꈸꈹꈺꈻꈼꈽꈾꈿꉀꉁꉂꉃꉄꉅꉆꉇꉈꉉꉊꉋꉌꉍꉎꉏꉐꉑꉒꉓꉔꉕꉖꉗꉘꉙꉚꉛꉜꉝꉞꉟꉠꉡꉢꉣꉤꉥꉦꉧꉨꉩꉪꉫꉬꉭꉮꉯꉰꉱꉲꉳꉴꉵꉶꉷꉸꉹꉺꉻꉼꉽꉾꉿꊀꊁꊂꊃꊄꊅꊆꊇꊈꊉꊊꊋꊌꊍꊎꊏꊐꊑꊒꊓꊔꊕꊖꊗꊘꊙꊚꊛꊜꊝꊞꊟꊠꊡꊢꊣꊤꊥꊦꊧꊨꊩꊪꊫꊬꊭꊮꊯꊰꊱꊲꊳꊴꊵꊶꊷꊸꊹꊺꊻꊼꊽꊾꊿꋀꋁꋂꋃꋄꋅꋆꋇꋈꋉꋊꋋꋌꋍꋎꋏꋐꋑꋒꋓꋔꋕꋖꋗꋘꋙꋚꋛꋜꋝꋞꋟꋠꋡꋢꋣꋤꋥꋦꋧꋨꋩꋪꋫꋬꋭꋮꋯꋰꋱꋲꋳꋴꋵꋶꋷꋸꋹꋺꋻꋼꋽꋾꋿꌀꌁꌂꌃꌄꌅꌆꌇꌈꌉꌊꌋꌌꌍꌎꌏꌐꌑꌒꌓꌔꌕꌖꌗꌘꌙꌚꌛꌜꌝꌞꌟꌠꌡꌢꌣꌤꌥꌦꌧꌨꌩꌪꌫꌬꌭꌮꌯꌰꌱꌲꌳꌴꌵꌶꌷꌸꌹꌺꌻꌼꌽꌾꌿꍀꍁꍂꍃꍄꍅꍆꍇꍈꍉꍊꍋꍌꍍꍎꍏꍐꍑꍒꍓꍔꍕꍖꍗꍘꍙꍚꍛꍜꍝꍞꍟꍠꍡꍢꍣꍤꍥꍦꍧꍨꍩꍪꍫꍬꍭꍮꍯꍰꍱꍲꍳꍴꍵꍶꍷꍸꍹꍺꍻꍼꍽꍾꍿꎀꎁꎂꎃꎄꎅꎆꎇꎈꎉꎊꎋꎌꎍꎎꎏꎐꎑꎒꎓꎔꎕꎖꎗꎘꎙꎚꎛꎜꎝꎞꎟꎠꎡꎢꎣꎤꎥꎦꎧꎨꎩꎪꎫꎬꎭꎮꎯꎰꎱꎲꎳꎴꎵꎶꎷꎸꎹꎺꎻꎼꎽꎾꎿꏀꏁꏂꏃꏄꏅꏆꏇꏈꏉꏊꏋꏌꏍꏎꏏꏐꏑꏒꏓꏔꏕꏖꏗꏘꏙꏚꏛꏜꏝꏞꏟꏠꏡꏢꏣꏤꏥꏦꏧꏨꏩꏪꏫꏬꏭꏮꏯꏰꏱꏲꏳꏴꏵꏶꏷꏸꏹꏺꏻꏼꏽꏾꏿꐀꐁꐂꐃꐄꐅꐆꐇꐈꐉꐊꐋꐌꐍꐎꐏꐐꐑꐒꐓꐔꐕꐖꐗꐘꐙꐚꐛꐜꐝꐞꐟꐠꐡꐢꐣꐤꐥꐦꐧꐨꐩꐪꐫꐬꐭꐮꐯꐰꐱꐲꐳꐴꐵꐶꐷꐸꐹꐺꐻꐼꐽꐾꐿꑀꑁꑂꑃꑄꑅꑆꑇꑈꑉꑊꑋꑌꑍꑎꑏꑐꑑꑒꑓꑔꑕꑖꑗꑘꑙꑚꑛꑜꑝꑞꑟꑠꑡꑢꑣꑤꑥꑦꑧꑨꑩꑪꑫꑬꑭꑮꑯꑰꑱꑲꑳꑴꑵꑶꑷꑸꑹꑺꑻꑼꑽꑾꑿꒀꒁꒂꒃꒄꒅꒆꒇꒈꒉꒊꒋꒌ
꒐꒑꒒꒓꒔꒕꒖꒗꒘꒙꒚꒛꒜꒝꒞꒟꒠꒡꒢꒣꒤꒥꒦꒧꒨꒩꒪꒫꒬꒭꒮꒯꒰꒱꒲꒳꒴꒵꒶꒷꒸꒹꒺꒻꒼꒽꒾꒿꓀꓁꓂꓃꓄꓅꓆
ꓐꓑꓒꓓꓔꓕꓖꓗꓘꓙꓚꓛꓜꓝꓞꓟꓠꓡꓢꓣꓤꓥꓦꓧꓨꓩꓪꓫꓬꓭꓮꓯꓰꓱꓲꓳꓴꓵꓶꓷꓸꓹꓺꓻꓼꓽ
꓾꓿
ꔀꔁꔂꔃꔄꔅꔆꔇꔈꔉꔊꔋꔌꔍꔎꔏꔐꔑꔒꔓꔔꔕꔖꔗꔘꔙꔚꔛꔜꔝꔞꔟꔠꔡꔢꔣꔤꔥꔦꔧꔨꔩꔪꔫꔬꔭꔮꔯꔰꔱꔲꔳꔴꔵꔶꔷꔸꔹꔺꔻꔼꔽꔾꔿꕀꕁꕂꕃꕄꕅꕆꕇꕈꕉꕊꕋꕌꕍꕎꕏꕐꕑꕒꕓꕔꕕꕖꕗꕘꕙꕚꕛꕜꕝꕞꕟꕠꕡꕢꕣꕤꕥꕦꕧꕨꕩꕪꕫꕬꕭꕮꕯꕰꕱꕲꕳꕴꕵꕶꕷꕸꕹꕺꕻꕼꕽꕾꕿꖀꖁꖂꖃꖄꖅꖆꖇꖈꖉꖊꖋꖌꖍꖎꖏꖐꖑꖒꖓꖔꖕꖖꖗꖘꖙꖚꖛꖜꖝꖞꖟꖠꖡꖢꖣꖤꖥꖦꖧꖨꖩꖪꖫꖬꖭꖮꖯꖰꖱꖲꖳꖴꖵꖶꖷꖸꖹꖺꖻꖼꖽꖾꖿꗀꗁꗂꗃꗄꗅꗆꗇꗈꗉꗊꗋꗌꗍꗎꗏꗐꗑꗒꗓꗔꗕꗖꗗꗘꗙꗚꗛꗜꗝꗞꗟꗠꗡꗢꗣꗤꗥꗦꗧꗨꗩꗪꗫꗬꗭꗮꗯꗰꗱꗲꗳꗴꗵꗶꗷꗸꗹꗺꗻꗼꗽꗾꗿꘀꘁꘂꘃꘄꘅꘆꘇꘈꘉꘊꘋꘌ
꘍꘎꘏
ꘐꘑꘒꘓꘔꘕꘖꘗꘘꘙꘚꘛꘜꘝꘞꘟ꘠꘡꘢꘣꘤꘥꘦꘧꘨꘩ꘪꘫꙀꙁꙂꙃꙄꙅꙆꙇꙈꙉꙊꙋꙌꙍꙎꙏꙐꙑꙒꙓꙔꙕꙖꙗꙘꙙꙚꙛꙜꙝꙞꙟꙢꙣꙤꙥꙦꙧꙨꙩꙪꙫꙬꙭꙮ꙯꙰꙱꙲
꙼꙽
ꙿꚀꚁꚂꚃꚄꚅꚆꚇꚈꚉꚊꚋꚌꚍꚎꚏꚐꚑꚒꚓꚔꚕꚖꚗꚠꚡꚢꚣꚤꚥꚦꚧꚨꚩꚪꚫꚬꚭꚮꚯꚰꚱꚲꚳꚴꚵꚶꚷꚸꚹꚺꚻꚼꚽꚾꚿꛀꛁꛂꛃꛄꛅꛆꛇꛈꛉꛊꛋꛌꛍꛎꛏꛐꛑꛒꛓꛔꛕꛖꛗꛘꛙꛚꛛꛜꛝꛞꛟꛠꛡꛢꛣꛤꛥꛦꛧꛨꛩꛪꛫꛬꛭꛮꛯ꛰꛱
꛲꛳꛴꛵꛶꛷꜀꜁꜂꜃꜄꜅꜆꜇꜈꜉꜊꜋꜌꜍꜎꜏꜐꜑꜒꜓꜔꜕꜖
ꜗꜘꜙꜚꜛꜜꜝꜞꜟ
꜠꜡
ꜢꜣꜤꜥꜦꜧꜨꜩꜪꜫꜬꜭꜮꜯꜰꜱꜲꜳꜴꜵꜶꜷꜸꜹꜺꜻꜼꜽꜾꜿꝀꝁꝂꝃꝄꝅꝆꝇꝈꝉꝊꝋꝌꝍꝎꝏꝐꝑꝒꝓꝔꝕꝖꝗꝘꝙꝚꝛꝜꝝꝞꝟꝠꝡꝢꝣꝤꝥꝦꝧꝨꝩꝪꝫꝬꝭꝮꝯꝰꝱꝲꝳꝴꝵꝶꝷꝸꝹꝺꝻꝼꝽꝾꝿꞀꞁꞂꞃꞄꞅꞆꞇꞈ
꞉꞊
Ꞌꞌꟻꟼꟽꟾꟿꠀꠁꠂꠃꠄꠅ꠆ꠇꠈꠉꠊꠋꠌꠍꠎꠏꠐꠑꠒꠓꠔꠕꠖꠗꠘꠙꠚꠛꠜꠝꠞꠟꠠꠡꠢꠣꠤꠥꠦꠧ
꠨꠩꠪꠫
꠰꠱꠲꠳꠴꠵
꠶꠷꠸꠹
ꡀꡁꡂꡃꡄꡅꡆꡇꡈꡉꡊꡋꡌꡍꡎꡏꡐꡑꡒꡓꡔꡕꡖꡗꡘꡙꡚꡛꡜꡝꡞꡟꡠꡡꡢꡣꡤꡥꡦꡧꡨꡩꡪꡫꡬꡭꡮꡯꡰꡱꡲꡳ
꡴꡵꡶꡷
ꢀꢁꢂꢃꢄꢅꢆꢇꢈꢉꢊꢋꢌꢍꢎꢏꢐꢑꢒꢓꢔꢕꢖꢗꢘꢙꢚꢛꢜꢝꢞꢟꢠꢡꢢꢣꢤꢥꢦꢧꢨꢩꢪꢫꢬꢭꢮꢯꢰꢱꢲꢳꢴꢵꢶꢷꢸꢹꢺꢻꢼꢽꢾꢿꣀꣁꣂꣃ꣄
꣎꣏
꣐꣑꣒꣓꣔꣕꣖꣗꣘꣙꣠꣡꣢꣣꣤꣥꣦꣧꣨꣩꣪꣫꣬꣭꣮꣯꣰꣱ꣲꣳꣴꣵꣶꣷ
꣸꣹꣺
ꣻ꤀꤁꤂꤃꤄꤅꤆꤇꤈꤉ꤊꤋꤌꤍꤎꤏꤐꤑꤒꤓꤔꤕꤖꤗꤘꤙꤚꤛꤜꤝꤞꤟꤠꤡꤢꤣꤤꤥꤦꤧꤨꤩꤪ꤫꤬꤭
꤮꤯
ꤰꤱꤲꤳꤴꤵꤶꤷꤸꤹꤺꤻꤼꤽꤾꤿꥀꥁꥂꥃꥄꥅꥆꥇꥈꥉꥊꥋꥌꥍꥎꥏꥐꥑꥒ꥓
ꥠꥡꥢꥣꥤꥥꥦꥧꥨꥩꥪꥫꥬꥭꥮꥯꥰꥱꥲꥳꥴꥵꥶꥷꥸꥹꥺꥻꥼꦀꦁꦂꦃꦄꦅꦆꦇꦈꦉꦊꦋꦌꦍꦎꦏꦐꦑꦒꦓꦔꦕꦖꦗꦘꦙꦚꦛꦜꦝꦞꦟꦠꦡꦢꦣꦤꦥꦦꦧꦨꦩꦪꦫꦬꦭꦮꦯꦰꦱꦲ꦳ꦴꦵꦶꦷꦸꦹꦺꦻꦼꦽꦾꦿ꧀
꧁꧂꧃꧄꧅꧆꧇꧈꧉꧊꧋꧌꧍
ꧏ꧐꧑꧒꧓꧔꧕꧖꧗꧘꧙
꧞꧟
ꨀꨁꨂꨃꨄꨅꨆꨇꨈꨉꨊꨋꨌꨍꨎꨏꨐꨑꨒꨓꨔꨕꨖꨗꨘꨙꨚꨛꨜꨝꨞꨟꨠꨡꨢꨣꨤꨥꨦꨧꨨꨩꨪꨫꨬꨭꨮꨯꨰꨱꨲꨳꨴꨵꨶꩀꩁꩂꩃꩄꩅꩆꩇꩈꩉꩊꩋꩌꩍ꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙
꩜꩝꩞꩟
ꩠꩡꩢꩣꩤꩥꩦꩧꩨꩩꩪꩫꩬꩭꩮꩯꩰꩱꩲꩳꩴꩵꩶ
꩷꩸꩹
ꩺꩻꪀꪁꪂꪃꪄꪅꪆꪇꪈꪉꪊꪋꪌꪍꪎꪏꪐꪑꪒꪓꪔꪕꪖꪗꪘꪙꪚꪛꪜꪝꪞꪟꪠꪡꪢꪣꪤꪥꪦꪧꪨꪩꪪꪫꪬꪭꪮꪯꪰꪱꪴꪲꪳꪵꪶꪷꪸꪹꪺꪻꪼꪽꪾ꪿ꫀ꫁ꫂꫛꫜꫝ
꫞꫟
ꯀꯁꯂꯃꯄꯅꯆꯇꯈꯉꯊꯋꯌꯍꯎꯏꯐꯑꯒꯓꯔꯕꯖꯗꯘꯙꯚꯛꯜꯝꯞꯟꯠꯡꯢꯣꯤꯥꯦꯧꯨꯩꯪ
꯬꯭꯰꯱꯲꯳꯴꯵꯶꯷꯸꯹가힣ힰힱힲힳힴힵힶힷힸힹힺힻힼힽힾힿퟀퟁퟂퟃퟄퟅퟆퟋퟌퟍퟎퟏퟐퟑퟒퟓퟔퟕퟖퟗퟘퟙퟚퟛퟜퟝퟞퟟퟠퟡퟢퟣퟤퟥퟦퟧퟨퟩퟪퟫퟬퟭퟮퟯퟰퟱퟲퟳퟴퟵퟶퟷퟸퟹퟺퟻ

豈更車賈滑串句龜龜契金喇奈懶癩羅蘿螺裸邏樂洛烙珞落酪駱亂卵欄爛蘭鸞嵐濫藍襤拉臘蠟廊朗浪狼郎來冷勞擄櫓爐盧老蘆虜路露魯鷺碌祿綠菉錄鹿論壟弄籠聾牢磊賂雷壘屢樓淚漏累縷陋勒肋凜凌稜綾菱陵讀拏樂諾丹寧怒率異北磻便復不泌數索參塞省葉說殺辰沈拾若掠略亮兩凉梁糧良諒量勵呂女廬旅濾礪閭驪麗黎力曆歷轢年憐戀撚漣煉璉秊練聯輦蓮連鍊列劣咽烈裂說廉念捻殮簾獵令囹寧嶺怜玲瑩羚聆鈴零靈領例禮醴隸惡了僚寮尿料樂燎療蓼遼龍暈阮劉杻柳流溜琉留硫紐類六戮陸倫崙淪輪律慄栗率隆利吏履易李梨泥理痢罹裏裡里離匿溺吝燐璘藺隣鱗麟林淋臨立笠粒狀炙識什茶刺切度拓糖宅洞暴輻行降見廓兀嗀﨎﨏塚﨑晴﨓﨔凞猪益礼神祥福靖精羽﨟蘒﨡諸﨣﨤逸都﨧﨨﨩飯飼館鶴侮僧免勉勤卑喝嘆器塀墨層屮悔慨憎懲敏既暑梅海渚漢煮爫琢碑社祉祈祐祖祝禍禎穀突節練縉繁署者臭艹艹著褐視謁謹賓贈辶逸難響頻恵𤋮舘並况全侀充冀勇勺喝啕喙嗢塚墳奄奔婢嬨廒廙彩徭惘慎愈憎慠懲戴揄搜摒敖晴朗望杖歹殺流滛滋漢瀞煮瞧爵犯猪瑱甆画瘝瘟益盛直睊着磌窱節类絛練缾者荒華蝹襁覆視調諸請謁諾諭謹變贈輸遲醙鉶陼難靖韛響頋頻鬒龜𢡊𢡄𣏕㮝䀘䀹𥉉𥳐𧻓齃龎fffiflffifflſtstﬓﬔﬕﬖﬗיִﬞײַﬠﬡﬢﬣﬤﬥﬦﬧﬨ
שׁשׂשּׁשּׂאַאָאּבּגּדּהּוּזּטּיּךּכּלּמּנּסּףּפּצּקּרּשּתּוֹבֿכֿפֿﭏﭐﭑﭒﭓﭔﭕﭖﭗﭘﭙﭚﭛﭜﭝﭞﭟﭠﭡﭢﭣﭤﭥﭦﭧﭨﭩﭪﭫﭬﭭﭮﭯﭰﭱﭲﭳﭴﭵﭶﭷﭸﭹﭺﭻﭼﭽﭾﭿﮀﮁﮂﮃﮄﮅﮆﮇﮈﮉﮊﮋﮌﮍﮎﮏﮐﮑﮒﮓﮔﮕﮖﮗﮘﮙﮚﮛﮜﮝﮞﮟﮠﮡﮢﮣﮤﮥﮦﮧﮨﮩﮪﮫﮬﮭﮮﮯﮰﮱﯓﯔﯕﯖﯗﯘﯙﯚﯛﯜﯝﯞﯟﯠﯡﯢﯣﯤﯥﯦﯧﯨﯩﯪﯫﯬﯭﯮﯯﯰﯱﯲﯳﯴﯵﯶﯷﯸﯹﯺﯻﯼﯽﯾﯿﰀﰁﰂﰃﰄﰅﰆﰇﰈﰉﰊﰋﰌﰍﰎﰏﰐﰑﰒﰓﰔﰕﰖﰗﰘﰙﰚﰛﰜﰝﰞﰟﰠﰡﰢﰣﰤﰥﰦﰧﰨﰩﰪﰫﰬﰭﰮﰯﰰﰱﰲﰳﰴﰵﰶﰷﰸﰹﰺﰻﰼﰽﰾﰿﱀﱁﱂﱃﱄﱅﱆﱇﱈﱉﱊﱋﱌﱍﱎﱏﱐﱑﱒﱓﱔﱕﱖﱗﱘﱙﱚﱛﱜﱝﱞﱟﱠﱡﱢﱣﱤﱥﱦﱧﱨﱩﱪﱫﱬﱭﱮﱯﱰﱱﱲﱳﱴﱵﱶﱷﱸﱹﱺﱻﱼﱽﱾﱿﲀﲁﲂﲃﲄﲅﲆﲇﲈﲉﲊﲋﲌﲍﲎﲏﲐﲑﲒﲓﲔﲕﲖﲗﲘﲙﲚﲛﲜﲝﲞﲟﲠﲡﲢﲣﲤﲥﲦﲧﲨﲩﲪﲫﲬﲭﲮﲯﲰﲱﲲﲳﲴﲵﲶﲷﲸﲹﲺﲻﲼﲽﲾﲿﳀﳁﳂﳃﳄﳅﳆﳇﳈﳉﳊﳋﳌﳍﳎﳏﳐﳑﳒﳓﳔﳕﳖﳗﳘﳙﳚﳛﳜﳝﳞﳟﳠﳡﳢﳣﳤﳥﳦﳧﳨﳩﳪﳫﳬﳭﳮﳯﳰﳱﳲﳳﳴﳵﳶﳷﳸﳹﳺﳻﳼﳽﳾﳿﴀﴁﴂﴃﴄﴅﴆﴇﴈﴉﴊﴋﴌﴍﴎﴏﴐﴑﴒﴓﴔﴕﴖﴗﴘﴙﴚﴛﴜﴝﴞﴟﴠﴡﴢﴣﴤﴥﴦﴧﴨﴩﴪﴫﴬﴭﴮﴯﴰﴱﴲﴳﴴﴵﴶﴷﴸﴹﴺﴻﴼﴽ
﴿
ﵐﵑﵒﵓﵔﵕﵖﵗﵘﵙﵚﵛﵜﵝﵞﵟﵠﵡﵢﵣﵤﵥﵦﵧﵨﵩﵪﵫﵬﵭﵮﵯﵰﵱﵲﵳﵴﵵﵶﵷﵸﵹﵺﵻﵼﵽﵾﵿﶀﶁﶂﶃﶄﶅﶆﶇﶈﶉﶊﶋﶌﶍﶎﶏﶒﶓﶔﶕﶖﶗﶘﶙﶚﶛﶜﶝﶞﶟﶠﶡﶢﶣﶤﶥﶦﶧﶨﶩﶪﶫﶬﶭﶮﶯﶰﶱﶲﶳﶴﶵﶶﶷﶸﶹﶺﶻﶼﶽﶾﶿﷀﷁﷂﷃﷄﷅﷆﷇﷰﷱﷲﷳﷴﷵﷶﷷﷸﷹﷺﷻ
﷼﷽
︐︑︒︓︔︕︖︗︘︙
︠︡︢︣︤︥︦
︰︱︲︳︴︵︶︷︸︹︺︻︼︽︾︿﹀﹁﹂﹃﹄﹅﹆﹇﹈﹉﹊﹋﹌﹍﹎﹏﹐﹑﹒﹔﹕﹖﹗﹘﹙﹚﹛﹜﹝﹞﹟﹠﹡﹢﹣﹤﹥﹦﹨﹩﹪﹫
ﹰﹱﹲﹳﹴﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ

_
{|}~⦅⦆。「」、・
ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙゚ᅠᄀᄁᆪᄂᆬᆭᄃᄄᄅᆰᆱᆲᆳᆴᆵᄚᄆᄇᄈᄡᄉᄊᄋᄌᄍᄎᄏᄐᄑ하ᅢᅣᅤᅥᅦᅧᅨᅩᅪᅫᅬᅭᅮᅯᅰᅱᅲᅳᅴᅵ
¢£¬ ̄¦¥₩←↑→↓■○<EFBFBD>
𐀀

View File

@@ -0,0 +1,10 @@
name: 'Search Date Query Alter'
type: module
description: 'Test module that adds date conditions to node searches.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,20 @@
<?php
/**
* @file
* Adds date conditions to node searches.
*/
use Drupal\Core\Database\Query\AlterableInterface;
/**
* Implements hook_query_TAG_alter().
*
* Tags search_$type with $type node_search.
*/
function search_date_query_alter_query_search_node_search_alter(AlterableInterface $query) {
// Start date Sat, 19 Mar 2016 00:00:00 GMT.
$query->condition('n.created', 1458345600, '>=');
// End date Sun, 20 Mar 2016 00:00:00 GMT.
$query->condition('n.created', 1458432000, '<');
}

View File

@@ -0,0 +1,10 @@
name: 'Search Embedded Form'
type: module
description: 'Support module for Search module testing of embedded forms.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,18 @@
<?php
/**
* @file
* Test module implementing a form that can be embedded in search results.
*
* A sample use of an embedded form is an e-commerce site where each search
* result may include an embedded form with buttons like "Add to cart" for each
* individual product (node) listed in the search results.
*/
/**
* Adds the test form to search results.
*/
function search_embedded_form_preprocess_search_result(&$variables) {
$form = \Drupal::formBuilder()->getForm('Drupal\search_embedded_form\Form\SearchEmbeddedForm');
$variables['snippet'] = array_merge($variables['snippet'], $form);
}

View File

@@ -0,0 +1,7 @@
search_embedded_form.test_embedded_form:
path: '/search_embedded_form'
defaults:
_title: 'Search_Embed_Form'
_form: '\Drupal\search_embedded_form\Form\SearchEmbeddedForm'
requirements:
_permission: 'search content'

View File

@@ -0,0 +1,56 @@
<?php
namespace Drupal\search_embedded_form\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Form controller for search_embedded_form form.
*
* @internal
*/
class SearchEmbeddedForm extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'search_embedded_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$count = \Drupal::state()->get('search_embedded_form.submit_count', 0);
$form['name'] = [
'#type' => 'textfield',
'#title' => $this->t('Your name'),
'#maxlength' => 255,
'#default_value' => '',
'#required' => TRUE,
'#description' => $this->t('Times form has been submitted: %count', ['%count' => $count]),
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Send away'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$state = \Drupal::state();
$submit_count = $state->get('search_embedded_form.submit_count', 0);
$state->set('search_embedded_form.submit_count', $submit_count + 1);
$this->messenger()->addStatus($this->t('Test form was submitted'));
}
}

View File

@@ -0,0 +1,12 @@
name: 'Test Search Type'
type: module
description: 'Support module for Search module testing.'
package: Testing
# version: VERSION
dependencies:
- drupal:test_page_test
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,128 @@
<?php
namespace Drupal\search_extra_type\Plugin\Search;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\search\Attribute\Search;
use Drupal\search\Plugin\ConfigurableSearchPluginBase;
/**
* Executes a dummy keyword search.
*/
#[Search(
id: 'search_extra_type_search',
title: new TranslatableMarkup('Dummy search type'),
use_admin_theme: TRUE,
)]
class SearchExtraTypeSearch extends ConfigurableSearchPluginBase {
/**
* {@inheritdoc}
*/
public function setSearch($keywords, array $parameters, array $attributes) {
if (empty($parameters['search_conditions'])) {
$parameters['search_conditions'] = '';
}
parent::setSearch($keywords, $parameters, $attributes);
return $this;
}
/**
* Verifies if the given parameters are valid enough to execute a search for.
*
* @return bool
* TRUE if there are keywords or search conditions in the query.
*/
public function isSearchExecutable() {
return (bool) ($this->keywords || !empty($this->searchParameters['search_conditions']));
}
/**
* Execute the search.
*
* This is a dummy search, so when search "executes", we just return a dummy
* result containing the keywords and a list of conditions.
*
* @return array
* A structured list of search results
*/
public function execute() {
$results = [];
if (!$this->isSearchExecutable()) {
return $results;
}
return [
[
'link' => Url::fromRoute('test_page_test.test_page')->toString(),
'type' => 'Dummy result type',
'title' => 'Dummy title',
'snippet' => new FormattableMarkup("Dummy search snippet to display. Keywords: @keywords\n\nConditions: @search_parameters", ['@keywords' => $this->keywords, '@search_parameters' => print_r($this->searchParameters, TRUE)]),
],
];
}
/**
* {@inheritdoc}
*/
public function buildResults() {
$results = $this->execute();
$output['prefix']['#markup'] = '<h2>Test page text is here</h2> <ol class="search-results">';
foreach ($results as $entry) {
$output[] = [
'#theme' => 'search_result',
'#result' => $entry,
'#plugin_id' => 'search_extra_type_search',
];
}
$pager = [
'#type' => 'pager',
];
$output['suffix']['#markup'] = '</ol>' . \Drupal::service('renderer')->render($pager);
return $output;
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
// Output form for defining rank factor weights.
$form['extra_type_settings'] = [
'#type' => 'fieldset',
'#title' => $this->t('Extra type settings'),
'#tree' => TRUE,
];
$form['extra_type_settings']['boost'] = [
'#type' => 'select',
'#title' => $this->t('Boost method'),
'#options' => [
'bi' => $this->t('Bistro mathematics'),
'ii' => $this->t('Infinite Improbability'),
],
'#default_value' => $this->configuration['boost'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['boost'] = $form_state->getValue(['extra_type_settings', 'boost']);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'boost' => 'bi',
];
}
}

View File

@@ -0,0 +1,10 @@
name: 'Test search entity langcode'
type: module
description: 'Support module for search module testing.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,41 @@
<?php
/**
* @file
* Tests the preprocessing of search text.
*
* Preprocessing is tested when the language code is passed to the preprocess
* hook and also when with alternate verb forms for the stemming test.
*/
/**
* Implements hook_search_preprocess().
*/
function search_langcode_test_search_preprocess($text, $langcode = NULL) {
if (isset($langcode) && $langcode == 'en') {
// Add the alternate verb forms for the word "testing".
if ($text == 'we are testing') {
$text .= ' test tested';
}
// Prints the langcode for testPreprocessLangcode() and adds some
// extra text.
else {
\Drupal::messenger()->addStatus('Langcode Preprocess Test: ' . $langcode);
$text .= 'Additional text';
}
}
// Prints the langcode for testPreprocessLangcode().
elseif (isset($langcode)) {
\Drupal::messenger()->addStatus('Langcode Preprocess Test: ' . $langcode);
// Preprocessing for the excerpt test.
if ($langcode == 'ex') {
$text = str_replace('finding', 'find', $text);
$text = str_replace('finds', 'find', $text);
$text = str_replace('dic', ' dependency injection container', $text);
$text = str_replace('hypertext markup language', 'html', $text);
}
}
return $text;
}

View File

@@ -0,0 +1,10 @@
name: 'Test Search Query Alter'
type: module
description: 'Support module for Search module testing.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,18 @@
<?php
/**
* @file
* Test module that alters search queries.
*/
use Drupal\Core\Database\Query\AlterableInterface;
/**
* Implements hook_query_TAG_alter().
*
* Tags search_$type with $type node_search.
*/
function search_query_alter_query_search_node_search_alter(AlterableInterface $query) {
// For testing purposes, restrict the query to node type 'article' only.
$query->condition('n.type', 'article');
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for search.
*
* @group search
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class SearchPageJsonAnonTest extends SearchPageResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
*/
class SearchPageJsonBasicAuthTest extends SearchPageResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
*/
class SearchPageJsonCookieTest extends SearchPageResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional\Rest;
use Drupal\search\Entity\SearchPage;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
// cspell:ignore hinode
/**
* Base class for Search page tests.
*/
abstract class SearchPageResourceTestBase extends ConfigEntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'search_page';
/**
* @var \Drupal\search\SearchPageInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['access content']);
break;
case 'POST':
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['administer search']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$search_page = SearchPage::create([
'id' => 'hinode_search',
'plugin' => 'node_search',
'label' => 'Search of magnetic activity of the Sun',
'path' => 'sun',
]);
$search_page->save();
return $search_page;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return [
'configuration' => [
'rankings' => [],
],
'dependencies' => [
'module' => ['node'],
],
'id' => 'hinode_search',
'label' => 'Search of magnetic activity of the Sun',
'langcode' => 'en',
'path' => 'sun',
'plugin' => 'node_search',
'status' => TRUE,
'uuid' => $this->entity->uuid(),
'weight' => 0,
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
return [];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
switch ($method) {
case 'GET':
return "The 'access content' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
// @see \Drupal\search\SearchPageAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedEntityAccessCacheability($is_authenticated)
->addCacheTags(['config:search.page.hinode_search']);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class SearchPageXmlAnonTest extends SearchPageResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class SearchPageXmlBasicAuthTest extends SearchPageResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class SearchPageXmlCookieTest extends SearchPageResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Verify the search results using administration theme for specific plugins.
*
* @see \Drupal\search\Annotation\SearchPlugin::$use_admin_theme
* @see \Drupal\search\Routing\SearchPageRoutes::routes()
* @see \Drupal\Tests\system\Functional\System\ThemeTest::testAdministrationTheme()
*
* @group search
*/
class SearchAdminThemeTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'help',
'node',
'search',
'search_extra_type',
'user',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The administration theme name.
*
* @var string
*/
protected $adminTheme = 'claro';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Install an administration theme to make sure it used for search results.
\Drupal::service('theme_installer')->install([$this->adminTheme]);
\Drupal::configFactory()
->getEditable('system.theme')
->set('admin', $this->adminTheme)
->save();
// Create searching user.
$user = $this->drupalCreateUser([
'access content',
'search content',
'access help pages',
'access user profiles',
'view the administration theme',
]);
// Log in with sufficient privileges.
$this->drupalLogin($user);
}
/**
* Tests that search results could be displayed in administration theme.
*
* @see \Drupal\node\Plugin\Search\NodeSearch
* @see \Drupal\search_extra_type\Plugin\Search\SearchExtraTypeSearch
* @see \Drupal\user\Plugin\Search\UserSearch
*/
public function testSearchUsingAdminTheme(): void {
/** @var \Drupal\search\SearchPageRepositoryInterface $repository */
$repository = \Drupal::service('search.search_page_repository');
$pages = $repository->getActiveSearchPages();
// Test default configured pages.
$page_ids = [
'node_search' => FALSE,
'dummy_search_type' => TRUE,
'help_search' => TRUE,
'user_search' => FALSE,
];
foreach ($page_ids as $page_id => $use_admin_theme) {
$plugin = $pages[$page_id]->getPlugin();
$path = 'search/' . $pages[$page_id]->getPath();
$this->drupalGet($path);
$session = $this->assertSession();
// Make sure help plugin rendered help link.
$path_help = $path . '/help';
$session->linkByHrefExists($path_help);
$this->assertSame($use_admin_theme, $plugin->usesAdminTheme());
$this->assertAdminTheme($use_admin_theme);
// Make sure that search help also rendered in admin theme.
$this->drupalGet($path_help);
$this->assertAdminTheme($use_admin_theme);
}
}
/**
* Asserts whether an administrative theme's used for the loaded page.
*
* @param bool $is_admin
* TRUE to test for administrative theme, FALSE otherwise.
*
* @internal
*/
protected function assertAdminTheme(bool $is_admin): void {
if ($is_admin) {
$this->assertSession()->responseContains('core/themes/' . $this->adminTheme);
}
else {
$this->assertSession()->responseNotContains('core/themes/' . $this->adminTheme);
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Indexes content and tests the advanced search form.
*
* @group search
*/
class SearchAdvancedSearchFormTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search', 'dblog'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A node to use for testing.
*
* @var \Drupal\node\NodeInterface
*/
protected $node;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
// Create and log in user.
$test_user = $this->drupalCreateUser([
'access content',
'search content',
'use advanced search',
'administer nodes',
]);
$this->drupalLogin($test_user);
// Create initial node.
$this->node = $this->drupalCreateNode();
// First update the index. This does the initial processing.
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
}
/**
* Tests advanced search by node type.
*/
public function testNodeType(): void {
// Verify some properties of the node that was created.
$this->assertSame('page', $this->node->getType(), 'Node type is Basic page.');
$dummy_title = 'Lorem ipsum';
$this->assertNotEquals($dummy_title, $this->node->label(), "Dummy title doesn't equal node title.");
// Search for the dummy title with a GET query.
$this->drupalGet('search/node', ['query' => ['keys' => $dummy_title]]);
$this->assertSession()->pageTextNotContains($this->node->label());
// Search for the title of the node with a GET query.
$this->drupalGet('search/node', ['query' => ['keys' => $this->node->label()]]);
$this->assertSession()->pageTextContains($this->node->label());
// Search for the title of the node with a POST query.
$edit = ['or' => $this->node->label()];
$this->drupalGet('search/node');
$this->submitForm($edit, 'edit-submit--2');
$this->assertSession()->pageTextContains($this->node->label());
// Search by node type.
$this->drupalGet('search/node');
$this->submitForm(array_merge($edit, ['type[page]' => 'page']), 'edit-submit--2');
$this->assertSession()->pageTextContains($this->node->label());
$this->drupalGet('search/node');
$this->submitForm(array_merge($edit, ['type[article]' => 'article']), 'edit-submit--2');
$this->assertSession()->pageTextContains('search yielded no results');
}
/**
* Tests that after submitting the advanced search form, the form is refilled.
*/
public function testFormRefill(): void {
$edit = [
'keys' => 'cat',
'or' => 'dog gerbil',
'phrase' => 'pets are nice',
'negative' => 'fish snake',
'type[page]' => 'page',
];
$this->drupalGet('search/node');
$this->submitForm($edit, 'edit-submit--2');
// Test that the encoded query appears in the page title. Only test the
// part not including the quote, because assertText() cannot seem to find
// the quote marks successfully.
$this->assertSession()->pageTextContains('Search for cat dog OR gerbil -fish -snake');
// Verify that all of the form fields are filled out.
foreach ($edit as $key => $value) {
if ($key != 'type[page]') {
$this->assertSession()->fieldValueEquals($key, $value);
}
else {
$this->assertSession()->checkboxChecked($key);
}
}
// Now test by submitting the or/not part of the query in the main
// search box, and verify that the advanced form is not filled out.
// (It shouldn't be filled out unless you submit values in those fields.)
$edit2 = ['keys' => 'cat dog OR gerbil -fish -snake'];
$this->drupalGet('search/node');
$this->submitForm($edit2, 'edit-submit--2');
$this->assertSession()->pageTextContains('Search for cat dog OR gerbil -fish -snake');
foreach ($edit as $key => $value) {
if ($key != 'type[page]') {
$this->assertSession()->fieldValueNotEquals($key, $value);
}
}
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
/**
* Tests if the search form block is available.
*
* @group search
*/
class SearchBlockTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['block', 'node', 'search', 'dblog', 'user'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The administrative user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create and log in user.
$this->adminUser = $this->drupalCreateUser([
'administer blocks',
'search content',
'access user profiles',
'access content',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Tests that the search form block can be placed and works.
*/
public function testSearchFormBlock(): void {
// Test availability of the search block in the admin "Place blocks" list.
$this->drupalGet('admin/structure/block');
$this->getSession()->getPage()->findLink('Place block')->click();
$this->assertSession()->linkByHrefExists('/admin/structure/block/add/search_form_block/stark', 0,
'Did not find the search block in block candidate list.');
$block = $this->drupalPlaceBlock('search_form_block');
$this->drupalGet('');
$this->assertSession()->pageTextContains($block->label());
// Check that name attribute is not empty.
$this->assertSession()->elementNotExists('xpath', "//input[@type='submit' and @name='']");
// Test a normal search via the block form, from the front page.
$terms = ['keys' => 'test'];
$this->drupalGet('');
$this->submitForm($terms, 'Search');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Your search yielded no results');
// Test a search from the block on a 404 page.
$this->drupalGet('foo');
$this->assertSession()->statusCodeEquals(404);
$this->submitForm($terms, 'Search');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Your search yielded no results');
$visibility = $block->getVisibility();
$visibility['request_path']['pages'] = 'search';
$block->setVisibilityConfig('request_path', $visibility['request_path']);
$this->drupalGet('');
$this->submitForm($terms, 'Search');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Your search yielded no results');
// Confirm that the form submits to the default search page.
/** @var \Drupal\search\SearchPageRepositoryInterface $search_page_repository */
$search_page_repository = \Drupal::service('search.search_page_repository');
$entity_id = $search_page_repository->getDefaultSearchPage();
$this->assertEquals(
$this->getUrl(),
Url::fromRoute('search.view_' . $entity_id, [], ['query' => ['keys' => $terms['keys']], 'absolute' => TRUE])->toString(),
'Submitted to correct URL.'
);
// Test an empty search via the block form, from the front page.
$terms = ['keys' => ''];
$this->drupalGet('');
$this->submitForm($terms, 'Search');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->statusMessageContains('Enter some keywords', 'error');
// Confirm that the user is redirected to the search page, when form is
// submitted empty.
$this->assertEquals(
$this->getUrl(),
Url::fromRoute('search.view_' . $entity_id, [], ['query' => ['keys' => ''], 'absolute' => TRUE])->toString(),
'Redirected to correct URL.'
);
// Test that after entering a too-short keyword in the form, you can then
// search again with a longer keyword. First test using the block form.
$this->drupalGet('node');
$this->submitForm(['keys' => $this->randomMachineName(1)], 'Search');
$this->assertSession()->statusMessageContains('You must include at least one keyword to match in the content', 'warning');
$this->assertSession()->statusMessageNotContains('Enter some keywords');
$this->submitForm(['keys' => $this->randomMachineName()], 'Search', 'search-block-form');
$this->assertSession()->statusMessageNotContains('You must include at least one keyword to match in the content');
// Same test again, using the search page form for the second search this
// time.
$this->drupalGet('node');
$this->submitForm(['keys' => $this->randomMachineName(1)], 'Search');
$this->submitForm(['keys' => $this->randomMachineName()], 'Search', 'search-form');
$this->assertSession()->statusMessageNotContains('You must include at least one keyword to match in the content');
// Edit the block configuration so that it searches users instead of nodes,
// and test.
$this->drupalGet('admin/structure/block/manage/' . $block->id());
$this->submitForm(['settings[page_id]' => 'user_search'], 'Save block');
$name = $this->adminUser->getAccountName();
$this->drupalGet('node');
$this->submitForm(['keys' => $name], 'Search');
$this->assertSession()->linkExists($name);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
use Drupal\comment\Tests\CommentTestTrait;
use Drupal\Tests\BrowserTestBase;
/**
* Tests that comment count display toggles properly on comment status of node.
*
* Issue 537278
*
* - Nodes with comment status set to Open should always how comment counts
* - Nodes with comment status set to Closed should show comment counts
* only when there are comments
* - Nodes with comment status set to Hidden should never show comment counts
*
* @group search
*/
class SearchCommentCountToggleTest extends BrowserTestBase {
use CommentTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'comment', 'search', 'dblog'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to search and post comments.
*
* @var \Drupal\user\UserInterface
*/
protected $searchingUser;
/**
* Array of nodes available to search.
*
* @var \Drupal\node\NodeInterface[]
*/
protected $searchableNodes;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
// Create searching user.
$this->searchingUser = $this->drupalCreateUser([
'search content',
'access content',
'access comments',
'post comments',
'skip comment approval',
]);
// Log in with sufficient privileges.
$this->drupalLogin($this->searchingUser);
// Add a comment field.
$this->addDefaultCommentField('node', 'article');
// Create initial nodes.
$node_params = ['type' => 'article', 'body' => [['value' => 'SearchCommentToggleTestCase']]];
$this->searchableNodes['1 comment'] = $this->drupalCreateNode($node_params);
$this->searchableNodes['0 comments'] = $this->drupalCreateNode($node_params);
// Create a comment array
$edit_comment = [];
$edit_comment['subject[0][value]'] = $this->randomMachineName();
$edit_comment['comment_body[0][value]'] = $this->randomMachineName();
// Post comment to the test node with comment
$this->drupalGet('comment/reply/node/' . $this->searchableNodes['1 comment']->id() . '/comment');
$this->submitForm($edit_comment, 'Save');
// First update the index. This does the initial processing.
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
}
/**
* Verify that comment count display toggles properly on comment status of node.
*/
public function testSearchCommentCountToggle(): void {
// Search for the nodes by string in the node body.
$edit = [
'keys' => "'SearchCommentToggleTestCase'",
];
$this->drupalGet('search/node');
// Test comment count display for nodes with comment status set to Open
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains('0 comments');
$this->assertSession()->pageTextContains('1 comment');
// Test comment count display for nodes with comment status set to Closed
$this->searchableNodes['0 comments']->set('comment', CommentItemInterface::CLOSED);
$this->searchableNodes['0 comments']->save();
$this->searchableNodes['1 comment']->set('comment', CommentItemInterface::CLOSED);
$this->searchableNodes['1 comment']->save();
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextNotContains('0 comments');
$this->assertSession()->pageTextContains('1 comment');
// Test comment count display for nodes with comment status set to Hidden
$this->searchableNodes['0 comments']->set('comment', CommentItemInterface::HIDDEN);
$this->searchableNodes['0 comments']->save();
$this->searchableNodes['1 comment']->set('comment', CommentItemInterface::HIDDEN);
$this->searchableNodes['1 comment']->save();
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextNotContains('0 comments');
$this->assertSession()->pageTextNotContains('1 comment');
}
}

View File

@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Behat\Mink\Exception\ResponseTextException;
use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
use Drupal\comment\Tests\CommentTestTrait;
use Drupal\field\Entity\FieldConfig;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\Traits\Core\CronRunTrait;
use Drupal\user\RoleInterface;
use Drupal\filter\Entity\FilterFormat;
/**
* Tests integration searching comments.
*
* @group search
*/
class SearchCommentTest extends BrowserTestBase {
use CommentTestTrait;
use CronRunTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['filter', 'node', 'comment', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Test subject for comments.
*
* @var string
*/
protected $commentSubject;
/**
* ID for the administrator role.
*
* @var string
*/
protected $adminRole;
/**
* A user with various administrative permissions.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* Test node for searching.
*
* @var \Drupal\node\NodeInterface
*/
protected $node;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
$full_html_format = FilterFormat::create([
'format' => 'full_html',
'name' => 'Full HTML',
'weight' => 1,
'filters' => [],
]);
$full_html_format->save();
// Create and log in an administrative user having access to the Full HTML
// text format.
$permissions = [
'administer filters',
$full_html_format->getPermissionName(),
'administer permissions',
'create page content',
'post comments',
'skip comment approval',
'access comments',
];
$this->adminUser = $this->drupalCreateUser($permissions);
$this->drupalLogin($this->adminUser);
// Add a comment field.
$this->addDefaultCommentField('node', 'article');
}
/**
* Verify that comments are rendered using proper format in search results.
*/
public function testSearchResultsComment(): void {
$node_storage = $this->container->get('entity_type.manager')->getStorage('node');
// Create basic_html format that escapes all HTML.
$basic_html_format = FilterFormat::create([
'format' => 'basic_html',
'name' => 'Basic HTML',
'weight' => 1,
'filters' => [
'filter_html_escape' => ['status' => 1],
],
'roles' => [RoleInterface::AUTHENTICATED_ID],
]);
$basic_html_format->save();
$comment_body = 'Test comment body';
// Make preview optional.
$field = FieldConfig::loadByName('node', 'article', 'comment');
$field->setSetting('preview', DRUPAL_OPTIONAL);
$field->save();
// Allow anonymous users to search content.
$edit = [
RoleInterface::ANONYMOUS_ID . '[search content]' => 1,
RoleInterface::ANONYMOUS_ID . '[access comments]' => 1,
RoleInterface::ANONYMOUS_ID . '[post comments]' => 1,
];
$this->drupalGet('admin/people/permissions');
$this->submitForm($edit, 'Save permissions');
// Create a node.
$node = $this->drupalCreateNode(['type' => 'article']);
// Post a comment using 'Full HTML' text format.
$edit_comment = [];
$edit_comment['subject[0][value]'] = 'Test comment subject';
$edit_comment['comment_body[0][value]'] = '<h1>' . $comment_body . '</h1>';
$full_html_format_id = 'full_html';
$edit_comment['comment_body[0][format]'] = $full_html_format_id;
$this->drupalGet('comment/reply/node/' . $node->id() . '/comment');
$this->submitForm($edit_comment, 'Save');
// Post a comment with an evil script tag in the comment subject and a
// script tag nearby a keyword in the comment body. Use the 'FULL HTML' text
// format so the script tag stored.
$edit_comment2 = [];
$edit_comment2['subject[0][value]'] = "<script>alert('subject_keyword');</script>";
$edit_comment2['comment_body[0][value]'] = "nearby-keyword<script>alert('something generic');</script>";
$edit_comment2['comment_body[0][format]'] = $full_html_format_id;
$this->drupalGet('comment/reply/node/' . $node->id() . '/comment');
$this->submitForm($edit_comment2, 'Save');
// Post a comment with a keyword inside an evil script tag in the comment
// body. Use the 'FULL HTML' text format so the script tag is stored.
$edit_comment3 = [];
$edit_comment3['subject[0][value]'] = 'a subject';
$edit_comment3['comment_body[0][value]'] = "<script>alert('inside-keyword');</script>";
$edit_comment3['comment_body[0][format]'] = $full_html_format_id;
$this->drupalGet('comment/reply/node/' . $node->id() . '/comment');
$this->submitForm($edit_comment3, 'Save');
// Invoke search index update.
$this->drupalLogout();
$this->cronRun();
// Search for the comment subject.
$edit = [
'keys' => "'" . $edit_comment['subject[0][value]'] . "'",
];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$node_storage->resetCache([$node->id()]);
$node2 = $node_storage->load($node->id());
$this->assertSession()->pageTextContains($node2->label());
$this->assertSession()->pageTextContains($edit_comment['subject[0][value]']);
// Search for the comment body.
$edit = [
'keys' => "'" . $comment_body . "'",
];
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains($node2->label());
// Verify that comment is rendered using proper format.
$this->assertSession()->pageTextContains($comment_body);
// Verify that HTML in comment body is not hidden.
$this->assertSession()->pageTextNotContains('n/a');
$this->assertSession()->assertNoEscaped($edit_comment['comment_body[0][value]']);
// Search for the evil script comment subject.
$edit = [
'keys' => 'subject_keyword',
];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
// Verify the evil comment subject is escaped in search results.
$this->assertSession()->responseContains('&lt;script&gt;alert(&#039;<strong>subject_keyword</strong>&#039;);');
$this->assertSession()->responseNotContains('<script>');
// Search for the keyword near the evil script tag in the comment body.
$edit = [
'keys' => 'nearby-keyword',
];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
// Verify that nearby script tag in the evil comment body is stripped from
// search results.
$this->assertSession()->responseContains('<strong>nearby-keyword</strong>');
$this->assertSession()->responseNotContains('<script>');
// Search for contents inside the evil script tag in the comment body.
$edit = [
'keys' => 'inside-keyword',
];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
// @todo Verify the actual search results.
// https://www.drupal.org/node/2551135
// Verify there is no script tag in search results.
$this->assertSession()->responseNotContains('<script>');
// Hide comments.
$this->drupalLogin($this->adminUser);
$node->set('comment', CommentItemInterface::HIDDEN);
$node->save();
// Invoke search index update.
$this->drupalLogout();
$this->cronRun();
// Search for $title.
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains('Your search yielded no results.');
}
/**
* Verify access rules for comment indexing with different permissions.
*/
public function testSearchResultsCommentAccess(): void {
$comment_body = 'Test comment body';
$this->commentSubject = 'Test comment subject';
$roles = $this->adminUser->getRoles(TRUE);
$this->adminRole = $roles[0];
// Create a node.
// Make preview optional.
$field = FieldConfig::loadByName('node', 'article', 'comment');
$field->setSetting('preview', DRUPAL_OPTIONAL);
$field->save();
$this->node = $this->drupalCreateNode(['type' => 'article']);
// Post a comment using 'Full HTML' text format.
$edit_comment = [];
$edit_comment['subject[0][value]'] = $this->commentSubject;
$edit_comment['comment_body[0][value]'] = '<h1>' . $comment_body . '</h1>';
$this->drupalGet('comment/reply/node/' . $this->node->id() . '/comment');
$this->submitForm($edit_comment, 'Save');
$this->drupalLogout();
$this->setRolePermissions(RoleInterface::ANONYMOUS_ID);
$this->assertCommentAccess(FALSE, 'Anon user has search permission but no access comments permission, comments should not be indexed');
$this->setRolePermissions(RoleInterface::ANONYMOUS_ID, TRUE);
$this->assertCommentAccess(TRUE, 'Anon user has search permission and access comments permission, comments should be indexed');
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/people/permissions');
// Disable search access for authenticated user to test admin user.
$this->setRolePermissions(RoleInterface::AUTHENTICATED_ID, FALSE, FALSE);
$this->setRolePermissions($this->adminRole);
$this->assertCommentAccess(FALSE, 'Admin user has search permission but no access comments permission, comments should not be indexed');
$this->drupalGet('node/' . $this->node->id());
$this->setRolePermissions($this->adminRole, TRUE);
$this->assertCommentAccess(TRUE, 'Admin user has search permission and access comments permission, comments should be indexed');
$this->setRolePermissions(RoleInterface::AUTHENTICATED_ID);
$this->assertCommentAccess(FALSE, 'Authenticated user has search permission but no access comments permission, comments should not be indexed');
$this->setRolePermissions(RoleInterface::AUTHENTICATED_ID, TRUE);
$this->assertCommentAccess(TRUE, 'Authenticated user has search permission and access comments permission, comments should be indexed');
// Verify that access comments permission is inherited from the
// authenticated role.
$this->setRolePermissions(RoleInterface::AUTHENTICATED_ID, TRUE, FALSE);
$this->setRolePermissions($this->adminRole);
$this->assertCommentAccess(TRUE, 'Admin user has search permission and no access comments permission, but comments should be indexed because admin user inherits authenticated user\'s permission to access comments');
// Verify that search content permission is inherited from the authenticated
// role.
$this->setRolePermissions(RoleInterface::AUTHENTICATED_ID, TRUE, TRUE);
$this->setRolePermissions($this->adminRole, TRUE, FALSE);
$this->assertCommentAccess(TRUE, 'Admin user has access comments permission and no search permission, but comments should be indexed because admin user inherits authenticated user\'s permission to search');
}
/**
* Set permissions for role.
*/
public function setRolePermissions($rid, $access_comments = FALSE, $search_content = TRUE) {
$permissions = [
'access comments' => $access_comments,
'search content' => $search_content,
];
user_role_change_permissions($rid, $permissions);
}
/**
* Update search index and search for comment.
*
* @internal
*/
public function assertCommentAccess(bool $assume_access, string $message): void {
// Invoke search index update.
\Drupal::service('search.index')->markForReindex('node_search', $this->node->id());
$this->cronRun();
// Search for the comment subject.
$edit = [
'keys' => "'" . $this->commentSubject . "'",
];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
try {
if ($assume_access) {
$this->assertSession()->pageTextContains($this->node->label());
$this->assertSession()->pageTextContains($this->commentSubject);
}
else {
$this->assertSession()->pageTextContains('Your search yielded no results.');
}
}
catch (ResponseTextException $exception) {
$this->fail($message);
}
}
/**
* Verify that 'add new comment' does not appear in search results or index.
*/
public function testAddNewComment(): void {
// Create a node with a short body.
$settings = [
'type' => 'article',
'title' => 'short title',
'body' => [['value' => 'short body text']],
];
$user = $this->drupalCreateUser([
'search content',
'create article content',
'access content',
'post comments',
'access comments',
]);
$this->drupalLogin($user);
$node = $this->drupalCreateNode($settings);
// Verify that if you view the node on its own page, 'add new comment'
// is there.
$this->drupalGet('node/' . $node->id());
$this->assertSession()->pageTextContains('Add new comment');
// Run cron to index this page.
$this->drupalLogout();
$this->cronRun();
// Search for 'comment'. Should be no results.
$this->drupalLogin($user);
$this->drupalGet('search/node');
$this->submitForm(['keys' => 'comment'], 'Search');
$this->assertSession()->pageTextContains('Your search yielded no results');
// Search for the node title. Should be found, and 'Add new comment' should
// not be part of the search snippet.
$this->drupalGet('search/node');
$this->submitForm(['keys' => 'short'], 'Search');
$this->assertSession()->pageTextContains($node->label());
$this->assertSession()->pageTextNotContains('Add new comment');
}
}

View File

@@ -0,0 +1,444 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\search\Entity\SearchPage;
use Drupal\Tests\BrowserTestBase;
/**
* Verify the search config settings form.
*
* @group search
* @group #slow
*/
class SearchConfigSettingsFormTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'block',
'dblog',
'node',
'search',
'search_extra_type',
'test_page_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* User who can search and administer search.
*
* @var \Drupal\user\UserInterface
*/
protected $searchUser;
/**
* Node indexed for searching.
*
* @var \Drupal\node\NodeInterface
*/
protected $searchNode;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Log in as a user that can create and search content.
$this->searchUser = $this->drupalCreateUser([
'search content',
'administer search',
'administer nodes',
'bypass node access',
'access user profiles',
'administer users',
'administer blocks',
'access site reports',
]);
$this->drupalLogin($this->searchUser);
// Add a single piece of content and index it.
$node = $this->drupalCreateNode();
$this->searchNode = $node;
// Link the node to itself to test that it's only indexed once. The content
// also needs the word "pizza" so we can use it as the search keyword.
$body_key = 'body[0][value]';
$edit[$body_key] = Link::fromTextAndUrl($node->label(), $node->toUrl())->toString() . ' pizza sandwich';
$this->drupalGet('node/' . $node->id() . '/edit');
$this->submitForm($edit, 'Save');
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
// Enable the search block.
$this->drupalPlaceBlock('search_form_block');
$this->drupalPlaceBlock('local_tasks_block', ['id' => 'local_tasks']);
$this->drupalPlaceBlock('page_title_block');
}
/**
* Verifies the search settings form.
*/
public function testSearchSettingsPage(): void {
// Test that the settings form displays the correct count of items left to index.
$this->drupalGet('admin/config/search/pages');
$this->assertSession()->pageTextContains('There are 0 items left to index.');
// Test the re-index button.
$this->drupalGet('admin/config/search/pages');
$this->submitForm([], 'Re-index site');
$this->assertSession()->pageTextContains('Are you sure you want to re-index the site');
$this->drupalGet('admin/config/search/pages/reindex');
$this->submitForm([], 'Re-index site');
$this->assertSession()->statusMessageContains('All search indexes will be rebuilt', 'status');
$this->drupalGet('admin/config/search/pages');
$this->assertSession()->pageTextContains('There is 1 item left to index.');
// Test that the form saves with the default values.
$this->drupalGet('admin/config/search/pages');
$this->submitForm([], 'Save configuration');
$this->assertSession()->statusMessageContains('The configuration options have been saved.', 'status');
// Test that the form does not save with an invalid word length.
$edit = [
'minimum_word_size' => $this->randomMachineName(3),
];
$this->drupalGet('admin/config/search/pages');
$this->submitForm($edit, 'Save configuration');
$this->assertSession()->statusMessageNotContains('The configuration options have been saved.');
$this->assertSession()->statusMessageContains('Minimum word length to index must be a number.', 'error');
// Test logging setting. It should be off by default.
$text = $this->randomMachineName(5);
$this->drupalGet('search/node');
$this->submitForm(['keys' => $text], 'Search');
$this->drupalGet('admin/reports/dblog');
$this->assertSession()->linkNotExists('Searched Content for ' . $text . '.', 'Search was not logged');
// Turn on logging.
$edit = ['logging' => TRUE];
$this->drupalGet('admin/config/search/pages');
$this->submitForm($edit, 'Save configuration');
$text = $this->randomMachineName(5);
$this->drupalGet('search/node');
$this->submitForm(['keys' => $text], 'Search');
$this->drupalGet('admin/reports/dblog');
$this->assertSession()->linkExists('Searched Content for ' . $text . '.', 0, 'Search was logged');
}
/**
* Verifies plugin-supplied settings form.
*/
public function testSearchModuleSettingsPage(): void {
$this->drupalGet('admin/config/search/pages');
$this->clickLink('Edit', 1);
// Ensure that the default setting was picked up from the default config
$this->assertTrue($this->assertSession()->optionExists('edit-extra-type-settings-boost', 'bi')->isSelected());
// Change extra type setting and also modify a common search setting.
$edit = [
'extra_type_settings[boost]' => 'ii',
];
$this->submitForm($edit, 'Save search page');
// Ensure that the modifications took effect.
$this->assertSession()->statusMessageContains("The Dummy search type search page has been updated.", 'status');
$this->drupalGet('admin/config/search/pages/manage/dummy_search_type');
$this->assertTrue($this->assertSession()->optionExists('edit-extra-type-settings-boost', 'ii')->isSelected());
}
/**
* Verifies that you can disable individual search plugins.
*/
public function testSearchModuleDisabling(): void {
// Array of search plugins to test: 'keys' are the keywords to search for,
// and 'text' is the text to assert is on the results page.
$plugin_info = [
'node_search' => [
'keys' => 'pizza',
'text' => $this->searchNode->label(),
],
'user_search' => [
'keys' => $this->searchUser->getAccountName(),
'text' => $this->searchUser->getEmail(),
],
'dummy_search_type' => [
'keys' => 'foo',
'text' => 'Dummy search snippet to display',
],
];
$plugins = array_keys($plugin_info);
/** @var \Drupal\search\SearchPageInterface[] $entities */
$entities = SearchPage::loadMultiple();
// Disable all of the search pages.
foreach ($entities as $entity) {
$entity->disable()->save();
}
// Test each plugin if it's enabled as the only search plugin.
foreach ($entities as $entity_id => $entity) {
$this->setDefaultThroughUi($entity_id);
// Run a search from the correct search URL.
$info = $plugin_info[$entity_id];
$this->drupalGet('search/' . $entity->getPath(), ['query' => ['keys' => $info['keys']]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextNotContains('no results');
$this->assertSession()->pageTextContains($info['text']);
// Verify that other plugin search tab labels are not visible.
foreach ($plugins as $other) {
if ($other != $entity_id) {
$path = 'search/' . $entities[$other]->getPath();
$this->assertSession()->elementNotExists('xpath', '//div[@id="block-local-tasks"]//li/a[@data-drupal-link-system-path="' . $path . '"]');
}
}
// Run a search from the search block on the node page. Verify you get
// to this plugin's search results page.
$terms = ['keys' => $info['keys']];
$this->drupalGet('node');
$this->submitForm($terms, 'Search');
$current = $this->getURL();
$expected = Url::fromRoute('search.view_' . $entity->id(), [], ['query' => ['keys' => $info['keys']], 'absolute' => TRUE])->toString();
$this->assertEquals($expected, $current, 'Block redirected to right search page');
// Try an invalid search path, which should 404.
$this->drupalGet('search/not_a_plugin_path');
$this->assertSession()->statusCodeEquals(404);
$entity->disable()->save();
}
// Set the node search as default.
$this->setDefaultThroughUi('node_search');
// Test with all search plugins enabled. When you go to the search
// page or run search, all plugins should be shown.
foreach ($entities as $entity) {
$entity->enable()->save();
}
\Drupal::service('router.builder')->rebuild();
$paths = [
['path' => 'search/node', 'options' => ['query' => ['keys' => 'pizza']]],
['path' => 'search/node', 'options' => []],
];
foreach ($paths as $item) {
$this->drupalGet($item['path'], $item['options']);
foreach ($plugins as $entity_id) {
$path = 'search/' . $entities[$entity_id]->getPath();
$label = $entities[$entity_id]->label();
$this->assertSession()->elementTextContains('xpath', '//div[@id="block-local-tasks"]//li/a[@data-drupal-link-system-path="' . $path . '"]', $label);
}
}
}
/**
* Tests the ordering of search pages on a clean install.
*/
public function testDefaultSearchPageOrdering(): void {
$this->drupalGet('search');
$elements = $this->xpath('//div[@id="block-local-tasks"]//a');
$this->assertSame(Url::fromRoute('search.view_node_search')->toString(), $elements[0]->getAttribute('href'));
$this->assertSame(Url::fromRoute('search.view_dummy_search_type')->toString(), $elements[1]->getAttribute('href'));
$this->assertSame(Url::fromRoute('search.view_user_search')->toString(), $elements[2]->getAttribute('href'));
}
/**
* Tests multiple search pages of the same type.
*/
public function testMultipleSearchPages(): void {
$this->assertDefaultSearch('node_search', 'The default page is set to the installer default.');
$search_storage = \Drupal::entityTypeManager()->getStorage('search_page');
$entities = $search_storage->loadMultiple();
$search_storage->delete($entities);
$this->assertDefaultSearch(FALSE);
// Ensure that no search pages are configured.
$this->drupalGet('admin/config/search/pages');
$this->assertSession()->pageTextContains('No search pages have been configured.');
// Add a search page.
$edit = [];
$edit['search_type'] = 'search_extra_type_search';
$this->submitForm($edit, 'Add search page');
$this->assertSession()->titleEquals('Add new search page | Drupal');
$first = [];
$first['label'] = $this->randomString();
$first_id = $first['id'] = $this->randomMachineName(8);
$first['path'] = $this->randomMachineName(8);
$this->submitForm($first, 'Save');
$this->assertDefaultSearch($first_id, 'The default page matches the only search page.');
$this->assertSession()->statusMessageContains("The {$first['label']} search page has been added.", 'status');
// Attempt to add a search page with an existing path.
$edit = [];
$edit['search_type'] = 'search_extra_type_search';
$this->submitForm($edit, 'Add search page');
$edit = [];
$edit['label'] = $this->randomString();
$edit['id'] = $this->randomMachineName(8);
$edit['path'] = $first['path'];
$this->submitForm($edit, 'Save');
$this->assertSession()->statusMessageContains('The search page path must be unique.', 'error');
// Add a second search page.
$second = [];
$second['label'] = $this->randomString();
$second_id = $second['id'] = $this->randomMachineName(8);
$second['path'] = $this->randomMachineName(8);
$this->submitForm($second, 'Save');
$this->assertDefaultSearch($first_id, 'The default page matches the only search page.');
// Ensure both search pages have their tabs displayed.
$this->drupalGet('search');
$elements = $this->xpath('//div[@id="block-local-tasks"]//a');
$this->assertSame(Url::fromRoute('search.view_' . $first_id)->toString(), $elements[0]->getAttribute('href'));
$this->assertSame(Url::fromRoute('search.view_' . $second_id)->toString(), $elements[1]->getAttribute('href'));
// Switch the weight of the search pages and check the order of the tabs.
$edit = [
'entities[' . $first_id . '][weight]' => 10,
'entities[' . $second_id . '][weight]' => -10,
];
$this->drupalGet('admin/config/search/pages');
$this->submitForm($edit, 'Save configuration');
$this->drupalGet('search');
$elements = $this->xpath('//div[@id="block-local-tasks"]//a');
$this->assertSame(Url::fromRoute('search.view_' . $second_id)->toString(), $elements[0]->getAttribute('href'));
$this->assertSame(Url::fromRoute('search.view_' . $first_id)->toString(), $elements[1]->getAttribute('href'));
// Check the initial state of the search pages.
$this->drupalGet('admin/config/search/pages');
$this->verifySearchPageOperations($first_id, TRUE, FALSE, FALSE, FALSE);
$this->verifySearchPageOperations($second_id, TRUE, TRUE, TRUE, FALSE);
// Change the default search page.
$this->clickLink('Set as default');
$this->assertSession()->statusMessageContains("The default search page is now {$second['label']}. Be sure to check the ordering of your search pages.", 'status');
$this->verifySearchPageOperations($first_id, TRUE, TRUE, TRUE, FALSE);
$this->verifySearchPageOperations($second_id, TRUE, FALSE, FALSE, FALSE);
// Disable the first search page.
$this->clickLink('Disable');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->linkNotExists('Disable');
$this->verifySearchPageOperations($first_id, TRUE, TRUE, FALSE, TRUE);
$this->verifySearchPageOperations($second_id, TRUE, FALSE, FALSE, FALSE);
// Enable the first search page.
$this->clickLink('Enable');
$this->assertSession()->statusCodeEquals(200);
$this->verifySearchPageOperations($first_id, TRUE, TRUE, TRUE, FALSE);
$this->verifySearchPageOperations($second_id, TRUE, FALSE, FALSE, FALSE);
// Test deleting.
$this->clickLink('Delete');
$this->assertSession()->pageTextContains("Are you sure you want to delete the search page {$first['label']}?");
$this->submitForm([], 'Delete');
$this->assertSession()->statusMessageContains("The search page {$first['label']} has been deleted.", 'status');
$this->verifySearchPageOperations($first_id, FALSE, FALSE, FALSE, FALSE);
}
/**
* Tests that the enable/disable/default routes are protected from CSRF.
*/
public function testRouteProtection(): void {
// Ensure that the enable and disable routes are protected.
$this->drupalGet('admin/config/search/pages/manage/node_search/enable');
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet('admin/config/search/pages/manage/node_search/disable');
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet('admin/config/search/pages/manage/node_search/set-default');
$this->assertSession()->statusCodeEquals(403);
}
/**
* Checks that the search page operations match expectations.
*
* @param string $id
* The search page ID to check.
* @param bool $edit
* Whether the edit link is expected.
* @param bool $delete
* Whether the delete link is expected.
* @param bool $disable
* Whether the disable link is expected.
* @param bool $enable
* Whether the enable link is expected.
*/
protected function verifySearchPageOperations($id, $edit, $delete, $disable, $enable) {
if ($edit) {
$this->assertSession()->linkByHrefExists("admin/config/search/pages/manage/$id");
}
else {
$this->assertSession()->linkByHrefNotExists("admin/config/search/pages/manage/$id");
}
if ($delete) {
$this->assertSession()->linkByHrefExists("admin/config/search/pages/manage/$id/delete");
}
else {
$this->assertSession()->linkByHrefNotExists("admin/config/search/pages/manage/$id/delete");
}
if ($disable) {
$this->assertSession()->linkByHrefExists("admin/config/search/pages/manage/$id/disable");
}
else {
$this->assertSession()->linkByHrefNotExists("admin/config/search/pages/manage/$id/disable");
}
if ($enable) {
$this->assertSession()->linkByHrefExists("admin/config/search/pages/manage/$id/enable");
}
else {
$this->assertSession()->linkByHrefNotExists("admin/config/search/pages/manage/$id/enable");
}
}
/**
* Checks that the default search page matches expectations.
*
* @param string|false $expected
* The expected search page.
* @param string $message
* (optional) A message to display with the assertion.
*
* @internal
*/
protected function assertDefaultSearch($expected, string $message = ''): void {
/** @var \Drupal\search\SearchPageRepositoryInterface $search_page_repository */
$search_page_repository = \Drupal::service('search.search_page_repository');
$this->assertSame($expected, $search_page_repository->getDefaultSearchPage(), $message);
}
/**
* Sets a search page as the default in the UI.
*
* @param string $entity_id
* The search page entity ID to enable.
*/
protected function setDefaultThroughUi($entity_id) {
$this->drupalGet('admin/config/search/pages');
preg_match('|href="([^"]+' . $entity_id . '/set-default[^"]+)"|', $this->getSession()->getPage()->getContent(), $matches);
$this->drupalGet($this->getAbsoluteUrl($matches[1]));
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
/**
* Tests searching with date filters that exclude some translations.
*
* @group search
*/
class SearchDateIntervalTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'search_date_query_alter',
'node',
'search',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create and log in user.
$test_user = $this->drupalCreateUser([
'access content',
'search content',
'use advanced search',
'administer nodes',
'administer languages',
'access administration pages',
'administer site configuration',
]);
$this->drupalLogin($test_user);
// Add a new language.
ConfigurableLanguage::createFromLangcode('es')->save();
// Set up times to be applied to the English and Spanish translations of the
// node create time, so that they are filtered in/out in the
// search_date_query_alter test module.
$created_time_en = new \DateTime('February 10 2016 10PM');
$created_time_es = new \DateTime('March 19 2016 10PM');
$default_format = filter_default_format();
$node = $this->drupalCreateNode([
'title' => 'Node EN',
'type' => 'page',
'body' => [
'value' => $this->randomMachineName(32),
'format' => $default_format,
],
'langcode' => 'en',
'created' => $created_time_en->getTimestamp(),
]);
// Add Spanish translation to the node.
$translation = $node->addTranslation('es', ['title' => 'Node ES']);
$translation->body->value = $this->randomMachineName(32);
$translation->created->value = $created_time_es->getTimestamp();
$node->save();
// Update the index.
$plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
$plugin->updateIndex();
}
/**
* Tests searching with date filters that exclude some translations.
*/
public function testDateIntervalQueryAlter(): void {
// Search for keyword node.
$edit = ['keys' => 'node'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
// The nodes must have the same node ID but the created date is different.
// So only the Spanish translation must appear.
$this->assertSession()->linkExists('Node ES', 0, 'Spanish translation found in search results');
$this->assertSession()->linkNotExists('Node EN', 'Search results do not contain English node');
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Verifies that a form embedded in search results works.
*
* @group search
*/
class SearchEmbedFormTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search', 'search_embedded_form'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Node used for testing.
*
* @var \Drupal\node\NodeInterface
*/
protected $node;
/**
* Count of how many times the form has been submitted.
*
* @var int
*/
protected $submitCount = 0;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create a user and a node, and update the search index.
$test_user = $this->drupalCreateUser([
'access content',
'search content',
'administer nodes',
]);
$this->drupalLogin($test_user);
$this->node = $this->drupalCreateNode();
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
// Set up a dummy initial count of times the form has been submitted.
$this->submitCount = \Drupal::state()->get('search_embedded_form.submit_count');
$this->refreshVariables();
}
/**
* Tests that the embedded form appears and can be submitted.
*/
public function testEmbeddedForm(): void {
// First verify we can submit the form from the module's page.
$this->drupalGet('search_embedded_form');
$this->submitForm(['name' => 'John'], 'Send away');
$this->assertSession()->pageTextContains('Test form was submitted');
$count = \Drupal::state()->get('search_embedded_form.submit_count');
$this->assertEquals($this->submitCount + 1, $count, 'Form submission count is correct');
$this->submitCount = $count;
// Now verify that we can see and submit the form from the search results.
$this->drupalGet('search/node', ['query' => ['keys' => $this->node->label()]]);
$this->assertSession()->pageTextContains('Your name');
$this->submitForm(['name' => 'John'], 'Send away');
$this->assertSession()->pageTextContains('Test form was submitted');
$count = \Drupal::state()->get('search_embedded_form.submit_count');
$this->assertEquals($this->submitCount + 1, $count, 'Form submission count is correct');
$this->submitCount = $count;
// Now verify that if we submit the search form, it doesn't count as
// our form being submitted.
$this->drupalGet('search');
$this->submitForm(['keys' => 'foo'], 'Search');
$this->assertSession()->pageTextNotContains('Test form was submitted');
$count = \Drupal::state()->get('search_embedded_form.submit_count');
$this->assertEquals($this->submitCount, $count, 'Form submission count is correct');
$this->submitCount = $count;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests that searching for a phrase gets the correct page count.
*
* @group search
*/
class SearchExactTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests that the correct number of pager links are found for both keywords and phrases.
*/
public function testExactQuery(): void {
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Log in with sufficient privileges.
$user = $this->drupalCreateUser(['create page content', 'search content']);
$this->drupalLogin($user);
$settings = [
'type' => 'page',
'title' => 'Simple Node',
];
// Create nodes with exact phrase.
for ($i = 0; $i <= 17; $i++) {
$settings['body'] = [['value' => 'love pizza']];
$this->drupalCreateNode($settings);
}
// Create nodes containing keywords.
for ($i = 0; $i <= 17; $i++) {
$settings['body'] = [['value' => 'love cheesy pizza']];
$this->drupalCreateNode($settings);
}
// Create another node and save it for later.
$settings['body'] = [['value' => 'Druplicon']];
$node = $this->drupalCreateNode($settings);
// Update the search index.
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
// Refresh variables after the treatment.
$this->refreshVariables();
// Test that the correct number of pager links are found for keyword search.
$edit = ['keys' => 'love pizza'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->linkByHrefExists('page=1', 0, '2nd page link is found for keyword search.');
$this->assertSession()->linkByHrefExists('page=2', 0, '3rd page link is found for keyword search.');
$this->assertSession()->linkByHrefExists('page=3', 0, '4th page link is found for keyword search.');
$this->assertSession()->linkByHrefNotExists('page=4', '5th page link is not found for keyword search.');
// Test that the correct number of pager links are found for exact phrase search.
$edit = ['keys' => '"love pizza"'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->linkByHrefExists('page=1', 0, '2nd page link is found for exact phrase search.');
$this->assertSession()->linkByHrefNotExists('page=2', '3rd page link is not found for exact phrase search.');
// Check that with post settings turned on the post information is displayed.
$node_type_config = \Drupal::configFactory()->getEditable('node.type.page');
$node_type_config->set('display_submitted', TRUE);
$node_type_config->save();
$edit = ['keys' => 'Druplicon'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains($user->getAccountName());
$this->assertSession()->pageTextContains($this->container->get('date.formatter')->format($node->getChangedTime(), 'short'));
// Check that with post settings turned off the user and changed date
// information is not displayed.
$node_type_config->set('display_submitted', FALSE);
$node_type_config->save();
$edit = ['keys' => 'Druplicon'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextNotContains($user->getAccountName());
$this->assertSession()->pageTextNotContains($this->container->get('date.formatter')->format($node->getChangedTime(), 'short'));
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Component\Utility\Html;
use Drupal\Tests\BrowserTestBase;
/**
* Verify the search without keywords set and extra conditions.
*
* Verifies that a plugin can override the isSearchExecutable() method to allow
* searching without keywords set and that GET query parameters are made
* available to plugins during search execution.
*
* @group search
*/
class SearchKeywordsConditionsTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'comment',
'search',
'search_extra_type',
'test_page_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to search and post comments.
*
* @var \Drupal\user\UserInterface
*/
protected $searchingUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create searching user.
$this->searchingUser = $this->drupalCreateUser([
'search content',
'access content',
'access comments',
'skip comment approval',
]);
// Log in with sufficient privileges.
$this->drupalLogin($this->searchingUser);
}
/**
* Verify the keywords are captured and conditions respected.
*/
public function testSearchKeywordsConditions(): void {
// No keys, not conditions - no results.
$this->drupalGet('search/dummy_path');
$this->assertSession()->pageTextNotContains('Dummy search snippet to display');
// With keys - get results.
$keys = 'bike shed ' . $this->randomMachineName();
$this->drupalGet("search/dummy_path", ['query' => ['keys' => $keys]]);
$this->assertSession()->pageTextContains("Dummy search snippet to display. Keywords: {$keys}");
$keys = 'blue drop ' . $this->randomMachineName();
$this->drupalGet("search/dummy_path", ['query' => ['keys' => $keys]]);
$this->assertSession()->pageTextContains("Dummy search snippet to display. Keywords: {$keys}");
// Add some conditions and keys.
$keys = 'moving drop ' . $this->randomMachineName();
$this->drupalGet("search/dummy_path", ['query' => ['keys' => 'bike', 'search_conditions' => $keys]]);
$this->assertSession()->pageTextContains("Dummy search snippet to display.");
$this->assertSession()->responseContains(Html::escape(print_r(['keys' => 'bike', 'search_conditions' => $keys], TRUE)));
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
/**
* Tests advanced search with different languages added.
*
* @group search
*/
class SearchLanguageTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['language', 'node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Array of nodes available to search.
*
* @var \Drupal\node\NodeInterface[]
*/
protected $searchableNodes;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create and log in user.
$test_user = $this->drupalCreateUser([
'access content',
'search content',
'use advanced search',
'administer nodes',
'administer languages',
'access administration pages',
'administer site configuration',
]);
$this->drupalLogin($test_user);
// Add a new language.
ConfigurableLanguage::createFromLangcode('es')->save();
// Make the body field translatable. The title is already translatable by
// definition. The parent class has already created the article and page
// content types.
$field_storage = FieldStorageConfig::loadByName('node', 'body');
$field_storage->setTranslatable(TRUE);
$field_storage->save();
// Create a few page nodes with multilingual body values.
$default_format = filter_default_format();
$nodes = [
[
'title' => 'First node en',
'type' => 'page',
'body' => [['value' => $this->randomMachineName(32), 'format' => $default_format]],
'langcode' => 'en',
],
[
'title' => 'Second node this is the Spanish title',
'type' => 'page',
'body' => [['value' => $this->randomMachineName(32), 'format' => $default_format]],
'langcode' => 'es',
],
[
'title' => 'Third node en',
'type' => 'page',
'body' => [['value' => $this->randomMachineName(32), 'format' => $default_format]],
'langcode' => 'en',
],
];
$this->searchableNodes = [];
foreach ($nodes as $setting) {
$this->searchableNodes[] = $this->drupalCreateNode($setting);
}
// Add English translation to the second node.
$translation = $this->searchableNodes[1]->addTranslation('en', ['title' => 'Second node en']);
$translation->body->value = $this->randomMachineName(32);
$this->searchableNodes[1]->save();
// Add Spanish translation to the third node.
$translation = $this->searchableNodes[2]->addTranslation('es', ['title' => 'Third node es']);
$translation->body->value = $this->randomMachineName(32);
$this->searchableNodes[2]->save();
// Update the index and then run the shutdown method.
$plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
$plugin->updateIndex();
}
public function testLanguages(): void {
// Add predefined language.
$edit = ['predefined_langcode' => 'fr'];
$this->drupalGet('admin/config/regional/language/add');
$this->submitForm($edit, 'Add language');
$this->assertSession()->pageTextContains('French');
// Now we should have languages displayed.
$this->drupalGet('search/node');
$this->assertSession()->pageTextContains('Languages');
$this->assertSession()->pageTextContains('English');
$this->assertSession()->pageTextContains('French');
// Ensure selecting no language does not make the query different.
$this->drupalGet('search/node');
$this->submitForm([], 'edit-submit--2');
$this->assertSession()->addressEquals(Url::fromRoute('search.view_node_search', [], ['query' => ['keys' => '']]));
// Pick French and ensure it is selected.
$edit = ['language[fr]' => TRUE];
$this->drupalGet('search/node');
$this->submitForm($edit, 'edit-submit--2');
// Get the redirected URL.
$url = $this->getUrl();
$parts = parse_url($url);
$query_string = isset($parts['query']) ? rawurldecode($parts['query']) : '';
$this->assertStringContainsString('=language:fr', $query_string, 'Language filter language:fr add to the query string.');
// Search for keyword node and language filter as Spanish.
$edit = ['keys' => 'node', 'language[es]' => TRUE];
$this->drupalGet('search/node');
$this->submitForm($edit, 'edit-submit--2');
// Check for Spanish results.
$this->assertSession()->linkExists('Second node this is the Spanish title', 0, 'Second node Spanish title found in search results');
$this->assertSession()->linkExists('Third node es', 0, 'Third node Spanish found in search results');
// Ensure that results don't contain other language nodes.
$this->assertSession()->linkNotExists('First node en', 'Search results do not contain first English node');
$this->assertSession()->linkNotExists('Second node en', 'Search results do not contain second English node');
$this->assertSession()->linkNotExists('Third node en', 'Search results do not contain third English node');
// Change the default language and delete English.
$path = 'admin/config/regional/language';
$this->drupalGet($path);
$this->assertSession()->checkboxChecked('edit-site-default-language-en');
$edit = [
'site_default_language' => 'fr',
];
$this->drupalGet($path);
$this->submitForm($edit, 'Save configuration');
$this->assertSession()->checkboxNotChecked('edit-site-default-language-en');
$this->drupalGet('admin/config/regional/language/delete/en');
$this->submitForm([], 'Delete');
}
/**
* Test language attribute "lang" for the search results.
*/
public function testLanguageAttributes(): void {
$this->drupalGet('search/node');
$this->submitForm(['keys' => 'the Spanish title'], 'Search');
$node = $this->searchableNodes[1]->getTranslation('es');
$this->assertSession()->elementExists('xpath', '//div[@class="layout-content"]//ol/li/h3[contains(@lang, "es")]');
$this->assertSession()->elementTextEquals('xpath', '//div[@class="layout-content"]//ol/li/h3[contains(@lang, "es")]/a', $node->getTitle());
$this->assertSession()->elementExists('xpath', '//div[@class="layout-content"]//ol/li/p[contains(@lang, "es")]');
// Visit the search form in Spanish language.
$this->drupalGet('es/search/node');
$this->submitForm(['keys' => 'First node'], 'Search');
$this->assertSession()->elementExists('xpath', '//div[@class="layout-content"]//ol/li/h3[contains(@lang, "en")]');
$node = $this->searchableNodes[0]->getTranslation('en');
$this->assertSession()->elementTextEquals('xpath', '//div[@class="layout-content"]//ol/li/h3[contains(@lang, "en")]/a', $node->getTitle());
$this->assertSession()->elementExists('xpath', '//div[@class="layout-content"]//ol/li/p[contains(@lang, "en")]');
}
}

View File

@@ -0,0 +1,354 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Database\Database;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\search\SearchIndexInterface;
use Drupal\Tests\BrowserTestBase;
/**
* Tests entities with multilingual fields.
*
* @group search
*/
class SearchMultilingualEntityTest extends BrowserTestBase {
/**
* List of searchable nodes.
*
* @var \Drupal\node\NodeInterface[]
*/
protected $searchableNodes = [];
/**
* Node search plugin.
*
* @var \Drupal\node\Plugin\Search\NodeSearch
*/
protected $plugin;
/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'locale',
'comment',
'node',
'search',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create a user who can administer search, do searches, see the status
// report, and administer cron. Log in.
$user = $this->drupalCreateUser([
'administer search',
'search content',
'use advanced search',
'access content',
'access site reports',
'administer site configuration',
]);
$this->drupalLogin($user);
// Set up the search plugin.
$this->plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
// Check indexing counts before adding any nodes.
$this->assertIndexCounts(0, 0, 'before adding nodes');
$this->assertDatabaseCounts(0, 0, 'before adding nodes');
// Add two new languages.
ConfigurableLanguage::createFromLangcode('hu')->save();
ConfigurableLanguage::createFromLangcode('sv')->save();
// Make the body field translatable. The title is already translatable by
// definition. The parent class has already created the article and page
// content types.
$field_storage = FieldStorageConfig::loadByName('node', 'body');
$field_storage->setTranslatable(TRUE);
$field_storage->save();
// Create a few page nodes with multilingual body values.
$default_format = filter_default_format();
$nodes = [
[
'title' => 'First node en',
'type' => 'page',
'body' => [['value' => $this->randomMachineName(32), 'format' => $default_format]],
'langcode' => 'en',
],
[
'title' => 'Second node this is the English title',
'type' => 'page',
'body' => [['value' => $this->randomMachineName(32), 'format' => $default_format]],
'langcode' => 'en',
],
[
'title' => 'Third node en',
'type' => 'page',
'body' => [['value' => $this->randomMachineName(32), 'format' => $default_format]],
'langcode' => 'en',
],
// After the third node, we don't care what the settings are. But we
// need to have at least 5 to make sure the throttling is working
// correctly. So, let's make 8 total.
[],
[],
[],
[],
[],
];
$this->searchableNodes = [];
foreach ($nodes as $setting) {
$this->searchableNodes[] = $this->drupalCreateNode($setting);
}
// Add a single translation to the second node.
$translation = $this->searchableNodes[1]->addTranslation('hu', ['title' => 'Second node hu']);
$translation->body->value = $this->randomMachineName(32);
$this->searchableNodes[1]->save();
// Add two translations to the third node.
$translation = $this->searchableNodes[2]->addTranslation('hu', ['title' => 'Third node this is the Hungarian title']);
$translation->body->value = $this->randomMachineName(32);
$translation = $this->searchableNodes[2]->addTranslation('sv', ['title' => 'Third node sv']);
$translation->body->value = $this->randomMachineName(32);
$this->searchableNodes[2]->save();
// Verify that we have 8 nodes left to do.
$this->assertIndexCounts(8, 8, 'before updating the search index');
$this->assertDatabaseCounts(0, 0, 'before updating the search index');
}
/**
* Tests the indexing throttle and search results with multilingual nodes.
*/
public function testMultilingualSearch(): void {
// Index only 2 nodes per cron run. We cannot do this setting in the UI,
// because it doesn't go this low.
$this->config('search.settings')->set('index.cron_limit', 2)->save();
// Get a new search plugin, to make sure it has this setting.
$this->plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
// Update the index. This does the initial processing.
$this->plugin->updateIndex();
// Run the shutdown function. Testing is a unique case where indexing
// and searching has to happen in the same request, so running the shutdown
// function manually is needed to finish the indexing process.
$search_index = \Drupal::service('search.index');
assert($search_index instanceof SearchIndexInterface);
$this->assertIndexCounts(6, 8, 'after updating partially');
$this->assertDatabaseCounts(2, 0, 'after updating partially');
// Now index the rest of the nodes.
// Make sure index throttle is high enough, via the UI.
$this->drupalGet('admin/config/search/pages');
$this->submitForm(['cron_limit' => 20], 'Save configuration');
$this->assertEquals(20, $this->config('search.settings')->get('index.cron_limit'), 'Config setting was saved correctly');
// Get a new search plugin, to make sure it has this setting.
$this->plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
$this->plugin->updateIndex();
$this->assertIndexCounts(0, 8, 'after updating fully');
$this->assertDatabaseCounts(8, 0, 'after updating fully');
// Click the reindex button on the admin page, verify counts, and reindex.
$this->drupalGet('admin/config/search/pages');
$this->submitForm([], 'Re-index site');
$this->submitForm([], 'Re-index site');
$this->assertIndexCounts(8, 8, 'after reindex');
$this->assertDatabaseCounts(8, 0, 'after reindex');
$this->plugin->updateIndex();
// Test search results.
// This should find two results for the second and third node.
$this->plugin->setSearch('English OR Hungarian', [], []);
$search_result = $this->plugin->execute();
$this->assertCount(2, $search_result, 'Found two results.');
// Nodes are saved directly after each other and have the same created time
// so testing for the order is not possible.
$results = [$search_result[0]['title'], $search_result[1]['title']];
$this->assertContains('Third node this is the Hungarian title', $results, 'The search finds the correct Hungarian title.');
$this->assertContains('Second node this is the English title', $results, 'The search finds the correct English title.');
// Now filter for Hungarian results only.
$this->plugin->setSearch('English OR Hungarian', ['f' => ['language:hu']], []);
$search_result = $this->plugin->execute();
$this->assertCount(1, $search_result, 'The search found only one result');
$this->assertEquals('Third node this is the Hungarian title', $search_result[0]['title'], 'The search finds the correct Hungarian title.');
// Test for search with common key word across multiple languages.
$this->plugin->setSearch('node', [], []);
$search_result = $this->plugin->execute();
$this->assertCount(6, $search_result, 'The search found total six results');
// Test with language filters and common key word.
$this->plugin->setSearch('node', ['f' => ['language:hu']], []);
$search_result = $this->plugin->execute();
$this->assertCount(2, $search_result, 'The search found 2 results');
// Test to check for the language of result items.
foreach ($search_result as $result) {
$this->assertEquals('hu', $result['langcode'], 'The search found the correct Hungarian result');
}
// Mark one of the nodes for reindexing, using the API function, and
// verify indexing status.
$search_index->markForReindex('node_search', $this->searchableNodes[0]->id());
$this->assertIndexCounts(1, 8, 'after marking one node to reindex via API function');
// Update the index and verify the totals again.
$this->plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
$this->plugin->updateIndex();
$this->assertIndexCounts(0, 8, 'after indexing again');
// Mark one node for reindexing by saving it, and verify indexing status.
$this->searchableNodes[1]->save();
$this->assertIndexCounts(1, 8, 'after marking one node to reindex via save');
// The request time is always the same throughout test runs. Update the
// request time to a previous time, to simulate it having been marked
// previously.
$current = \Drupal::time()->getRequestTime();
$old = $current - 10;
$connection = Database::getConnection();
$connection->update('search_dataset')
->fields(['reindex' => $old])
->condition('reindex', $current, '>=')
->execute();
// Save the node again. Verify that the request time on it is not updated.
$this->searchableNodes[1]->save();
$result = $connection->select('search_dataset', 'd')
->fields('d', ['reindex'])
->condition('type', 'node_search')
->condition('sid', $this->searchableNodes[1]->id())
->execute()
->fetchField();
$this->assertEquals($old, $result, 'Reindex time was not updated if node was already marked');
// Add a bogus entry to the search index table using a different search
// type. This will not appear in the index status, because it is not
// managed by a plugin.
$search_index->index('foo', $this->searchableNodes[0]->id(), 'en', 'some text');
$this->assertIndexCounts(1, 8, 'after adding a different index item');
// Mark just this "foo" index for reindexing.
$search_index->markForReindex('foo');
$this->assertIndexCounts(1, 8, 'after reindexing the other search type');
// Mark everything for reindexing.
$search_index->markForReindex();
$this->assertIndexCounts(8, 8, 'after reindexing everything');
// Clear one item from the index, but with wrong language.
$this->assertDatabaseCounts(8, 1, 'before clear');
$search_index->clear('node_search', $this->searchableNodes[0]->id(), 'hu');
$this->assertDatabaseCounts(8, 1, 'after clear with wrong language');
// Clear using correct language.
$search_index->clear('node_search', $this->searchableNodes[0]->id(), 'en');
$this->assertDatabaseCounts(7, 1, 'after clear with right language');
// Don't specify language.
$search_index->clear('node_search', $this->searchableNodes[1]->id());
$this->assertDatabaseCounts(6, 1, 'unspecified language clear');
// Clear everything in 'foo'.
$search_index->clear('foo');
$this->assertDatabaseCounts(6, 0, 'other index clear');
// Clear everything.
$search_index->clear();
$this->assertDatabaseCounts(0, 0, 'complete clear');
}
/**
* Verifies the indexing status counts.
*
* @param int $remaining
* Count of remaining items to verify.
* @param int $total
* Count of total items to verify.
* @param string $message
* Message to use, something like "after updating the search index".
*
* @internal
*/
protected function assertIndexCounts(int $remaining, int $total, string $message): void {
// Check status via plugin method call.
$status = $this->plugin->indexStatus();
$this->assertEquals($remaining, $status['remaining'], 'Remaining items ' . $message . ' is ' . $remaining);
$this->assertEquals($total, $status['total'], 'Total items ' . $message . ' is ' . $total);
// Check text in progress section of Search settings page. Note that this
// test avoids using
// \Drupal\Core\StringTranslation\TranslationInterface::formatPlural(), so
// it tests for fragments of text.
$indexed = $total - $remaining;
$percent = ($total > 0) ? floor(100 * $indexed / $total) : 100;
$this->drupalGet('admin/config/search/pages');
$this->assertSession()->pageTextContains($percent . '% of the site has been indexed.');
$this->assertSession()->pageTextContains($remaining . ' item');
// Check text in pages section of Search settings page.
$this->assertSession()->pageTextContains($indexed . ' of ' . $total . ' indexed');
// Check text on status report page.
$this->drupalGet('admin/reports/status');
$this->assertSession()->pageTextContains('Search index progress');
$this->assertSession()->pageTextContains($percent . '%');
$this->assertSession()->pageTextContains('(' . $remaining . ' remaining)');
}
/**
* Checks actual database counts of items in the search index.
*
* @param int $count_node
* Count of node items to assert.
* @param int $count_foo
* Count of "foo" items to assert.
* @param string $message
* Message suffix to use.
*
* @internal
*/
protected function assertDatabaseCounts(int $count_node, int $count_foo, string $message): void {
// Count number of distinct nodes by ID.
$connection = Database::getConnection();
$results = $connection->select('search_dataset', 'i')
->fields('i', ['sid'])
->condition('type', 'node_search')
->groupBy('sid')
->execute()
->fetchCol();
$this->assertCount($count_node, $results, 'Node count was ' . $count_node . ' for ' . $message);
// Count number of "foo" records.
$results = $connection->select('search_dataset', 'i')
->fields('i', ['sid'])
->condition('type', 'foo')
->execute()
->fetchCol();
$this->assertCount($count_foo, $results, 'Foo count was ' . $count_foo . ' for ' . $message);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests search functionality with diacritics.
*
* @group search
*/
class SearchNodeDiacriticsTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to use advanced search.
*
* @var \Drupal\user\UserInterface
*/
public $testUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
node_access_rebuild();
// Create a test user and log in.
$this->testUser = $this->drupalCreateUser([
'access content',
'search content',
'use advanced search',
'access user profiles',
]);
$this->drupalLogin($this->testUser);
}
/**
* Tests that search returns results with diacritics in the search phrase.
*/
public function testPhraseSearchPunctuation(): void {
// cSpell:disable
$body_text = 'The Enricþment Center is cómmīŦŧęđ to the well BɆĬŇĜ of æll påŔťıçȉpǎǹţș. ';
$body_text .= 'Also meklēt (see #731298)';
$this->drupalCreateNode(['body' => [['value' => $body_text]]]);
// Update the search index.
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
// Refresh variables after the treatment.
$this->refreshVariables();
$edit = ['keys' => 'meklet'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseContains('<strong>meklēt</strong>');
$edit = ['keys' => 'meklēt'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseContains('<strong>meklēt</strong>');
$edit = ['keys' => 'cómmīŦŧęđ BɆĬŇĜ påŔťıçȉpǎǹţș'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseContains('<strong>cómmīŦŧęđ</strong>');
$this->assertSession()->responseContains('<strong>BɆĬŇĜ</strong>');
$this->assertSession()->responseContains('<strong>påŔťıçȉpǎǹţș</strong>');
$edit = ['keys' => 'committed being participants'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseContains('<strong>cómmīŦŧęđ</strong>');
$this->assertSession()->responseContains('<strong>BɆĬŇĜ</strong>');
$this->assertSession()->responseContains('<strong>påŔťıçȉpǎǹţș</strong>');
$edit = ['keys' => 'Enricþment'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseContains('<strong>Enricþment</strong>');
$edit = ['keys' => 'Enritchment'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseNotContains('<strong>Enricþment</strong>');
$edit = ['keys' => 'æll'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseContains('<strong>æll</strong>');
$edit = ['keys' => 'all'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseNotContains('<strong>æll</strong>');
// cSpell:enable
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests search functionality with punctuation and HTML entities.
*
* @group search
*/
class SearchNodePunctuationTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to use advanced search.
*
* @var \Drupal\user\UserInterface
*/
public $testUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
node_access_rebuild();
// Create a test user and log in.
$this->testUser = $this->drupalCreateUser([
'access content',
'search content',
'use advanced search',
'access user profiles',
]);
$this->drupalLogin($this->testUser);
}
/**
* Tests that search works with punctuation and HTML entities.
*/
public function testPhraseSearchPunctuation(): void {
$node = $this->drupalCreateNode(['body' => [['value' => "The bunny's ears were fluffy."]]]);
// cSpell:disable-next-line
$this->drupalCreateNode(['body' => [['value' => 'Dignissim Aliquam &amp; Quieligo meus natu quae quia te. Damnum&copy; erat&mdash; neo pneum. Facilisi feugiat ibidem ratis.']]]);
// Update the search index.
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
// Refresh variables after the treatment.
$this->refreshVariables();
// Submit a phrase wrapped in double quotes to include the punctuation.
$edit = ['keys' => '"bunny\'s"'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains($node->label());
// Check if the author is linked correctly to the user profile page.
$username = $node->getOwner()->getAccountName();
$this->assertSession()->linkExists($username);
// Search for "&" and verify entities are not broken up in the output.
$edit = ['keys' => '&'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseNotContains('<strong>&</strong>amp;');
$this->assertSession()->statusMessageContains('You must include at least one keyword', 'warning');
$edit = ['keys' => '&amp;'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseNotContains('<strong>&</strong>amp;');
$this->assertSession()->statusMessageContains('You must include at least one keyword', 'warning');
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Database\Database;
use Drupal\search\SearchIndexInterface;
use Drupal\Tests\BrowserTestBase;
/**
* Tests search index is updated properly when nodes are removed or updated.
*
* @group search
*/
class SearchNodeUpdateAndDeletionTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to access and search content.
*
* @var \Drupal\user\UserInterface
*/
public $testUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create a test user and log in.
$this->testUser = $this->drupalCreateUser([
'access content',
'search content',
]);
$this->drupalLogin($this->testUser);
}
/**
* Tests that the search index info is properly updated when a node changes.
*/
public function testSearchIndexUpdateOnNodeChange(): void {
// Create a node.
$node = $this->drupalCreateNode([
'title' => 'Someone who says Ni!',
'body' => [['value' => "We are the knights who say Ni!"]],
'type' => 'page',
]);
$node_search_plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
// Update the search index.
$node_search_plugin->updateIndex();
$search_index = \Drupal::service('search.index');
assert($search_index instanceof SearchIndexInterface);
// Search the node to verify it appears in search results
$edit = ['keys' => 'knights'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains($node->label());
// Update the node
$node->body->value = "We want a shrubbery!";
$node->save();
// Run indexer again
$node_search_plugin->updateIndex();
// Search again to verify the new text appears in test results.
$edit = ['keys' => 'shrubbery'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains($node->label());
}
/**
* Tests that the search index info is updated when a node is deleted.
*/
public function testSearchIndexUpdateOnNodeDeletion(): void {
// Create a node.
$node = $this->drupalCreateNode([
'title' => 'No dragons here',
'body' => [['value' => 'Again: No dragons here']],
'type' => 'page',
]);
$node_search_plugin = $this->container->get('plugin.manager.search')->createInstance('node_search');
// Update the search index.
$node_search_plugin->updateIndex();
// Search the node to verify it appears in search results
$edit = ['keys' => 'dragons'];
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains($node->label());
// Get the node info from the search index tables.
$connection = Database::getConnection();
$search_index_dataset = $connection->select('search_index', 'si')
->fields('si', ['sid'])
->condition('type', 'node_search')
->condition('word', 'dragons')
->execute()
->fetchField();
$this->assertNotFalse($search_index_dataset, 'Node info found on the search_index');
// Delete the node.
$node->delete();
// Check if the node info is gone from the search table.
$search_index_dataset = $connection->select('search_index', 'si')
->fields('si', ['sid'])
->condition('type', 'node_search')
->condition('word', 'dragons')
->execute()
->fetchField();
$this->assertFalse($search_index_dataset, 'Node info successfully removed from search_index');
// Search again to verify the node doesn't appear anymore.
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextNotContains($node->label());
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\Traits\Core\CronRunTrait;
/**
* Tests that numbers can be searched with more complex matching.
*
* @group search
*/
class SearchNumberMatchingTest extends BrowserTestBase {
use CronRunTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['dblog', 'node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to administer nodes.
*
* @var \Drupal\user\UserInterface
*/
protected $testUser;
/**
* An array of strings containing numbers to use for testing.
*
* Define a group of numbers that should all match each other --
* numbers with internal punctuation should match each other, as well
* as numbers with and without leading zeros and leading/trailing
* . and -.
*
* @var string[]
*/
protected $numbers = [
'123456789',
'12/34/56789',
'12.3456789',
'12-34-56789',
'123,456,789',
'-123456789',
'0123456789',
];
/**
* An array of nodes created for testing purposes.
*
* @var \Drupal\node\NodeInterface[]
*/
protected $nodes;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->testUser = $this->drupalCreateUser([
'search content',
'access content',
'administer nodes',
'access site reports',
]);
$this->drupalLogin($this->testUser);
foreach ($this->numbers as $num) {
$info = [
'body' => [['value' => $num]],
'type' => 'page',
'language' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
];
$this->nodes[] = $this->drupalCreateNode($info);
}
// Run cron to ensure the content is indexed.
$this->cronRun();
$this->drupalGet('admin/reports/dblog');
$this->assertSession()->pageTextContains('Cron run completed');
}
/**
* Tests that all the numbers can be searched.
*/
public function testNumberSearching(): void {
for ($i = 0; $i < count($this->numbers); $i++) {
$node = $this->nodes[$i];
// Verify that the node title does not appear on the search page
// with a dummy search.
$this->drupalGet('search/node');
$this->submitForm(['keys' => 'foo'], 'Search');
$this->assertSession()->pageTextNotContains($node->label());
// Now verify that we can find node i by searching for any of the
// numbers.
for ($j = 0; $j < count($this->numbers); $j++) {
$number = $this->numbers[$j];
// If the number is negative, remove the - sign, because - indicates
// "not keyword" when searching.
$number = ltrim($number, '-');
$this->drupalGet('search/node');
$this->submitForm(['keys' => $number], 'Search');
$this->assertSession()->pageTextContains($node->label());
}
}
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\Traits\Core\CronRunTrait;
/**
* Tests that numbers can be searched.
*
* @group search
*/
class SearchNumbersTest extends BrowserTestBase {
use CronRunTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['dblog', 'node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to administer nodes.
*
* @var \Drupal\user\UserInterface
*/
protected $testUser;
/**
* An array containing a series of "numbers" for testing purposes.
*
* Create content with various numbers in it.
* Note: 50 characters is the current limit of the search index's word
* field.
*
* @var string[]
*/
protected $numbers = [
'ISBN' => '978-0446365383',
'UPC' => '036000 291452',
'EAN bar code' => '5901234123457',
'negative' => '-123456.7890',
'quoted negative' => '"-123456.7890"',
'leading zero' => '0777777777',
'tiny' => '111',
'small' => '22222222222222',
'medium' => '333333333333333333333333333',
'large' => '444444444444444444444444444444444444444',
'gigantic' => '5555555555555555555555555555555555555555555555555',
'over fifty characters' => '666666666666666666666666666666666666666666666666666666666666',
'date' => '01/02/2009',
'commas' => '987,654,321',
];
/**
* An array of nodes created for testing purposes.
*
* @var \Drupal\node\NodeInterface[]
*/
protected $nodes;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->testUser = $this->drupalCreateUser([
'search content',
'access content',
'administer nodes',
'access site reports',
]);
$this->drupalLogin($this->testUser);
foreach ($this->numbers as $doc => $num) {
$info = [
'body' => [['value' => $num]],
'type' => 'page',
'language' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
'title' => $doc . ' number',
];
$this->nodes[$doc] = $this->drupalCreateNode($info);
}
// Run cron to ensure the content is indexed.
$this->cronRun();
$this->drupalGet('admin/reports/dblog');
$this->assertSession()->pageTextContains('Cron run completed');
}
/**
* Tests that all the numbers can be searched.
*/
public function testNumberSearching(): void {
$types = array_keys($this->numbers);
foreach ($types as $type) {
$number = $this->numbers[$type];
// If the number is negative, remove the - sign, because - indicates
// "not keyword" when searching.
$number = ltrim($number, '-');
$node = $this->nodes[$type];
// Verify that the node title does not appear on the search page
// with a dummy search.
$this->drupalGet('search/node');
$this->submitForm(['keys' => 'foo'], 'Search');
$this->assertSession()->pageTextNotContains($node->label());
// Verify that the node title does appear as a link on the search page
// when searching for the number.
$this->drupalGet('search/node');
$this->submitForm(['keys' => $number], 'Search');
$this->assertSession()->pageTextContains($node->label());
}
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\search\Functional;
use Drupal\Core\Cache\Cache;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\field_ui\Traits\FieldUiTestTrait;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
/**
* Tests the search_page entity cache tags on the search results pages.
*
* @group search
*/
class SearchPageCacheTagsTest extends BrowserTestBase {
use AssertPageCacheContextsAndTagsTrait;
use FieldUiTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to search content.
*
* @var \Drupal\user\UserInterface
*/
protected $searchingUser;
/**
* A node that is indexed by the search module.
*
* @var \Drupal\node\NodeInterface
*/
protected $node;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create user.
$this->searchingUser = $this->drupalCreateUser([
'search content',
'access user profiles',
]);
// Create a node and update the search index.
$this->node = $this->drupalCreateNode(['title' => 'bike shed shop']);
$this->node->setOwner($this->searchingUser);
$this->node->save();
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
}
/**
* Tests the presence of the expected cache tag in various situations.
*/
public function testSearchText(): void {
$this->drupalLogin($this->searchingUser);
// Initial page for searching nodes.
$this->drupalGet('search/node');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:search.page.node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'search_index:node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'node_list');
// Node search results.
$edit = [];
$edit['keys'] = 'bike shed';
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains('bike shed shop');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:search.page.node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'search_index');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'search_index:node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'node:1');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'user:2');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'rendered');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'http_response');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'node_list');
// Updating a node should invalidate the search plugin's index cache tag.
$this->node->title = 'bike shop';
$this->node->save();
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains('bike shop');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:search.page.node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'search_index');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'search_index:node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'node:1');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'user:2');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'rendered');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'http_response');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'node_list');
// Deleting a node should invalidate the search plugin's index cache tag.
$this->node->delete();
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains('Your search yielded no results.');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:search.page.node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'search_index');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'search_index:node_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'node_list');
// Initial page for searching users.
$this->drupalGet('search/user');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:search.page.user_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'user_list');
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'search_index');
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'search_index:user_search');
// User search results.
$edit['keys'] = $this->searchingUser->getAccountName();
$this->drupalGet('search/user');
$this->submitForm($edit, 'Search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:search.page.user_search');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'user_list');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'user:2');
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'search_index');
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'search_index:user_search');
}
/**
* Tests the presence of expected cache tags with referenced entities.
*/
public function testSearchTagsBubbling(): void {
// Install field UI module.
$this->container->get('module_installer')->install(['field_ui']);
$this->resetAll();
// Creates a new content type that will have an entity reference.
$type_name = 'entity_reference_test';
$type = $this->drupalCreateContentType(['name' => $type_name, 'type' => $type_name]);
$bundle_path = 'admin/structure/types/manage/' . $type->id();
// Create test user.
$admin_user = $this->drupalCreateUser([
'access content',
'create ' . $type_name . ' content',
'administer node fields',
'administer node display',
]);
$this->drupalLogin($admin_user);
$this->fieldUIAddNewField($bundle_path, 'test__ref', 'Test label', 'entity_reference', [], ['settings[handler_settings][target_bundles][page]' => TRUE]);
// Create a new node of our newly created node type and fill in the entity
// reference field.
$edit = [
'title[0][value]' => 'Llama shop',
'field_test__ref[0][target_id]' => $this->node->getTitle(),
];
$this->drupalGet('node/add/' . $type->id());
$this->submitForm($edit, 'Save');
// Test that the value of the entity reference field is shown.
$this->drupalGet('node/2');
$this->assertSession()->pageTextContains('bike shed shop');
// Refresh the search index.
$this->container->get('plugin.manager.search')->createInstance('node_search')->updateIndex();
// Log in with searching user again.
$this->drupalLogin($this->searchingUser);
// Default search cache tags.
$default_search_tags = [
'config:search.page.node_search',
'search_index',
'search_index:node_search',
'http_response',
'CACHE_MISS_IF_UNCACHEABLE_HTTP_METHOD:form',
'rendered',
'node_list',
];
// Node search results for shop, should return node:1 (bike shed shop) and
// node:2 (Llama shop). The related authors cache tags should be visible as
// well.
$edit = [];
$edit['keys'] = 'shop';
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains('bike shed shop');
$this->assertSession()->pageTextContains('Llama shop');
$expected_cache_tags = Cache::mergeTags($default_search_tags, [
'node:1',
'user:2',
'node:2',
'user:3',
'node_view',
'config:filter.format.plain_text',
]);
$this->assertCacheTags($expected_cache_tags);
// Only get the new node in the search results, should result in node:1,
// node:2 and user:3 as cache tags even though only node:1 is shown. This is
// because node:2 is reference in node:1 as an entity reference.
$edit = [];
$edit['keys'] = 'Llama';
$this->drupalGet('search/node');
$this->submitForm($edit, 'Search');
$this->assertSession()->pageTextContains('Llama shop');
$expected_cache_tags = Cache::mergeTags($default_search_tags, [
'node:1',
'node:2',
'user:3',
'node_view',
]);
$this->assertCacheTags($expected_cache_tags);
}
}

Some files were not shown because too many files have changed in this diff Show More