PluginProbe
ElasticPress / 4.4.1
ElasticPress v4.4.1
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.4.1, at includes/classes/Feature/Autosuggest/Autosuggest.php

920 lines 29.2 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 $mapping['settings']['analysis']['analyzer']['edge_ngram_analyzer'] = array(
164 'type' => 'custom',
165 'tokenizer' => 'standard',
166 'filter' => array(
167 'lowercase',
168 'edge_ngram',
169 ),
170 );
171
172 if ( version_compare( Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
173 $text_type = $mapping['mappings']['post']['properties']['post_content']['type'];
174
175 $mapping['mappings']['post']['properties']['post_title']['fields']['suggest'] = array(
176 'type' => $text_type,
177 'analyzer' => 'edge_ngram_analyzer',
178 'search_analyzer' => 'standard',
179 );
180
181 $mapping['mappings']['post']['properties']['term_suggest'] = array(
182 'type' => $text_type,
183 'analyzer' => 'edge_ngram_analyzer',
184 'search_analyzer' => 'standard',
185 );
186 } else {
187 $text_type = $mapping['mappings']['properties']['post_content']['type'];
188
189 $mapping['mappings']['properties']['post_title']['fields']['suggest'] = array(
190 'type' => $text_type,
191 'analyzer' => 'edge_ngram_analyzer',
192 'search_analyzer' => 'standard',
193 );
194
195 $mapping['mappings']['properties']['term_suggest'] = array(
196 'type' => $text_type,
197 'analyzer' => 'edge_ngram_analyzer',
198 'search_analyzer' => 'standard',
199 );
200 }
201
202 return $mapping;
203 }
204
205 /**
206 * Ensure both search and autosuggest use fuziness with type auto
207 *
208 * @param integer $fuzziness Fuzziness
209 * @param array $search_fields Search Fields
210 * @param array $args Array of ES args
211 * @return array
212 */
213 public function set_fuzziness( $fuzziness, $search_fields, $args ) {
214 if ( Utils\is_integrated_request( $this->slug, [ 'public' ] ) && ! empty( $args['s'] ) ) {
215 return 'auto';
216 }
217 return $fuzziness;
218 }
219
220 /**
221 * Handle ngram search fields for fuzziness fields
222 *
223 * @param array $query ES Query arguments
224 * @param string $post_type Post Type
225 * @param array $args WP_Query args
226 * @return array $query adjusted ES Query arguments
227 */
228 public function adjust_fuzzy_fields( $query, $post_type, $args ) {
229 if ( ! Utils\is_integrated_request( $this->slug, [ 'public' ] ) || empty( $args['s'] ) ) {
230 return $query;
231 }
232
233 if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['must'] ) ) {
234 return $query;
235 }
236
237 /**
238 * Filter autosuggest ngram fields
239 *
240 * @hook ep_autosuggest_ngram_fields
241 * @param {array} $fields Fields available to ngram
242 * @return {array} New fields array
243 */
244 $ngram_fields = apply_filters(
245 'ep_autosuggest_ngram_fields',
246 [
247 'post_title' => 'post_title.suggest',
248 'terms\.(.+)\.name' => 'term_suggest',
249 ]
250 );
251
252 /**
253 * At this point, `$query` might look like this (using the 3.5 search algorithm):
254 *
255 * [
256 * [bool] => [
257 * [must] => [
258 * [0] => [
259 * [bool] => [
260 * [should] => [
261 * [0] => [
262 * [multi_match] => [
263 * [query] => ep_autosuggest_placeholder
264 * [type] => phrase
265 * [fields] => [
266 * [0] => post_title^1
267 * ...
268 * [n] => terms.category.name^27
269 * ]
270 * [boost] => 3
271 * ]
272 * ]
273 * [1] => [
274 * [multi_match] => [
275 * [query] => ep_autosuggest_placeholder
276 * [fields] => [ ... ]
277 * [type] => phrase
278 * [slop] => 5
279 * ]
280 * ]
281 * ]
282 * ]
283 * ]
284 * ]
285 * ]
286 * ...
287 * ]
288 *
289 * Also, note the usage of `&$must_query`. This means that by changing `$must_query`
290 * you will be actually changing `$query`.
291 */
292 foreach ( $query['bool']['must'] as &$must_query ) {
293 if ( ! isset( $must_query['bool'] ) || ! isset( $must_query['bool']['should'] ) ) {
294 continue;
295 }
296 foreach ( $must_query['bool']['should'] as &$current_bool_should ) {
297 if ( ! isset( $current_bool_should['multi_match'] ) || ! isset( $current_bool_should['multi_match']['fields'] ) ) {
298 continue;
299 }
300
301 /**
302 * `fuzziness` is used in the original algorithm.
303 * `slop` is used in `3.5`.
304 *
305 * @see \ElasticPress\Indexable\Post\Post::format_args()
306 */
307 if ( empty( $current_bool_should['multi_match']['fuzziness'] ) && empty( $current_bool_should['multi_match']['slop'] ) ) {
308 continue;
309 }
310
311 $fields_to_add = [];
312
313 /**
314 * If the regex used in `$ngram_fields` matches more than one field,
315 * like taxonomies, for example, we use the min value - 1.
316 */
317 foreach ( $current_bool_should['multi_match']['fields'] as $field ) {
318 foreach ( $ngram_fields as $regex => $ngram_field ) {
319 if ( preg_match( '/^(' . $regex . ')(\^(\d+))?$/', $field, $match ) ) {
320 $weight = 1;
321 if ( isset( $match[4] ) && $match[4] > 1 ) {
322 $weight = $match[4] - 1;
323 }
324
325 if ( isset( $fields_to_add[ $ngram_field ] ) ) {
326 $fields_to_add[ $ngram_field ] = min( $fields_to_add[ $ngram_field ], $weight );
327 } else {
328 $fields_to_add[ $ngram_field ] = $weight;
329 }
330 }
331 }
332 }
333
334 foreach ( $fields_to_add as $field => $weight ) {
335 $current_bool_should['multi_match']['fields'][] = "{$field}^{$weight}";
336 }
337 }
338 }
339
340 return $query;
341 }
342
343 /**
344 * Add term suggestions to be indexed
345 *
346 * @param array $post_args Array of ES args.
347 * @since 2.4
348 * @return array
349 */
350 public function filter_term_suggest( $post_args ) {
351 $suggest = [];
352
353 if ( ! empty( $post_args['terms'] ) ) {
354 foreach ( $post_args['terms'] as $taxonomy ) {
355 foreach ( $taxonomy as $term ) {
356 $suggest[] = $term['name'];
357 }
358 }
359 }
360
361 if ( ! empty( $suggest ) ) {
362 $post_args['term_suggest'] = $suggest;
363 }
364
365 return $post_args;
366 }
367
368 /**
369 * Enqueue our autosuggest script
370 *
371 * @since 2.4
372 */
373 public function enqueue_scripts() {
374 if ( Utils\is_indexing() ) {
375 return;
376 }
377
378 $host = Utils\get_host();
379 $endpoint_url = false;
380 $settings = $this->get_settings();
381
382 if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) {
383 $endpoint_url = EP_AUTOSUGGEST_ENDPOINT;
384 } else {
385 if ( Utils\is_epio() ) {
386 $endpoint_url = trailingslashit( $host ) . Indexables::factory()->get( 'post' )->get_index_name() . '/autosuggest';
387 } else {
388 if ( ! $settings ) {
389 $settings = [];
390 }
391
392 $settings = wp_parse_args( $settings, $this->default_settings );
393
394 if ( empty( $settings['endpoint_url'] ) ) {
395 return;
396 }
397
398 $endpoint_url = $settings['endpoint_url'];
399 }
400 }
401
402 wp_enqueue_script(
403 'elasticpress-autosuggest',
404 EP_URL . 'dist/js/autosuggest-script.js',
405 Utils\get_asset_info( 'autosuggest-script', 'dependencies' ),
406 Utils\get_asset_info( 'autosuggest-script', 'version' ),
407 true
408 );
409
410 wp_set_script_translations( 'elasticpress-autosuggest', 'elasticpress' );
411
412 wp_enqueue_style(
413 'elasticpress-autosuggest',
414 EP_URL . 'dist/css/autosuggest-styles.css',
415 Utils\get_asset_info( 'autosuggest-styles', 'dependencies' ),
416 Utils\get_asset_info( 'autosuggest-styles', 'version' )
417 );
418
419 /** Features Class @var Features $features */
420 $features = Features::factory();
421
422 /** Search Feature @var Feature\Search\Search $search */
423 $search = $features->get_registered_feature( 'search' );
424
425 $post_types = $search->get_searchable_post_types();
426 $post_status = get_post_stati(
427 [
428 'public' => true,
429 'exclude_from_search' => false,
430 ]
431 );
432
433 $query = $this->generate_search_query();
434
435 $epas_options = [
436 'query' => $query['body'],
437 'placeholder' => $query['placeholder'],
438 'endpointUrl' => esc_url( untrailingslashit( $endpoint_url ) ),
439 'selector' => empty( $settings['autosuggest_selector'] ) ? 'ep-autosuggest' : esc_html( $settings['autosuggest_selector'] ),
440 /**
441 * Filter autosuggest default selectors.
442 *
443 * @hook ep_autosuggest_default_selectors
444 * @since 3.6.0
445 * @param {string} $selectors Default selectors used to attach autosuggest.
446 * @return {string} Selectors used to attach autosuggest.
447 */
448 'defaultSelectors' => apply_filters( 'ep_autosuggest_default_selectors', '.ep-autosuggest, input[type="search"], .search-field' ),
449 'action' => 'navigate',
450 'mimeTypes' => [],
451 /**
452 * Filter autosuggest HTTP headers
453 *
454 * @hook ep_autosuggest_http_headers
455 * @param {array} $headers Autosuggest HTTP headers in name => value format
456 * @return {array} HTTP headers
457 */
458 'http_headers' => apply_filters( 'ep_autosuggest_http_headers', [] ),
459 'triggerAnalytics' => ! empty( $settings['trigger_ga_event'] ),
460 'addSearchTermHeader' => false,
461 ];
462
463 if ( Utils\is_epio() ) {
464 $epas_options['addSearchTermHeader'] = true;
465 }
466
467 $search_settings = $search->get_settings();
468
469 if ( ! $search_settings ) {
470 $search_settings = [];
471 }
472
473 $search_settings = wp_parse_args( $search_settings, $search->default_settings );
474
475 if ( ! empty( $search_settings ) && $search_settings['highlight_enabled'] ) {
476 $epas_options['highlightingEnabled'] = true;
477 $epas_options['highlightingTag'] = apply_filters( 'ep_highlighting_tag', $search_settings['highlight_tag'] );
478 $epas_options['highlightingClass'] = apply_filters( 'ep_highlighting_class', 'ep-highlight' );
479 }
480
481 /**
482 * Output variables to use in Javascript
483 * index: the Elasticsearch index name
484 * endpointUrl: the Elasticsearch autosuggest endpoint url
485 * postType: which post types to use for suggestions
486 * action: the action to take when selecting an item. Possible values are "search" and "navigate".
487 */
488 wp_localize_script(
489 'elasticpress-autosuggest',
490 'epas',
491 /**
492 * Filter autosuggest JavaScript options
493 *
494 * @hook ep_autosuggest_options
495 * @param {array} $options Autosuggest options to be localized
496 * @return {array} New options
497 */
498 apply_filters(
499 'ep_autosuggest_options',
500 $epas_options
501 )
502 );
503 }
504
505 /**
506 * Build a default search request to pass to the autosuggest javascript.
507 * The request will include a placeholder that can then be replaced.
508 *
509 * @return array Generated ElasticSearch request array( 'placeholder'=> placeholderstring, 'body' => request body )
510 */
511 public function generate_search_query() {
512
513 /**
514 * Filter autosuggest query placeholder
515 *
516 * @hook ep_autosuggest_query_placeholder
517 * @param {string} $placeholder Autosuggest placeholder to be replaced later
518 * @return {string} New placeholder
519 */
520 $placeholder = apply_filters( 'ep_autosuggest_query_placeholder', 'ep_autosuggest_placeholder' );
521
522 /** Features Class @var Features $features */
523 $features = Features::factory();
524
525 $post_type = $features->get_registered_feature( 'search' )->get_searchable_post_types();
526
527 /**
528 * Filter post types available to autosuggest
529 *
530 * @hook ep_term_suggest_post_type
531 * @param {array} $post_types Post types
532 * @return {array} New post types
533 */
534 $post_type = apply_filters( 'ep_term_suggest_post_type', array_values( $post_type ) );
535
536 $post_status = get_post_stati(
537 [
538 'public' => true,
539 'exclude_from_search' => false,
540 ]
541 );
542
543 /**
544 * Filter post statuses available to autosuggest
545 *
546 * @hook ep_term_suggest_post_status
547 * @param {array} $post_statuses Post statuses
548 * @return {array} New post statuses
549 */
550 $post_status = apply_filters( 'ep_term_suggest_post_status', array_values( $post_status ) );
551
552 add_filter( 'ep_intercept_remote_request', '__return_true' );
553 add_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ], 10, 1 );
554
555 add_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ], 10, 4 );
556
557 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
558
559 new \WP_Query(
560 /**
561 * Filter WP Query args of the autosuggest query template.
562 *
563 * If you want to display 20 posts in autosuggest:
564 *
565 * ```
566 * add_filter(
567 * 'ep_autosuggest_query_args',
568 * function( $args ) {
569 * $args['posts_per_page'] = 20;
570 * return $args;
571 * }
572 * );
573 * ```
574 *
575 * @since 4.4.0
576 * @hook ep_autosuggest_query_args
577 * @param {array} $args Query args
578 * @return {array} New query args
579 */
580 apply_filters(
581 'ep_autosuggest_query_args',
582 [
583 'post_type' => $post_type,
584 'post_status' => $post_status,
585 's' => $placeholder,
586 'ep_integrate' => true,
587 ]
588 )
589 );
590
591 remove_filter( 'posts_pre_query', [ $features->get_registered_feature( $this->slug ), 'return_empty_posts' ], 100 );
592
593 remove_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ] );
594
595 remove_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ] );
596
597 remove_filter( 'ep_intercept_remote_request', '__return_true' );
598
599 return [
600 'body' => $this->autosuggest_query,
601 'placeholder' => $placeholder,
602 ];
603 }
604
605 /**
606 * Ensure we do not fallback to WPDB query for this request
607 *
608 * @param array $posts array of post objects
609 * @return array $posts
610 */
611 public function return_empty_posts( $posts = [] ) {
612 return [];
613 }
614
615 /**
616 * Allow applying custom weighting configuration for autosuggest
617 *
618 * @param array $config current configuration
619 * @return array $config desired configuration
620 */
621 public function apply_autosuggest_weighting( $config = [] ) {
622 /**
623 * Filter autosuggest weighting configuration
624 *
625 * @hook ep_weighting_configuration_for_autosuggest
626 * @param {array} $config Configuration
627 * @return {array} New config
628 */
629 $config = apply_filters( 'ep_weighting_configuration_for_autosuggest', $config );
630 return $config;
631 }
632
633 /**
634 * Store intercepted request value and return (cached) request result
635 *
636 * @param object $response Response
637 * @param array $query Query
638 * @param array $args WP_Query Argument array
639 * @param int $failures Count of failures in request loop
640 * @return object $response Response
641 */
642 public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) {
643 $this->autosuggest_query = $query['args']['body'];
644
645 // Let's make sure we also fire off the dummy request if settings have changed.
646 // But only fire this if we have object caching as otherwise this comes with a performance penalty.
647 // If we do not have object caching we cache only one value for 5 minutes in a transient.
648 if ( wp_using_ext_object_cache() ) {
649 $cache_key = md5( wp_json_encode( $query['url'] ) . wp_json_encode( $args ) );
650 $request = wp_cache_get( $cache_key, 'ep_autosuggest' );
651 if ( false === $request ) {
652 $request = wp_remote_request( $query['url'], $args );
653 if ( isset( $request->http_response ) && isset( $request->http_response->body ) ) {
654 $request->http_response->body = '';
655 }
656 wp_cache_set( $cache_key, $request, 'ep_autosuggest' );
657 }
658 } else {
659 $cache_key = 'ep_autosuggest_query_request_cache';
660 $request = get_transient( $cache_key );
661 if ( false === $request ) {
662 $request = wp_remote_request( $query['url'], $args );
663 if ( isset( $request->http_response ) && isset( $request->http_response->body ) ) {
664 $request->http_response->body = '';
665 }
666 set_transient( $cache_key, $request, 5 * MINUTE_IN_SECONDS );
667 }
668 }
669
670 return $request;
671 }
672
673 /**
674 * Delete the cached query for autosuggest.
675 *
676 * @since 3.5.5
677 */
678 public function delete_cached_query() {
679 global $wp_object_cache;
680 if ( wp_using_ext_object_cache() ) {
681 if ( function_exists( 'wp_cache_supports_group_flush' ) && wp_cache_supports_group_flush() ) {
682 wp_cache_flush_group( 'ep_autosuggest' );
683 } else {
684 // Try to delete the entire group.
685 // This may fail because the `$cache` property is not standardized.
686 unset( $wp_object_cache->cache['ep_autosuggest'] );
687 }
688 } else {
689 delete_transient( 'ep_autosuggest_query_request_cache' );
690 }
691 }
692
693 /**
694 * Tell user whether requirements for feature are met or not.
695 *
696 * @return array $status Status array
697 * @since 2.4
698 */
699 public function requirements_status() {
700 $status = new FeatureRequirementsStatus( 0 );
701
702 $status->message = [];
703
704 $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' );
705
706 if ( ! Utils\is_epio() ) {
707 $status->code = 1;
708 $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' ) );
709 }
710
711 return $status;
712 }
713
714 /**
715 * Do a non-blocking search query to force the autosuggest hash to update.
716 *
717 * This request has to happen in a public environment, so all code testing if `is_admin()`
718 * are properly executed.
719 *
720 * @param bool $blocking If the request should block the execution or not.
721 */
722 public function epio_send_autosuggest_public_request( $blocking = false ) {
723 if ( ! Utils\is_epio() ) {
724 return;
725 }
726
727 $url = add_query_arg(
728 [
729 's' => 'search test',
730 'ep_epio_set_autosuggest' => 1,
731 'ep_epio_nonce' => wp_create_nonce( 'ep-epio-set-autosuggest' ),
732 'nocache' => time(), // Here just to avoid the request hitting a CDN.
733 ],
734 home_url( '/' )
735 );
736
737 // Pass the same cookies, so the same authenticated user is used (and we can check the nonce).
738 $cookies = [];
739 foreach ( $_COOKIE as $name => $value ) {
740 $cookies[] = new \WP_Http_Cookie(
741 [
742 'name' => $name,
743 'value' => $value,
744 ]
745 );
746 }
747
748 wp_remote_get(
749 $url,
750 [
751 'cookies' => $cookies,
752 'blocking' => (bool) $blocking,
753 ]
754 );
755 }
756
757 /**
758 * Send the allowed parameters for autosuggest to ElasticPress.io.
759 */
760 public function epio_send_autosuggest_allowed() {
761 if ( empty( $_REQUEST['ep_epio_nonce'] ) || ! wp_verify_nonce( $_REQUEST['ep_epio_nonce'], 'ep-epio-set-autosuggest' ) ) {
762 return;
763 }
764 if ( empty( $_GET['ep_epio_set_autosuggest'] ) ) {
765 return;
766 }
767
768 /**
769 * Fires before the request is sent to EP.io to set Autosuggest allowed values.
770 *
771 * @hook ep_epio_pre_send_autosuggest_allowed
772 * @since 3.5.x
773 */
774 do_action( 'ep_epio_pre_send_autosuggest_allowed' );
775
776 /**
777 * The same ES query sent by autosuggest.
778 *
779 * Sometimes it'll be a string, sometimes it'll be already an array.
780 */
781 $es_search_query = $this->generate_search_query()['body'];
782 $es_search_query = ( is_array( $es_search_query ) ) ? $es_search_query : json_decode( $es_search_query, true );
783
784 /**
785 * Filter autosuggest ES query
786 *
787 * @since 3.5.x
788 * @hook ep_epio_autosuggest_es_query
789 * @param {array} The ES Query.
790 */
791 $es_search_query = apply_filters( 'ep_epio_autosuggest_es_query', $es_search_query );
792
793 /**
794 * Here is a chance to short-circuit the execution. Also, during the sync
795 * the query will be empty anyway.
796 */
797 if ( empty( $es_search_query ) ) {
798 return;
799 }
800
801 $index = Indexables::factory()->get( 'post' )->get_index_name();
802
803 add_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
804
805 Elasticsearch::factory()->query( $index, 'post', $es_search_query, [] );
806
807 remove_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
808
809 /**
810 * Fires after the request is sent to EP.io to set Autosuggest allowed values.
811 *
812 * @hook ep_epio_sent_autosuggest_allowed
813 * @since 3.5.x
814 */
815 do_action( 'ep_epio_sent_autosuggest_allowed' );
816 }
817
818 /**
819 * Set a header so EP.io servers know this request contains the values
820 * that should be stored as allowed.
821 *
822 * @since 3.5.x
823 * @param array $headers The Request Headers.
824 * @return array
825 */
826 public function add_ep_set_autosuggest_header( $headers ) {
827 $headers['EP-Set-Autosuggest'] = true;
828 return $headers;
829 }
830
831 /**
832 * Retrieve the allowed parameters for autosuggest from ElasticPress.io.
833 *
834 * @return array
835 */
836 public function epio_retrieve_autosuggest_allowed() {
837 $response = Elasticsearch::factory()->remote_request(
838 Indexables::factory()->get( 'post' )->get_index_name() . '/get-autosuggest-allowed'
839 );
840
841 $body = wp_remote_retrieve_body( $response, true );
842 return json_decode( $body, true );
843 }
844
845 /**
846 * Output the current allowed parameters for autosuggest stored in ElasticPress.io.
847 */
848 public function epio_allowed_parameters() {
849 global $wp_version;
850
851 $allowed_params = $this->epio_autosuggest_set_and_get();
852 if ( empty( $allowed_params ) ) {
853 return;
854 }
855 ?>
856 <div class="field js-toggle-feature" data-feature="<?php echo esc_attr( $this->slug ); ?>">
857 <div class="field-name status"><?php esc_html_e( 'Connection', 'elasticpress' ); ?></div>
858 <div class="input-wrap">
859 <?php
860 $epio_link = 'https://elasticpress.io';
861 $epio_autosuggest_kb_link = 'https://elasticpress.zendesk.com/hc/en-us/articles/360055402791';
862 $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' );
863
864 printf(
865 /* translators: 1: <a> tag (ElasticPress.io); 2. </a>; 3: <a> tag (KB article); 4. </a>; 5: <a> tag (Site Health Debug Section); 6. </a>; */
866 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' ),
867 '<a href="' . esc_url( $epio_link ) . '">',
868 '</a>',
869 '<a href="' . esc_url( $epio_autosuggest_kb_link ) . '">',
870 '</a>',
871 '<a href="' . esc_url( $status_report_link ) . '">',
872 '</a>'
873 );
874 ?>
875 </div>
876 </div>
877 <?php
878 }
879
880 /**
881 * Try to get the allowed parameters. If they are not set, set it and try to get them again.
882 *
883 * @since 3.5.x
884 * @return array
885 */
886 public function epio_autosuggest_set_and_get() {
887 $allowed_params = [];
888 $errors_count = 1;
889 for ( $i = 0; $i <= $errors_count; $i++ ) {
890 $allowed_params = $this->epio_retrieve_autosuggest_allowed();
891
892 if ( is_wp_error( $allowed_params ) || ( isset( $allowed_params['status'] ) && 200 !== $allowed_params['status'] ) ) {
893 $allowed_params = [];
894 break;
895 }
896
897 if ( empty( $allowed_params ) ) {
898 $this->epio_send_autosuggest_public_request( true );
899 }
900 }
901
902 return $allowed_params;
903 }
904
905 /**
906 * Returns the title.
907 *
908 * @since 4.4.1
909 * @return string
910 */
911 public function get_title() : string {
912 if ( ! Utils\is_epio() ) {
913 return esc_html__( 'Autosuggest', 'elasticpress' );
914 }
915
916 /* translators: 1. elasticpress.io logo; */
917 return sprintf( esc_html__( 'Autosuggest By %s', 'elasticpress' ), $this->get_epio_logo() );
918 }
919 }
920