slug = 'autosuggest';
$this->title = $this->get_title();
$this->short_title = esc_html__( 'Autosuggest', 'elasticpress' );
$this->summary = __( 'Suggest relevant content as text is entered into the search field.', 'elasticpress' );
$this->docs_url = __( 'https://elasticpress.zendesk.com/hc/en-us/articles/360050447492-Configuring-ElasticPress-via-the-Plugin-Dashboard#autosuggest', 'elasticpress' );
$this->requires_install_reindex = true;
$this->default_settings = [
'endpoint_url' => '',
'autosuggest_selector' => '',
'trigger_ga_event' => '0',
];
$this->available_during_installation = true;
parent::__construct();
}
/**
* Output feature box long
*
* @since 2.4
*/
public function output_feature_box_long() {
?>
get_settings();
?>
epio_allowed_parameters();
return;
}
$endpoint_url = ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) ? EP_AUTOSUGGEST_ENDPOINT : $settings['endpoint_url'];
?>
get( 'post' );
$mapping = $post_indexable->add_ngram_analyzer( $mapping );
$mapping = $post_indexable->add_term_suggest_field( $mapping );
// Note the assignment by reference below.
if ( version_compare( (string) Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
$mapping_properties = &$mapping['mappings']['post']['properties'];
} else {
$mapping_properties = &$mapping['mappings']['properties'];
}
$text_type = $mapping_properties['post_content']['type'];
$mapping_properties['post_title']['fields']['suggest'] = array(
'type' => $text_type,
'analyzer' => 'edge_ngram_analyzer',
'search_analyzer' => 'standard',
);
return $mapping;
}
/**
* Ensure both search and autosuggest use fuziness with type auto
*
* @param integer $fuzziness Fuzziness
* @param array $search_fields Search Fields
* @param array $args Array of ES args
* @return array
*/
public function set_fuzziness( $fuzziness, $search_fields, $args ) {
if ( Utils\is_integrated_request( $this->slug, [ 'public' ] ) && ! empty( $args['s'] ) ) {
return 'auto';
}
return $fuzziness;
}
/**
* Handle ngram search fields for fuzziness fields
*
* @param array $query ES Query arguments
* @param string $post_type Post Type
* @param array $args WP_Query args
* @return array $query adjusted ES Query arguments
*/
public function adjust_fuzzy_fields( $query, $post_type, $args ) {
if ( ! Utils\is_integrated_request( $this->slug, [ 'public' ] ) || empty( $args['s'] ) ) {
return $query;
}
if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['must'] ) ) {
return $query;
}
/**
* Filter autosuggest ngram fields
*
* @hook ep_autosuggest_ngram_fields
* @param {array} $fields Fields available to ngram
* @return {array} New fields array
*/
$ngram_fields = apply_filters(
'ep_autosuggest_ngram_fields',
[
'post_title' => 'post_title.suggest',
'terms\.(.+)\.name' => 'term_suggest',
]
);
/**
* At this point, `$query` might look like this (using the 3.5 search algorithm):
*
* [
* [bool] => [
* [must] => [
* [0] => [
* [bool] => [
* [should] => [
* [0] => [
* [multi_match] => [
* [query] => ep_autosuggest_placeholder
* [type] => phrase
* [fields] => [
* [0] => post_title^1
* ...
* [n] => terms.category.name^27
* ]
* [boost] => 3
* ]
* ]
* [1] => [
* [multi_match] => [
* [query] => ep_autosuggest_placeholder
* [fields] => [ ... ]
* [type] => phrase
* [slop] => 5
* ]
* ]
* ]
* ]
* ]
* ]
* ]
* ...
* ]
*
* Also, note the usage of `&$must_query`. This means that by changing `$must_query`
* you will be actually changing `$query`.
*/
foreach ( $query['bool']['must'] as &$must_query ) {
if ( ! isset( $must_query['bool'] ) || ! isset( $must_query['bool']['should'] ) ) {
continue;
}
foreach ( $must_query['bool']['should'] as &$current_bool_should ) {
if ( ! isset( $current_bool_should['multi_match'] ) || ! isset( $current_bool_should['multi_match']['fields'] ) ) {
continue;
}
/**
* `fuzziness` is used in the original algorithm.
* `slop` is used in `3.5`.
*
* @see \ElasticPress\Indexable\Post\Post::format_args()
*/
if ( empty( $current_bool_should['multi_match']['fuzziness'] ) && empty( $current_bool_should['multi_match']['slop'] ) ) {
continue;
}
$fields_to_add = [];
/**
* If the regex used in `$ngram_fields` matches more than one field,
* like taxonomies, for example, we use the min value - 1.
*/
foreach ( $current_bool_should['multi_match']['fields'] as $field ) {
foreach ( $ngram_fields as $regex => $ngram_field ) {
if ( preg_match( '/^(' . $regex . ')(\^(\d+))?$/', $field, $match ) ) {
$weight = 1;
if ( isset( $match[4] ) && $match[4] > 1 ) {
$weight = $match[4] - 1;
}
if ( isset( $fields_to_add[ $ngram_field ] ) ) {
$fields_to_add[ $ngram_field ] = min( $fields_to_add[ $ngram_field ], $weight );
} else {
$fields_to_add[ $ngram_field ] = $weight;
}
}
}
}
foreach ( $fields_to_add as $field => $weight ) {
$current_bool_should['multi_match']['fields'][] = "{$field}^{$weight}";
}
}
}
return $query;
}
/**
* Add term suggestions to be indexed
*
* @param array $post_args Array of ES args.
* @since 2.4
* @return array
*/
public function filter_term_suggest( $post_args ) {
$suggest = [];
if ( ! empty( $post_args['terms'] ) ) {
foreach ( $post_args['terms'] as $taxonomy ) {
foreach ( $taxonomy as $term ) {
$suggest[] = $term['name'];
}
}
}
if ( ! empty( $suggest ) ) {
$post_args['term_suggest'] = $suggest;
}
return $post_args;
}
/**
* Enqueue our autosuggest script
*
* @since 2.4
*/
public function enqueue_scripts() {
if ( Utils\is_indexing() ) {
return;
}
$host = Utils\get_host();
$settings = $this->get_settings();
if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) {
$endpoint_url = EP_AUTOSUGGEST_ENDPOINT;
} else {
if ( Utils\is_epio() ) {
$endpoint_url = trailingslashit( $host ) . Indexables::factory()->get( 'post' )->get_index_name() . '/autosuggest';
} else {
$endpoint_url = $settings['endpoint_url'];
}
}
if ( empty( $endpoint_url ) ) {
return;
}
wp_enqueue_script(
'elasticpress-autosuggest',
EP_URL . 'dist/js/autosuggest-script.js',
Utils\get_asset_info( 'autosuggest-script', 'dependencies' ),
Utils\get_asset_info( 'autosuggest-script', 'version' ),
true
);
wp_set_script_translations( 'elasticpress-autosuggest', 'elasticpress' );
wp_enqueue_style(
'elasticpress-autosuggest',
EP_URL . 'dist/css/autosuggest-styles.css',
Utils\get_asset_info( 'autosuggest-styles', 'dependencies' ),
Utils\get_asset_info( 'autosuggest-styles', 'version' )
);
/** Features Class @var Features $features */
$features = Features::factory();
/** Search Feature @var Feature\Search\Search $search */
$search = $features->get_registered_feature( 'search' );
$query = $this->generate_search_query();
$epas_options = [
'query' => $query['body'],
'placeholder' => $query['placeholder'],
'endpointUrl' => esc_url( untrailingslashit( $endpoint_url ) ),
'selector' => empty( $settings['autosuggest_selector'] ) ? 'ep-autosuggest' : esc_html( $settings['autosuggest_selector'] ),
/**
* Filter autosuggest default selectors.
*
* @hook ep_autosuggest_default_selectors
* @since 3.6.0
* @param {string} $selectors Default selectors used to attach autosuggest.
* @return {string} Selectors used to attach autosuggest.
*/
'defaultSelectors' => apply_filters( 'ep_autosuggest_default_selectors', '.ep-autosuggest, input[type="search"], .search-field' ),
'action' => 'navigate',
'mimeTypes' => [],
/**
* Filter autosuggest HTTP headers
*
* @hook ep_autosuggest_http_headers
* @param {array} $headers Autosuggest HTTP headers in name => value format
* @return {array} HTTP headers
*/
'http_headers' => apply_filters( 'ep_autosuggest_http_headers', [] ),
'triggerAnalytics' => ! empty( $settings['trigger_ga_event'] ),
'addSearchTermHeader' => false,
'requestIdBase' => Utils\get_request_id_base(),
];
if ( Utils\is_epio() ) {
$epas_options['addSearchTermHeader'] = true;
}
$search_settings = $search->get_settings();
if ( ! $search_settings ) {
$search_settings = [];
}
$search_settings = wp_parse_args( $search_settings, $search->default_settings );
if ( ! empty( $search_settings ) && $search_settings['highlight_enabled'] ) {
$epas_options['highlightingEnabled'] = true;
$epas_options['highlightingTag'] = apply_filters( 'ep_highlighting_tag', $search_settings['highlight_tag'] );
$epas_options['highlightingClass'] = apply_filters( 'ep_highlighting_class', 'ep-highlight' );
}
/**
* Output variables to use in Javascript
* index: the Elasticsearch index name
* endpointUrl: the Elasticsearch autosuggest endpoint url
* postType: which post types to use for suggestions
* action: the action to take when selecting an item. Possible values are "search" and "navigate".
*/
wp_localize_script(
'elasticpress-autosuggest',
'epas',
/**
* Filter autosuggest JavaScript options
*
* @hook ep_autosuggest_options
* @param {array} $options Autosuggest options to be localized
* @return {array} New options
*/
apply_filters(
'ep_autosuggest_options',
$epas_options
)
);
}
/**
* Build a default search request to pass to the autosuggest javascript.
* The request will include a placeholder that can then be replaced.
*
* @return array Generated ElasticSearch request array( 'placeholder'=> placeholderstring, 'body' => request body )
*/
public function generate_search_query() {
/**
* Filter autosuggest query placeholder
*
* @hook ep_autosuggest_query_placeholder
* @param {string} $placeholder Autosuggest placeholder to be replaced later
* @return {string} New placeholder
*/
$placeholder = apply_filters( 'ep_autosuggest_query_placeholder', 'ep_autosuggest_placeholder' );
/** Features Class @var Features $features */
$features = Features::factory();
$post_type = $features->get_registered_feature( 'search' )->get_searchable_post_types();
/**
* Filter post types available to autosuggest
*
* @hook ep_term_suggest_post_type
* @param {array} $post_types Post types
* @return {array} New post types
*/
$post_type = apply_filters( 'ep_term_suggest_post_type', array_values( $post_type ) );
$post_status = get_post_stati(
[
'public' => true,
'exclude_from_search' => false,
]
);
/**
* Filter post statuses available to autosuggest
*
* @hook ep_term_suggest_post_status
* @param {array} $post_statuses Post statuses
* @return {array} New post statuses
*/
$post_status = apply_filters( 'ep_term_suggest_post_status', array_values( $post_status ) );
add_filter( 'ep_intercept_remote_request', [ $this, 'intercept_remote_request' ] );
add_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ] );
add_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ], 10, 2 );
add_filter( 'posts_pre_query', [ $features->get_registered_feature( $this->slug ), 'return_empty_posts' ], 100, 1 ); // after ES Query to ensure we are not falling back to DB in any case
new \WP_Query(
/**
* Filter WP Query args of the autosuggest query template.
*
* If you want to display 20 posts in autosuggest:
*
* ```
* add_filter(
* 'ep_autosuggest_query_args',
* function( $args ) {
* $args['posts_per_page'] = 20;
* return $args;
* }
* );
* ```
*
* @since 4.4.0
* @hook ep_autosuggest_query_args
* @param {array} $args Query args
* @return {array} New query args
*/
apply_filters(
'ep_autosuggest_query_args',
[
'post_type' => $post_type,
'post_status' => $post_status,
's' => $placeholder,
'ep_integrate' => true,
]
)
);
remove_filter( 'posts_pre_query', [ $features->get_registered_feature( $this->slug ), 'return_empty_posts' ], 100 );
remove_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ] );
remove_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ] );
remove_filter( 'ep_intercept_remote_request', [ $this, 'intercept_remote_request' ] );
return [
'body' => $this->autosuggest_query,
'placeholder' => $placeholder,
];
}
/**
* Ensure we do not fallback to WPDB query for this request
*
* @param array $posts array of post objects
* @return array $posts
*/
public function return_empty_posts( $posts = [] ) {
return [];
}
/**
* Allow applying custom weighting configuration for autosuggest
*
* @param array $config current configuration
* @return array $config desired configuration
*/
public function apply_autosuggest_weighting( $config = [] ) {
/**
* Filter autosuggest weighting configuration
*
* @hook ep_weighting_configuration_for_autosuggest
* @param {array} $config Configuration
* @return {array} New config
*/
$config = apply_filters( 'ep_weighting_configuration_for_autosuggest', $config );
return $config;
}
/**
* Store intercepted request value and return a fake successful request result
*
* @param array $response Response
* @param array $query ES Query
* @return array $response Response
*/
public function intercept_search_request( $response, $query = [] ) {
$this->autosuggest_query = $query['args']['body'];
$message = wp_json_encode(
[
esc_html__( 'This is a fake request to build the ElasticPress Autosuggest query. It is not really sent.', 'elasticpress' ),
]
);
return [
'is_ep_fake_request' => true,
'body' => $message,
'response' => [
'code' => 200,
'message' => $message,
],
];
}
/**
* Tell user whether requirements for feature are met or not.
*
* @return array $status Status array
* @since 2.4
*/
public function requirements_status() {
$status = new FeatureRequirementsStatus( 0 );
$status->message = [];
$status->message[] = esc_html__( 'This feature modifies the site’s default user experience by presenting a list of suggestions below detected search fields as text is entered into the field.', 'elasticpress' );
if ( ! Utils\is_epio() ) {
$status->code = 1;
$status->message[] = wp_kses_post( __( "You aren't using ElasticPress.io so we can't be sure your host is properly secured. Autosuggest requires a publicly accessible endpoint, which can expose private content and allow data modification if improperly configured.", 'elasticpress' ) );
}
return $status;
}
/**
* Do a non-blocking search query to force the autosuggest hash to update.
*
* This request has to happen in a public environment, so all code testing if `is_admin()`
* are properly executed.
*
* @param bool $blocking If the request should block the execution or not.
*/
public function epio_send_autosuggest_public_request( $blocking = false ) {
if ( ! Utils\is_epio() ) {
return;
}
$url = add_query_arg(
[
's' => 'search test',
'ep_epio_set_autosuggest' => 1,
'ep_epio_nonce' => wp_create_nonce( 'ep-epio-set-autosuggest' ),
'nocache' => time(), // Here just to avoid the request hitting a CDN.
],
home_url( '/' )
);
// Pass the same cookies, so the same authenticated user is used (and we can check the nonce).
$cookies = [];
foreach ( $_COOKIE as $name => $value ) {
$cookies[] = new \WP_Http_Cookie(
[
'name' => $name,
'value' => $value,
]
);
}
wp_remote_get(
$url,
[
'cookies' => $cookies,
'blocking' => (bool) $blocking,
]
);
}
/**
* Send the allowed parameters for autosuggest to ElasticPress.io.
*/
public function epio_send_autosuggest_allowed() {
if ( empty( $_REQUEST['ep_epio_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_REQUEST['ep_epio_nonce'] ), 'ep-epio-set-autosuggest' ) ) {
return;
}
if ( empty( $_GET['ep_epio_set_autosuggest'] ) ) {
return;
}
/**
* Fires before the request is sent to EP.io to set Autosuggest allowed values.
*
* @hook ep_epio_pre_send_autosuggest_allowed
* @since 3.5.x
*/
do_action( 'ep_epio_pre_send_autosuggest_allowed' );
/**
* The same ES query sent by autosuggest.
*
* Sometimes it'll be a string, sometimes it'll be already an array.
*/
$es_search_query = $this->generate_search_query()['body'];
$es_search_query = ( is_array( $es_search_query ) ) ? $es_search_query : json_decode( $es_search_query, true );
/**
* Filter autosuggest ES query
*
* @since 3.5.x
* @hook ep_epio_autosuggest_es_query
* @param {array} The ES Query.
*/
$es_search_query = apply_filters( 'ep_epio_autosuggest_es_query', $es_search_query );
/**
* Here is a chance to short-circuit the execution. Also, during the sync
* the query will be empty anyway.
*/
if ( empty( $es_search_query ) ) {
return;
}
$index = Indexables::factory()->get( 'post' )->get_index_name();
add_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
Elasticsearch::factory()->query( $index, 'post', $es_search_query, [] );
remove_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
/**
* Fires after the request is sent to EP.io to set Autosuggest allowed values.
*
* @hook ep_epio_sent_autosuggest_allowed
* @since 3.5.x
*/
do_action( 'ep_epio_sent_autosuggest_allowed' );
}
/**
* Set a header so EP.io servers know this request contains the values
* that should be stored as allowed.
*
* @since 3.5.x
* @param array $headers The Request Headers.
* @return array
*/
public function add_ep_set_autosuggest_header( $headers ) {
$headers['EP-Set-Autosuggest'] = true;
return $headers;
}
/**
* Retrieve the allowed parameters for autosuggest from ElasticPress.io.
*
* @return array
*/
public function epio_retrieve_autosuggest_allowed() {
$response = Elasticsearch::factory()->remote_request(
Indexables::factory()->get( 'post' )->get_index_name() . '/get-autosuggest-allowed'
);
$body = wp_remote_retrieve_body( $response, true );
return json_decode( $body, true );
}
/**
* Output the current allowed parameters for autosuggest stored in ElasticPress.io.
*/
public function epio_allowed_parameters() {
global $wp_version;
$allowed_params = $this->epio_autosuggest_set_and_get();
if ( empty( $allowed_params ) ) {
return;
}
?>
epio_retrieve_autosuggest_allowed();
if ( is_wp_error( $allowed_params ) || ( isset( $allowed_params['status'] ) && 200 !== $allowed_params['status'] ) ) {
$allowed_params = [];
break;
}
// We have what we need, no need to retry.
if ( ! empty( $allowed_params ) ) {
break;
}
// Send to EP.io what should be autosuggest's allowed values and try to get them again.
$this->epio_send_autosuggest_public_request( true );
}
return $allowed_params;
}
/**
* Returns the title.
*
* @since 4.4.1
* @return string
*/
public function get_title() : string {
if ( ! Utils\is_epio() ) {
return esc_html__( 'Autosuggest', 'elasticpress' );
}
/* translators: 1. elasticpress.io logo; */
return sprintf( esc_html__( 'Autosuggest By %s', 'elasticpress' ), $this->get_epio_logo() );
}
/**
* Return true, so EP knows we want to intercept the remote request
*
* As we add and remove this function from `ep_intercept_remote_request`,
* using `__return_true` could remove a *real* `__return_true` added by someone else.
*
* @since 4.7.0
* @see https://github.com/10up/ElasticPress/issues/2887
* @return true
*/
public function intercept_remote_request() {
return true;
}
/**
* DEPRECATED. Delete the cached query for autosuggest.
*
* @since 3.5.5
*/
public function delete_cached_query() {
_doing_it_wrong(
__METHOD__,
esc_html__( 'This method should not be called anymore, as autosuggest requests are not sent regularly anymore.' ),
'ElasticPress 4.7.0'
);
}
}