PluginProbe
ElasticPress / 4.5.2
ElasticPress v4.5.2
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Feature / Autosuggest / Autosuggest.php

Autosuggest.php in ElasticPress 4.5.2, at includes/classes/Feature/Autosuggest/Autosuggest.php

906 lines 28.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Autosuggest feature
4 *
5 * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
6 *
7 * @package elasticpress
8 */
9
10 namespace ElasticPress\Feature\Autosuggest;
11
12 use ElasticPress\Feature as Feature;
13 use ElasticPress\Features as Features;
14 use ElasticPress\Utils as Utils;
15 use ElasticPress\FeatureRequirementsStatus as FeatureRequirementsStatus;
16 use ElasticPress\Indexables as Indexables;
17 use ElasticPress\Elasticsearch;
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit; // Exit if accessed directly.
21 }
22
23 /**
24 * Autosuggest feature class
25 */
26 class Autosuggest extends Feature {
27
28 /**
29 * Autosuggest query generated by intercept_search_request
30 *
31 * @var array
32 */
33 public $autosuggest_query = [];
34
35 /**
36 * Initialize feature setting it's config
37 *
38 * @since 3.0
39 */
40 public function __construct() {
41 $this->slug = 'autosuggest';
42
43 $this->title = $this->get_title();
44
45 $this->short_title = esc_html__( 'Autosuggest', 'elasticpress' );
46
47 $this->summary = __( 'Suggest relevant content as text is entered into the search field.', 'elasticpress' );
48
49 $this->docs_url = __( 'https://elasticpress.zendesk.com/hc/en-us/articles/360050447492-Configuring-ElasticPress-via-the-Plugin-Dashboard#autosuggest', 'elasticpress' );
50
51 $this->requires_install_reindex = true;
52
53 $this->default_settings = [
54 'endpoint_url' => '',
55 'autosuggest_selector' => '',
56 'trigger_ga_event' => '0',
57 ];
58
59 $this->available_during_installation = true;
60
61 parent::__construct();
62 }
63
64 /**
65 * Output feature box long
66 *
67 * @since 2.4
68 */
69 public function output_feature_box_long() {
70 ?>
71 <p><?php esc_html_e( 'Input fields of type "search" or with the CSS class "search-field" or "ep-autosuggest" will be enhanced with autosuggest functionality. As text is entered into the search field, suggested content will appear below it, based on top search results for the text. Suggestions link directly to the content.', 'elasticpress' ); ?></p>
72 <?php
73 }
74
75 /**
76 * Setup feature functionality
77 *
78 * @since 2.4
79 */
80 public function setup() {
81 add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
82 add_filter( 'ep_post_mapping', [ $this, 'mapping' ] );
83 add_filter( 'ep_post_sync_args', [ $this, 'filter_term_suggest' ], 10 );
84 add_filter( 'ep_post_fuzziness_arg', [ $this, 'set_fuzziness' ], 10, 3 );
85 add_filter( 'ep_weighted_query_for_post_type', [ $this, 'adjust_fuzzy_fields' ], 10, 3 );
86 add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_send_autosuggest_public_request' ] );
87 add_filter( 'wp', [ $this, 'epio_send_autosuggest_allowed' ] );
88 add_filter( 'ep_pre_dashboard_index', [ $this, 'epio_send_autosuggest_public_request' ] );
89 add_filter( 'ep_wp_cli_pre_index', [ $this, 'epio_send_autosuggest_public_request' ] );
90
91 add_action( 'ep_cli_after_set_search_algorithm_version', [ $this, 'delete_cached_query' ] );
92 add_action( 'ep_wp_cli_after_index', [ $this, 'delete_cached_query' ] );
93 add_action( 'ep_after_dashboard_index', [ $this, 'delete_cached_query' ] );
94 add_action( 'ep_after_update_feature', [ $this, 'delete_cached_query' ] );
95 add_action( 'ep_cli_after_clear_index', [ $this, 'delete_cached_query' ] );
96 }
97
98 /**
99 * Display decaying settings on dashboard.
100 *
101 * @since 2.4
102 */
103 public function output_feature_box_settings() {
104 $settings = $this->get_settings();
105
106 if ( ! $settings ) {
107 $settings = [];
108 }
109
110 $settings = wp_parse_args( $settings, $this->default_settings );
111
112 ?>
113 <div class="field">
114 <div class="field-name status"><label for="feature_autosuggest_selector"><?php esc_html_e( 'Autosuggest Selector', 'elasticpress' ); ?></label></div>
115 <div class="input-wrap">
116 <input value="<?php echo empty( $settings['autosuggest_selector'] ) ? '.ep-autosuggest' : esc_attr( $settings['autosuggest_selector'] ); ?>" type="text" name="settings[autosuggest_selector]" id="feature_autosuggest_selector">
117 <p class="field-description"><?php esc_html_e( 'Input additional selectors where you would like to include autosuggest separated by a comma. Example: .custom-selector, #custom-id, input[type="text"]', 'elasticpress' ); ?></p>
118 </div>
119 </div>
120
121 <div class="field">
122 <div class="field-name status"><?php esc_html_e( 'Google Analytics Events', 'elasticpress' ); ?></div>
123 <div class="input-wrap">
124 <label><input name="settings[trigger_ga_event]" <?php checked( (bool) $settings['trigger_ga_event'] ); ?> type="radio" value="1"><?php esc_html_e( 'Enabled', 'elasticpress' ); ?></label><br>
125 <label><input name="settings[trigger_ga_event]" <?php checked( ! (bool) $settings['trigger_ga_event'] ); ?> type="radio" value="0"><?php esc_html_e( 'Disabled', 'elasticpress' ); ?></label>
126 <p class="field-description"><?php esc_html_e( 'When enabled, a gtag tracking event is fired when an autosuggest result is clicked.', 'elasticpress' ); ?></p>
127 </div>
128 </div>
129 <?php
130
131 if ( Utils\is_epio() ) {
132 $this->epio_allowed_parameters();
133 return;
134 }
135
136 $endpoint_url = ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) ? EP_AUTOSUGGEST_ENDPOINT : $settings['endpoint_url'];
137 ?>
138
139 <div class="field">
140 <div class="field-name status"><label for="feature_autosuggest_endpoint_url"><?php esc_html_e( 'Endpoint URL', 'elasticpress' ); ?></label></div>
141 <div class="input-wrap">
142 <input <?php disabled( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ); ?> value="<?php echo esc_url( $endpoint_url ); ?>" type="text" name="settings[endpoint_url]" id="feature_autosuggest_endpoint_url">
143
144 <?php if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) : ?>
145 <p class="field-description"><?php esc_html_e( 'Your autosuggest endpoint is set in wp-config.php', 'elasticpress' ); ?></p>
146 <?php endif; ?>
147
148 <p class="field-description"><?php esc_html_e( 'This address will be exposed to the public.', 'elasticpress' ); ?></p>
149 </div>
150 </div>
151
152 <?php
153 }
154
155 /**
156 * Add mapping for suggest fields
157 *
158 * @param array $mapping ES mapping.
159 * @since 2.4
160 * @return array
161 */
162 public function mapping( $mapping ) {
163 $post_indexable = Indexables::factory()->get( 'post' );
164
165 $mapping = $post_indexable->add_ngram_analyzer( $mapping );
166 $mapping = $post_indexable->add_term_suggest_field( $mapping );
167
168 // Note the assignment by reference below.
169 if ( version_compare( Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
170 $mapping_properties = &$mapping['mappings']['post']['properties'];
171 } else {
172 $mapping_properties = &$mapping['mappings']['properties'];
173 }
174
175 $text_type = $mapping_properties['post_content']['type'];
176
177 $mapping_properties['post_title']['fields']['suggest'] = array(
178 'type' => $text_type,
179 'analyzer' => 'edge_ngram_analyzer',
180 'search_analyzer' => 'standard',
181 );
182
183 return $mapping;
184 }
185
186 /**
187 * Ensure both search and autosuggest use fuziness with type auto
188 *
189 * @param integer $fuzziness Fuzziness
190 * @param array $search_fields Search Fields
191 * @param array $args Array of ES args
192 * @return array
193 */
194 public function set_fuzziness( $fuzziness, $search_fields, $args ) {
195 if ( Utils\is_integrated_request( $this->slug, [ 'public' ] ) && ! empty( $args['s'] ) ) {
196 return 'auto';
197 }
198 return $fuzziness;
199 }
200
201 /**
202 * Handle ngram search fields for fuzziness fields
203 *
204 * @param array $query ES Query arguments
205 * @param string $post_type Post Type
206 * @param array $args WP_Query args
207 * @return array $query adjusted ES Query arguments
208 */
209 public function adjust_fuzzy_fields( $query, $post_type, $args ) {
210 if ( ! Utils\is_integrated_request( $this->slug, [ 'public' ] ) || empty( $args['s'] ) ) {
211 return $query;
212 }
213
214 if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['must'] ) ) {
215 return $query;
216 }
217
218 /**
219 * Filter autosuggest ngram fields
220 *
221 * @hook ep_autosuggest_ngram_fields
222 * @param {array} $fields Fields available to ngram
223 * @return {array} New fields array
224 */
225 $ngram_fields = apply_filters(
226 'ep_autosuggest_ngram_fields',
227 [
228 'post_title' => 'post_title.suggest',
229 'terms\.(.+)\.name' => 'term_suggest',
230 ]
231 );
232
233 /**
234 * At this point, `$query` might look like this (using the 3.5 search algorithm):
235 *
236 * [
237 * [bool] => [
238 * [must] => [
239 * [0] => [
240 * [bool] => [
241 * [should] => [
242 * [0] => [
243 * [multi_match] => [
244 * [query] => ep_autosuggest_placeholder
245 * [type] => phrase
246 * [fields] => [
247 * [0] => post_title^1
248 * ...
249 * [n] => terms.category.name^27
250 * ]
251 * [boost] => 3
252 * ]
253 * ]
254 * [1] => [
255 * [multi_match] => [
256 * [query] => ep_autosuggest_placeholder
257 * [fields] => [ ... ]
258 * [type] => phrase
259 * [slop] => 5
260 * ]
261 * ]
262 * ]
263 * ]
264 * ]
265 * ]
266 * ]
267 * ...
268 * ]
269 *
270 * Also, note the usage of `&$must_query`. This means that by changing `$must_query`
271 * you will be actually changing `$query`.
272 */
273 foreach ( $query['bool']['must'] as &$must_query ) {
274 if ( ! isset( $must_query['bool'] ) || ! isset( $must_query['bool']['should'] ) ) {
275 continue;
276 }
277 foreach ( $must_query['bool']['should'] as &$current_bool_should ) {
278 if ( ! isset( $current_bool_should['multi_match'] ) || ! isset( $current_bool_should['multi_match']['fields'] ) ) {
279 continue;
280 }
281
282 /**
283 * `fuzziness` is used in the original algorithm.
284 * `slop` is used in `3.5`.
285 *
286 * @see \ElasticPress\Indexable\Post\Post::format_args()
287 */
288 if ( empty( $current_bool_should['multi_match']['fuzziness'] ) && empty( $current_bool_should['multi_match']['slop'] ) ) {
289 continue;
290 }
291
292 $fields_to_add = [];
293
294 /**
295 * If the regex used in `$ngram_fields` matches more than one field,
296 * like taxonomies, for example, we use the min value - 1.
297 */
298 foreach ( $current_bool_should['multi_match']['fields'] as $field ) {
299 foreach ( $ngram_fields as $regex => $ngram_field ) {
300 if ( preg_match( '/^(' . $regex . ')(\^(\d+))?$/', $field, $match ) ) {
301 $weight = 1;
302 if ( isset( $match[4] ) && $match[4] > 1 ) {
303 $weight = $match[4] - 1;
304 }
305
306 if ( isset( $fields_to_add[ $ngram_field ] ) ) {
307 $fields_to_add[ $ngram_field ] = min( $fields_to_add[ $ngram_field ], $weight );
308 } else {
309 $fields_to_add[ $ngram_field ] = $weight;
310 }
311 }
312 }
313 }
314
315 foreach ( $fields_to_add as $field => $weight ) {
316 $current_bool_should['multi_match']['fields'][] = "{$field}^{$weight}";
317 }
318 }
319 }
320
321 return $query;
322 }
323
324 /**
325 * Add term suggestions to be indexed
326 *
327 * @param array $post_args Array of ES args.
328 * @since 2.4
329 * @return array
330 */
331 public function filter_term_suggest( $post_args ) {
332 $suggest = [];
333
334 if ( ! empty( $post_args['terms'] ) ) {
335 foreach ( $post_args['terms'] as $taxonomy ) {
336 foreach ( $taxonomy as $term ) {
337 $suggest[] = $term['name'];
338 }
339 }
340 }
341
342 if ( ! empty( $suggest ) ) {
343 $post_args['term_suggest'] = $suggest;
344 }
345
346 return $post_args;
347 }
348
349 /**
350 * Enqueue our autosuggest script
351 *
352 * @since 2.4
353 */
354 public function enqueue_scripts() {
355 if ( Utils\is_indexing() ) {
356 return;
357 }
358
359 $host = Utils\get_host();
360 $endpoint_url = false;
361 $settings = $this->get_settings();
362
363 if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) {
364 $endpoint_url = EP_AUTOSUGGEST_ENDPOINT;
365 } else {
366 if ( Utils\is_epio() ) {
367 $endpoint_url = trailingslashit( $host ) . Indexables::factory()->get( 'post' )->get_index_name() . '/autosuggest';
368 } else {
369 if ( ! $settings ) {
370 $settings = [];
371 }
372
373 $settings = wp_parse_args( $settings, $this->default_settings );
374
375 if ( empty( $settings['endpoint_url'] ) ) {
376 return;
377 }
378
379 $endpoint_url = $settings['endpoint_url'];
380 }
381 }
382
383 wp_enqueue_script(
384 'elasticpress-autosuggest',
385 EP_URL . 'dist/js/autosuggest-script.js',
386 Utils\get_asset_info( 'autosuggest-script', 'dependencies' ),
387 Utils\get_asset_info( 'autosuggest-script', 'version' ),
388 true
389 );
390
391 wp_set_script_translations( 'elasticpress-autosuggest', 'elasticpress' );
392
393 wp_enqueue_style(
394 'elasticpress-autosuggest',
395 EP_URL . 'dist/css/autosuggest-styles.css',
396 Utils\get_asset_info( 'autosuggest-styles', 'dependencies' ),
397 Utils\get_asset_info( 'autosuggest-styles', 'version' )
398 );
399
400 /** Features Class @var Features $features */
401 $features = Features::factory();
402
403 /** Search Feature @var Feature\Search\Search $search */
404 $search = $features->get_registered_feature( 'search' );
405
406 $post_types = $search->get_searchable_post_types();
407 $post_status = get_post_stati(
408 [
409 'public' => true,
410 'exclude_from_search' => false,
411 ]
412 );
413
414 $query = $this->generate_search_query();
415
416 $epas_options = [
417 'query' => $query['body'],
418 'placeholder' => $query['placeholder'],
419 'endpointUrl' => esc_url( untrailingslashit( $endpoint_url ) ),
420 'selector' => empty( $settings['autosuggest_selector'] ) ? 'ep-autosuggest' : esc_html( $settings['autosuggest_selector'] ),
421 /**
422 * Filter autosuggest default selectors.
423 *
424 * @hook ep_autosuggest_default_selectors
425 * @since 3.6.0
426 * @param {string} $selectors Default selectors used to attach autosuggest.
427 * @return {string} Selectors used to attach autosuggest.
428 */
429 'defaultSelectors' => apply_filters( 'ep_autosuggest_default_selectors', '.ep-autosuggest, input[type="search"], .search-field' ),
430 'action' => 'navigate',
431 'mimeTypes' => [],
432 /**
433 * Filter autosuggest HTTP headers
434 *
435 * @hook ep_autosuggest_http_headers
436 * @param {array} $headers Autosuggest HTTP headers in name => value format
437 * @return {array} HTTP headers
438 */
439 'http_headers' => apply_filters( 'ep_autosuggest_http_headers', [] ),
440 'triggerAnalytics' => ! empty( $settings['trigger_ga_event'] ),
441 'addSearchTermHeader' => false,
442 'requestIdBase' => Utils\get_request_id_base(),
443 ];
444
445 if ( Utils\is_epio() ) {
446 $epas_options['addSearchTermHeader'] = true;
447 }
448
449 $search_settings = $search->get_settings();
450
451 if ( ! $search_settings ) {
452 $search_settings = [];
453 }
454
455 $search_settings = wp_parse_args( $search_settings, $search->default_settings );
456
457 if ( ! empty( $search_settings ) && $search_settings['highlight_enabled'] ) {
458 $epas_options['highlightingEnabled'] = true;
459 $epas_options['highlightingTag'] = apply_filters( 'ep_highlighting_tag', $search_settings['highlight_tag'] );
460 $epas_options['highlightingClass'] = apply_filters( 'ep_highlighting_class', 'ep-highlight' );
461 }
462
463 /**
464 * Output variables to use in Javascript
465 * index: the Elasticsearch index name
466 * endpointUrl: the Elasticsearch autosuggest endpoint url
467 * postType: which post types to use for suggestions
468 * action: the action to take when selecting an item. Possible values are "search" and "navigate".
469 */
470 wp_localize_script(
471 'elasticpress-autosuggest',
472 'epas',
473 /**
474 * Filter autosuggest JavaScript options
475 *
476 * @hook ep_autosuggest_options
477 * @param {array} $options Autosuggest options to be localized
478 * @return {array} New options
479 */
480 apply_filters(
481 'ep_autosuggest_options',
482 $epas_options
483 )
484 );
485 }
486
487 /**
488 * Build a default search request to pass to the autosuggest javascript.
489 * The request will include a placeholder that can then be replaced.
490 *
491 * @return array Generated ElasticSearch request array( 'placeholder'=> placeholderstring, 'body' => request body )
492 */
493 public function generate_search_query() {
494
495 /**
496 * Filter autosuggest query placeholder
497 *
498 * @hook ep_autosuggest_query_placeholder
499 * @param {string} $placeholder Autosuggest placeholder to be replaced later
500 * @return {string} New placeholder
501 */
502 $placeholder = apply_filters( 'ep_autosuggest_query_placeholder', 'ep_autosuggest_placeholder' );
503
504 /** Features Class @var Features $features */
505 $features = Features::factory();
506
507 $post_type = $features->get_registered_feature( 'search' )->get_searchable_post_types();
508
509 /**
510 * Filter post types available to autosuggest
511 *
512 * @hook ep_term_suggest_post_type
513 * @param {array} $post_types Post types
514 * @return {array} New post types
515 */
516 $post_type = apply_filters( 'ep_term_suggest_post_type', array_values( $post_type ) );
517
518 $post_status = get_post_stati(
519 [
520 'public' => true,
521 'exclude_from_search' => false,
522 ]
523 );
524
525 /**
526 * Filter post statuses available to autosuggest
527 *
528 * @hook ep_term_suggest_post_status
529 * @param {array} $post_statuses Post statuses
530 * @return {array} New post statuses
531 */
532 $post_status = apply_filters( 'ep_term_suggest_post_status', array_values( $post_status ) );
533
534 add_filter( 'ep_intercept_remote_request', '__return_true' );
535 add_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ], 10, 1 );
536
537 add_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ], 10, 4 );
538
539 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
540
541 new \WP_Query(
542 /**
543 * Filter WP Query args of the autosuggest query template.
544 *
545 * If you want to display 20 posts in autosuggest:
546 *
547 * ```
548 * add_filter(
549 * 'ep_autosuggest_query_args',
550 * function( $args ) {
551 * $args['posts_per_page'] = 20;
552 * return $args;
553 * }
554 * );
555 * ```
556 *
557 * @since 4.4.0
558 * @hook ep_autosuggest_query_args
559 * @param {array} $args Query args
560 * @return {array} New query args
561 */
562 apply_filters(
563 'ep_autosuggest_query_args',
564 [
565 'post_type' => $post_type,
566 'post_status' => $post_status,
567 's' => $placeholder,
568 'ep_integrate' => true,
569 ]
570 )
571 );
572
573 remove_filter( 'posts_pre_query', [ $features->get_registered_feature( $this->slug ), 'return_empty_posts' ], 100 );
574
575 remove_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ] );
576
577 remove_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ] );
578
579 remove_filter( 'ep_intercept_remote_request', '__return_true' );
580
581 return [
582 'body' => $this->autosuggest_query,
583 'placeholder' => $placeholder,
584 ];
585 }
586
587 /**
588 * Ensure we do not fallback to WPDB query for this request
589 *
590 * @param array $posts array of post objects
591 * @return array $posts
592 */
593 public function return_empty_posts( $posts = [] ) {
594 return [];
595 }
596
597 /**
598 * Allow applying custom weighting configuration for autosuggest
599 *
600 * @param array $config current configuration
601 * @return array $config desired configuration
602 */
603 public function apply_autosuggest_weighting( $config = [] ) {
604 /**
605 * Filter autosuggest weighting configuration
606 *
607 * @hook ep_weighting_configuration_for_autosuggest
608 * @param {array} $config Configuration
609 * @return {array} New config
610 */
611 $config = apply_filters( 'ep_weighting_configuration_for_autosuggest', $config );
612 return $config;
613 }
614
615 /**
616 * Store intercepted request value and return (cached) request result
617 *
618 * @param object $response Response
619 * @param array $query Query
620 * @param array $args WP_Query Argument array
621 * @param int $failures Count of failures in request loop
622 * @return object $response Response
623 */
624 public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) {
625 $this->autosuggest_query = $query['args']['body'];
626
627 // Let's make sure we also fire off the dummy request if settings have changed.
628 // But only fire this if we have object caching as otherwise this comes with a performance penalty.
629 // If we do not have object caching we cache only one value for 5 minutes in a transient.
630 if ( wp_using_ext_object_cache() ) {
631 $cache_key = md5( wp_json_encode( $query['url'] ) . wp_json_encode( $args['body'] ) );
632 $request = wp_cache_get( $cache_key, 'ep_autosuggest' );
633 if ( false === $request ) {
634 $request = wp_remote_request( $query['url'], $args );
635 if ( isset( $request->http_response ) && isset( $request->http_response->body ) ) {
636 $request->http_response->body = '';
637 }
638 wp_cache_set( $cache_key, $request, 'ep_autosuggest' );
639 }
640 } else {
641 $cache_key = 'ep_autosuggest_query_request_cache';
642 $request = get_transient( $cache_key );
643 if ( false === $request ) {
644 $request = wp_remote_request( $query['url'], $args );
645 if ( isset( $request->http_response ) && isset( $request->http_response->body ) ) {
646 $request->http_response->body = '';
647 }
648 set_transient( $cache_key, $request, 5 * MINUTE_IN_SECONDS );
649 }
650 }
651
652 return $request;
653 }
654
655 /**
656 * Delete the cached query for autosuggest.
657 *
658 * @since 3.5.5
659 */
660 public function delete_cached_query() {
661 global $wp_object_cache;
662 if ( wp_using_ext_object_cache() ) {
663 if ( function_exists( 'wp_cache_supports_group_flush' ) && wp_cache_supports_group_flush() ) {
664 wp_cache_flush_group( 'ep_autosuggest' );
665 } else {
666 // Try to delete the entire group.
667 // This may fail because the `$cache` property is not standardized.
668 unset( $wp_object_cache->cache['ep_autosuggest'] );
669 }
670 } else {
671 delete_transient( 'ep_autosuggest_query_request_cache' );
672 }
673 }
674
675 /**
676 * Tell user whether requirements for feature are met or not.
677 *
678 * @return array $status Status array
679 * @since 2.4
680 */
681 public function requirements_status() {
682 $status = new FeatureRequirementsStatus( 0 );
683
684 $status->message = [];
685
686 $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' );
687
688 if ( ! Utils\is_epio() ) {
689 $status->code = 1;
690 $status->message[] = wp_kses_post( __( "You aren't using <a href='https://elasticpress.io'>ElasticPress.io</a> 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' ) );
691 }
692
693 return $status;
694 }
695
696 /**
697 * Do a non-blocking search query to force the autosuggest hash to update.
698 *
699 * This request has to happen in a public environment, so all code testing if `is_admin()`
700 * are properly executed.
701 *
702 * @param bool $blocking If the request should block the execution or not.
703 */
704 public function epio_send_autosuggest_public_request( $blocking = false ) {
705 if ( ! Utils\is_epio() ) {
706 return;
707 }
708
709 $url = add_query_arg(
710 [
711 's' => 'search test',
712 'ep_epio_set_autosuggest' => 1,
713 'ep_epio_nonce' => wp_create_nonce( 'ep-epio-set-autosuggest' ),
714 'nocache' => time(), // Here just to avoid the request hitting a CDN.
715 ],
716 home_url( '/' )
717 );
718
719 // Pass the same cookies, so the same authenticated user is used (and we can check the nonce).
720 $cookies = [];
721 foreach ( $_COOKIE as $name => $value ) {
722 $cookies[] = new \WP_Http_Cookie(
723 [
724 'name' => $name,
725 'value' => $value,
726 ]
727 );
728 }
729
730 wp_remote_get(
731 $url,
732 [
733 'cookies' => $cookies,
734 'blocking' => (bool) $blocking,
735 ]
736 );
737 }
738
739 /**
740 * Send the allowed parameters for autosuggest to ElasticPress.io.
741 */
742 public function epio_send_autosuggest_allowed() {
743 if ( empty( $_REQUEST['ep_epio_nonce'] ) || ! wp_verify_nonce( $_REQUEST['ep_epio_nonce'], 'ep-epio-set-autosuggest' ) ) {
744 return;
745 }
746 if ( empty( $_GET['ep_epio_set_autosuggest'] ) ) {
747 return;
748 }
749
750 /**
751 * Fires before the request is sent to EP.io to set Autosuggest allowed values.
752 *
753 * @hook ep_epio_pre_send_autosuggest_allowed
754 * @since 3.5.x
755 */
756 do_action( 'ep_epio_pre_send_autosuggest_allowed' );
757
758 /**
759 * The same ES query sent by autosuggest.
760 *
761 * Sometimes it'll be a string, sometimes it'll be already an array.
762 */
763 $es_search_query = $this->generate_search_query()['body'];
764 $es_search_query = ( is_array( $es_search_query ) ) ? $es_search_query : json_decode( $es_search_query, true );
765
766 /**
767 * Filter autosuggest ES query
768 *
769 * @since 3.5.x
770 * @hook ep_epio_autosuggest_es_query
771 * @param {array} The ES Query.
772 */
773 $es_search_query = apply_filters( 'ep_epio_autosuggest_es_query', $es_search_query );
774
775 /**
776 * Here is a chance to short-circuit the execution. Also, during the sync
777 * the query will be empty anyway.
778 */
779 if ( empty( $es_search_query ) ) {
780 return;
781 }
782
783 $index = Indexables::factory()->get( 'post' )->get_index_name();
784
785 add_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
786
787 Elasticsearch::factory()->query( $index, 'post', $es_search_query, [] );
788
789 remove_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
790
791 /**
792 * Fires after the request is sent to EP.io to set Autosuggest allowed values.
793 *
794 * @hook ep_epio_sent_autosuggest_allowed
795 * @since 3.5.x
796 */
797 do_action( 'ep_epio_sent_autosuggest_allowed' );
798 }
799
800 /**
801 * Set a header so EP.io servers know this request contains the values
802 * that should be stored as allowed.
803 *
804 * @since 3.5.x
805 * @param array $headers The Request Headers.
806 * @return array
807 */
808 public function add_ep_set_autosuggest_header( $headers ) {
809 $headers['EP-Set-Autosuggest'] = true;
810 return $headers;
811 }
812
813 /**
814 * Retrieve the allowed parameters for autosuggest from ElasticPress.io.
815 *
816 * @return array
817 */
818 public function epio_retrieve_autosuggest_allowed() {
819 $response = Elasticsearch::factory()->remote_request(
820 Indexables::factory()->get( 'post' )->get_index_name() . '/get-autosuggest-allowed'
821 );
822
823 $body = wp_remote_retrieve_body( $response, true );
824 return json_decode( $body, true );
825 }
826
827 /**
828 * Output the current allowed parameters for autosuggest stored in ElasticPress.io.
829 */
830 public function epio_allowed_parameters() {
831 global $wp_version;
832
833 $allowed_params = $this->epio_autosuggest_set_and_get();
834 if ( empty( $allowed_params ) ) {
835 return;
836 }
837 ?>
838 <div class="field js-toggle-feature" data-feature="<?php echo esc_attr( $this->slug ); ?>">
839 <div class="field-name status"><?php esc_html_e( 'Connection', 'elasticpress' ); ?></div>
840 <div class="input-wrap">
841 <?php
842 $epio_link = 'https://elasticpress.io';
843 $epio_autosuggest_kb_link = 'https://elasticpress.zendesk.com/hc/en-us/articles/360055402791';
844 $status_report_link = defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ? network_admin_url( 'admin.php?page=elasticpress-status-report' ) : admin_url( 'admin.php?page=elasticpress-status-report' );
845
846 printf(
847 /* translators: 1: <a> tag (ElasticPress.io); 2. </a>; 3: <a> tag (KB article); 4. </a>; 5: <a> tag (Site Health Debug Section); 6. </a>; */
848 esc_html__( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ),
849 '<a href="' . esc_url( $epio_link ) . '">',
850 '</a>',
851 '<a href="' . esc_url( $epio_autosuggest_kb_link ) . '">',
852 '</a>',
853 '<a href="' . esc_url( $status_report_link ) . '">',
854 '</a>'
855 );
856 ?>
857 </div>
858 </div>
859 <?php
860 }
861
862 /**
863 * Try to get the allowed parameters. If they are not set, set it and try to get them again.
864 *
865 * @since 3.5.x
866 * @return array
867 */
868 public function epio_autosuggest_set_and_get() {
869 $allowed_params = [];
870 $errors_count = 1;
871 for ( $i = 0; $i <= $errors_count; $i++ ) {
872 $allowed_params = $this->epio_retrieve_autosuggest_allowed();
873
874 if ( is_wp_error( $allowed_params ) || ( isset( $allowed_params['status'] ) && 200 !== $allowed_params['status'] ) ) {
875 $allowed_params = [];
876 break;
877 }
878
879 // We have what we need, no need to retry.
880 if ( ! empty( $allowed_params ) ) {
881 break;
882 }
883
884 // Send to EP.io what should be autosuggest's allowed values and try to get them again.
885 $this->epio_send_autosuggest_public_request( true );
886 }
887
888 return $allowed_params;
889 }
890
891 /**
892 * Returns the title.
893 *
894 * @since 4.4.1
895 * @return string
896 */
897 public function get_title() : string {
898 if ( ! Utils\is_epio() ) {
899 return esc_html__( 'Autosuggest', 'elasticpress' );
900 }
901
902 /* translators: 1. elasticpress.io logo; */
903 return sprintf( esc_html__( 'Autosuggest By %s', 'elasticpress' ), $this->get_epio_logo() );
904 }
905 }
906