PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
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 / InstantResults / InstantResults.php

InstantResults.php in ElasticPress 5.3.5, at includes/classes/Feature/InstantResults/InstantResults.php

1,069 lines 30.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Instant Search feature
4 *
5 * @package elasticpress
6 */
7
8 namespace ElasticPress\Feature\InstantResults;
9
10 use ElasticPress\Elasticsearch;
11 use ElasticPress\ElasticPressIoTemplateManager;
12 use ElasticPress\Feature;
13 use ElasticPress\FeatureRequirementsStatus;
14 use ElasticPress\Features;
15 use ElasticPress\Indexables;
16 use ElasticPress\Utils;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit; // Exit if accessed directly.
20 }
21
22 /**
23 * Instant Results feature class.
24 *
25 * @since 4.0.0
26 */
27 class InstantResults extends Feature {
28
29 use ElasticPressIoTemplateManager;
30
31 /**
32 * Elasticsearch index name.
33 *
34 * @var string
35 */
36 protected $index;
37
38 /**
39 * Host URL.
40 *
41 * @var string
42 */
43 protected $host;
44
45 /**
46 * WooCommerce is in use.
47 *
48 * @var boolean
49 */
50 protected $is_woocommerce;
51
52 /**
53 * Elasticsearch query template.
54 *
55 * @var string
56 */
57 protected $search_template = '';
58
59 /**
60 * Feature settings
61 *
62 * @var array
63 */
64 protected $settings = [];
65
66 /**
67 * Initialize feature.
68 *
69 * @return void
70 */
71 public function __construct() {
72 $this->slug = 'instant-results';
73
74 $this->group = 'live-search';
75
76 $this->host = trailingslashit( Utils\get_host() );
77
78 $this->is_woocommerce = function_exists( 'WC' );
79
80 $this->default_settings = [
81 'highlight_tag' => 'mark',
82 'facets' => 'post_type,tax-category,tax-post_tag',
83 'match_type' => 'all',
84 'term_count' => '1',
85 'numbered_pagination' => '0',
86 'per_page' => get_option( 'posts_per_page', 6 ),
87 'search_behavior' => '0',
88 ];
89
90 $this->settings = $this->get_settings();
91
92 $this->requires_install_reindex = true;
93
94 $this->available_during_installation = true;
95
96 $this->is_powered_by_epio = Utils\is_epio();
97
98 parent::__construct();
99 }
100
101 /**
102 * Sets i18n strings.
103 *
104 * @return void
105 * @since 5.2.0
106 */
107 public function set_i18n_strings(): void {
108 $this->title = esc_html__( 'Instant Results', 'elasticpress' );
109
110 $this->short_title = esc_html__( 'Instant Results', 'elasticpress' );
111
112 $this->summary = '<p>' . __( 'WordPress search forms will display results instantly. When the search query is submitted, a modal will open that populates results by querying ElasticPress directly, bypassing WordPress. As the user refines their search, results are refreshed.', 'elasticpress' ) . '</p>' .
113 '<p>' . __( 'Requires an <a href="https://www.elasticpress.io/" target="_blank">ElasticPress.io plan</a> or a custom proxy to function.', 'elasticpress' ) . '</p>';
114
115 $this->docs_url = __( 'https://www.elasticpress.io/resources/articles/configuring-elasticpress-via-the-plugin-dashboard/#instant-results', 'elasticpress' );
116 }
117
118 /**
119 * Tell user whether requirements for feature are met or not.
120 *
121 * @return array $status Status array
122 */
123 public function requirements_status() {
124 $status = new FeatureRequirementsStatus( 2, null, $this );
125
126 $status->message = [];
127
128 if ( Utils\is_epio() ) {
129 $status->code = 1;
130
131 /**
132 * Whether the feature is available for non ElasticPress.io customers.
133 *
134 * Installations using self-hosted Elasticsearch will need to implement an API for
135 * handling search requests before making the feature available.
136 *
137 * @since 4.0.0
138 * @hook ep_instant_results_available
139 * @param {string} $available Whether the feature is available.
140 */
141 } elseif ( apply_filters( 'ep_instant_results_available', false ) ) {
142 $status->code = 1;
143 $status->message[] = esc_html__( 'You are using a custom proxy. Make sure you implement all security measures needed.', 'elasticpress' );
144 } else {
145 $status->message[] = wp_kses_post( __( 'To use this feature you need to be an <a href="https://elasticpress.io">ElasticPress.io</a> customer or implement a <a href="https://github.com/10up/elasticpress-proxy">custom proxy</a>.', 'elasticpress' ) );
146 }
147
148 /**
149 * Display a warning if ElasticPress is network activated.
150 */
151 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
152 $status->message[] = wp_kses_post(
153 sprintf(
154 /* translators: Article URL */
155 __(
156 'ElasticPress is network activated. Additional steps are required to ensure Instant Results works for all sites on the network. See our article on <a href="%s" target="_blank">running ElasticPress in network mode</a> for more details.',
157 'elasticpress'
158 ),
159 'https://www.elasticpress.io/resources/articles/running-elasticpress-in-a-wordpress-multisite-network-mode/'
160 )
161 );
162 }
163
164 return $status;
165 }
166
167 /**
168 * Setup feature functionality.
169 *
170 * @return void
171 */
172 public function setup() {
173 add_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ], 10, 3 );
174 add_filter( 'ep_formatted_args', [ $this, 'maybe_apply_aggs_args' ], 10, 3 );
175 add_filter( 'ep_post_mapping', [ $this, 'add_mapping_properties' ] );
176 add_filter( 'ep_post_sync_args', [ $this, 'add_post_sync_args' ], 10, 2 );
177 add_action( 'ep_after_sync_index', [ $this, 'on_sync_complete' ] );
178 add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] );
179 add_action( 'pre_get_posts', [ $this, 'maybe_apply_product_visibility' ] );
180 add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_assets' ] );
181 add_action( 'wp_footer', [ $this, 'render' ] );
182 }
183
184 /**
185 * Output modal markup.
186 */
187 public function render() {
188 echo '<div id="ep-instant-results"></div>';
189 }
190
191 /**
192 * Enqueue our autosuggest script.
193 */
194 public function enqueue_frontend_assets() {
195 if ( Utils\is_indexing() ) {
196 return;
197 }
198
199 wp_enqueue_style(
200 'elasticpress-instant-results',
201 EP_URL . 'dist/css/instant-results-styles.css',
202 Utils\get_asset_info( 'instant-results-styles', 'dependencies' ),
203 Utils\get_asset_info( 'instant-results-styles', 'version' )
204 );
205
206 wp_enqueue_script(
207 'elasticpress-instant-results',
208 EP_URL . 'dist/js/instant-results-script.js',
209 Utils\get_asset_info( 'instant-results-script', 'dependencies' ),
210 Utils\get_asset_info( 'instant-results-script', 'version' ),
211 true
212 );
213
214 wp_set_script_translations( 'elasticpress-instant-results', 'elasticpress' );
215
216 $index = Indexables::factory()->get( 'post' )->get_index_name();
217 /**
218 * The search API endpoint.
219 *
220 * @since 4.0.0
221 * @hook ep_instant_results_search_endpoint
222 * @param {string} $endpoint Endpoint path.
223 * @param {string} $index Elasticsearch index.
224 */
225 $api_endpoint = apply_filters( 'ep_instant_results_search_endpoint', "api/v1/search/posts/{$index}", $index );
226
227 wp_localize_script(
228 'elasticpress-instant-results',
229 'epInstantResults',
230 array(
231 'apiEndpoint' => $api_endpoint,
232 'apiHost' => ( 0 !== strpos( $api_endpoint, 'http' ) ) ? esc_url_raw( $this->host ) : '',
233 'argsSchema' => $this->get_args_schema(),
234 'currencyCode' => $this->is_woocommerce ? get_woocommerce_currency() : false,
235 'excludedPostTypes' => $this->get_excluded_post_types(),
236 'excludedTermIds' => $this->get_excluded_term_ids(),
237 'facets' => $this->get_facets_for_frontend(),
238 'highlightTag' => $this->settings['highlight_tag'],
239 'isWooCommerce' => $this->is_woocommerce,
240 'locale' => str_replace( '_', '-', get_locale() ),
241 'matchType' => $this->settings['match_type'],
242 'paramPrefix' => 'ep-',
243 'postTypeLabels' => $this->get_post_type_labels(),
244 'termCount' => $this->settings['term_count'],
245 'numberedPagination' => $this->settings['numbered_pagination'],
246 'requestIdBase' => Utils\get_request_id_base(),
247 'showSuggestions' => \ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->is_active(),
248 'suggestionsBehavior' => $this->settings['search_behavior'],
249 )
250 );
251 }
252
253 /**
254 * Get the endpoint for the Instant Results search template.
255 *
256 * @return string Instant Results search template endpoint.
257 */
258 public function get_template_endpoint(): string {
259 $index = Indexables::factory()->get( 'post' )->get_index_name();
260 /**
261 * Filters the search template API endpoint.
262 *
263 * @since 4.0.0
264 * @hook ep_instant_results_template_endpoint
265 * @param {string} $endpoint Endpoint path.
266 * @param {string} $index Elasticsearch index.
267 * @returns {string} Search template API endpoint.
268 */
269 return apply_filters( 'ep_instant_results_template_endpoint', "api/v1/search/posts/{$index}/template/", $index );
270 }
271
272 /**
273 * Generate a search template.
274 *
275 * A search template is the JSON for an Elasticsearch query with a
276 * placeholder search term. The template is sent to ElasticPress.io where
277 * it's used to make Elasticsearch queries using search terms sent from
278 * the front end.
279 *
280 * @return string The search template as JSON.
281 */
282 public function get_search_template(): string {
283 $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types();
284 $excluded_post_types = $this->get_excluded_post_types();
285 $post_types = array_values( array_diff( $post_types, $excluded_post_types ) );
286
287 $post_statuses = get_post_stati(
288 [
289 'public' => true,
290 'exclude_from_search' => false,
291 ]
292 );
293
294 /**
295 * The ID of the current user when generating the Instant Results
296 * search template.
297 *
298 * By default Instant Results sets the current user as anomnymous when
299 * generating the search template, so that any filters applied to
300 * queries for logged-in or specific users are not applied to the
301 * template. This filter supports setting a specific user as the
302 * current user while the template is generated.
303 *
304 * @since 4.1.0
305 * @hook ep_search_template_user_id
306 * @param {int} $user_id User ID to use.
307 * @return {int} New user ID to use.
308 */
309 $template_user_id = apply_filters( 'ep_search_template_user_id', 0 );
310 $original_user_id = get_current_user_id();
311
312 wp_set_current_user( $template_user_id );
313
314 add_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10, 4 );
315 add_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10, 2 );
316
317 $query = new \WP_Query(
318 array(
319 'ep_integrate' => true,
320 'ep_search_template' => true,
321 'post_status' => array_values( $post_statuses ),
322 'post_type' => $post_types,
323 's' => '{{ep_placeholder}}',
324 'ep_intercept_request' => true,
325 'ep_skip_search_exclusion' => true,
326 )
327 );
328
329 remove_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10 );
330 remove_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10 );
331
332 wp_set_current_user( $original_user_id );
333
334 return $this->search_template;
335 }
336
337 /**
338 * Return true if a given feature is supported by Instant Results.
339 *
340 * Applied as a filter on Utils\is_integrated_request() so that features
341 * are enabled for the query that is used to generate the search template,
342 * regardless of the request type. This avoids the need to send a request
343 * to the front end.
344 *
345 * @param bool $is_integrated Whether queries for the request will be
346 * integrated.
347 * @param string $context Context for the original check. Usually the
348 * slug of the feature doing the check.
349 * @return bool True if the check is for a feature supported by instant
350 * search.
351 */
352 public function is_integrated_request( $is_integrated, $context ) {
353 $supported_contexts = [
354 'autosuggest',
355 'documents',
356 'search',
357 'weighting',
358 'woocommerce',
359 ];
360
361 return in_array( $context, $supported_contexts, true );
362 }
363
364 /**
365 * Store intercepted request body and return request result.
366 *
367 * @param object $response Response
368 * @param array $query Query
369 * @param array $args WP_Query argument array
370 * @param int $failures Count of failures in request loop
371 * @return object $response Response
372 */
373 public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) {
374 $this->search_template = $query['args']['body'];
375
376 return wp_remote_request( $query['url'], $args );
377 }
378
379 /**
380 * If generating the search template query, do not bypass the post exclusion
381 *
382 * @since 4.4.0
383 * @param bool $bypass_exclusion_from_search Whether the post exclusion from search should be applied or not
384 * @param WP_Query $query The WP Query
385 * @return bool
386 */
387 public function maybe_bypass_post_exclusion( $bypass_exclusion_from_search, $query ) {
388 _doing_it_wrong(
389 __METHOD__,
390 esc_html__( 'Use the WP_Query argument `ep_skip_search_exclusion`.', 'elasticpress' ),
391 'ElasticPress 5.3.0'
392 );
393
394 return true === $query->get( 'ep_search_template' ) ?
395 false : // not bypass, apply
396 $bypass_exclusion_from_search;
397 }
398
399 /**
400 * Apply product visibility taxonomy query to search template queries.
401 *
402 * @param \WP_Query $query Query instance.
403 * @return void
404 */
405 public function maybe_apply_product_visibility( $query ) {
406 if ( true !== $query->get( 'ep_search_template' ) ) {
407 return;
408 }
409
410 if ( ! $this->is_woocommerce ) {
411 return;
412 }
413
414 $this->apply_product_visibility( $query );
415 }
416
417 /**
418 * Apply product visibility taxonomy query.
419 *
420 * Applies filters to exclude products set to be excluded from search. Out
421 * of stock products will also be excluded if WooCommerce is configured to
422 * hide those products.
423 *
424 * Mimics the logic of WC_Query::get_tax_query().
425 *
426 * @param \WP_Query $query Query instance.
427 * @return void
428 */
429 public function apply_product_visibility( $query ) {
430 $product_visibility_terms = wc_get_product_visibility_term_ids();
431 $product_visibility_not_in = (array) $product_visibility_terms['exclude-from-search'];
432
433 if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
434 $product_visibility_not_in[] = $product_visibility_terms['outofstock'];
435 }
436
437 if ( ! empty( $product_visibility_not_in ) ) {
438 $tax_query = $query->get( 'tax_query', array() );
439
440 $tax_query[] = array(
441 'taxonomy' => 'product_visibility',
442 'field' => 'term_taxonomy_id',
443 'terms' => $product_visibility_not_in,
444 'operator' => 'NOT IN',
445 );
446
447 $query->set( 'tax_query', $tax_query );
448 }
449 }
450
451 /**
452 * Apply aggregation args to search templates.
453 *
454 * @param array $formatted_args Formatted Elasticsearch query.
455 * @param array $query_vars Query variables
456 * @param \WP_Query $query Query instance.
457 * @return array Formatted Elasticsearch query.
458 */
459 public function maybe_apply_aggs_args( $formatted_args, $query_vars, $query ) {
460 if ( true !== $query->get( 'ep_search_template' ) ) {
461 return $formatted_args;
462 }
463
464 return $this->apply_aggs_args( $formatted_args );
465 }
466
467 /**
468 * Add aggregation args to Elasticsearch query for facets.
469 *
470 * @param array $formatted_args Formatted Elasticsearch query.
471 * @return array Formatted Elasticsearch query.
472 */
473 public function apply_aggs_args( $formatted_args ) {
474 $filter = $formatted_args['post_filter'];
475 $facets = $this->get_facets();
476
477 foreach ( $facets as $key => $facet ) {
478 $formatted_args['aggs'][ $key ]['aggs'] = $facet['aggs'];
479
480 if ( $filter ) {
481 $formatted_args['aggs'][ $key ]['filter'] = $filter;
482 }
483 }
484
485 return $formatted_args;
486 }
487
488 /**
489 * Add additional fields to post mapping.
490 *
491 * @param array $mapping Post mapping.
492 * @return array Post mapping.
493 */
494 public function add_mapping_properties( $mapping ) {
495 $elasticsearch_version = Elasticsearch::factory()->get_elasticsearch_version();
496
497 $properties = array(
498 'post_content_plain' => array( 'type' => 'text' ),
499 'price_html' => array( 'type' => 'text' ),
500 );
501
502 if ( version_compare( (string) $elasticsearch_version, '7.0', '<' ) ) {
503 $mapping['mappings']['post']['properties'] = array_merge(
504 $mapping['mappings']['post']['properties'],
505 $properties
506 );
507 } else {
508 $mapping['mappings']['properties'] = array_merge(
509 $mapping['mappings']['properties'],
510 $properties
511 );
512 }
513
514 return $mapping;
515 }
516
517 /**
518 * Add data for additional mapping properties.
519 *
520 * @param array $post_args Post arguments.
521 * @param integer $post_id Post ID.
522 * @return array Post sync args.
523 */
524 public function add_post_sync_args( $post_args, $post_id ) {
525 $post = get_post( $post_id );
526
527 $post_args['post_content_plain'] = $this->prepare_plain_content_arg( $post );
528 $post_args['price_html'] = $this->prepare_price_html_arg( $post );
529
530 return $post_args;
531 }
532
533
534 /**
535 * Get data for the plain post content.
536 *
537 * @param WP_Post $post Post object.
538 * @return string Post content.
539 */
540 public function prepare_plain_content_arg( $post ) {
541 $post_content = apply_filters( 'the_content', $post->post_content );
542
543 return wp_strip_all_tags( $post_content );
544 }
545
546 /**
547 * Get data for the price HTML arg.
548 *
549 * @param WP_Post $post Post object.
550 * @return string|null Price HTML.
551 */
552 public function prepare_price_html_arg( $post ) {
553 if ( 'product' !== $post->post_type ) {
554 return null;
555 }
556
557 if ( ! $this->is_woocommerce ) {
558 return null;
559 }
560
561 $product = wc_get_product( $post );
562
563 return $product->get_price_html();
564 }
565
566 /**
567 * Get post type labels.
568 *
569 * Only the post type slug is indexed, so we'll need the labels on the
570 * front end for display.
571 *
572 * @return array Array of post types and their labels.
573 */
574 public function get_post_type_labels() {
575 $labels = [];
576
577 $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types();
578
579 foreach ( $post_types as $post_type ) {
580 $post_type_object = get_post_type_object( $post_type );
581 $post_type_labels = get_post_type_labels( $post_type_object );
582
583 $labels[ $post_type ] = array(
584 'plural' => $post_type_labels->name,
585 'singular' => $post_type_labels->singular_name,
586 );
587 }
588
589 return $labels;
590 }
591
592 /**
593 * Get post type slugs to exclude from the Instant Results post type filter.
594 *
595 * @since 5.3.4
596 * @return string[] Post type slugs to exclude.
597 */
598 public function get_excluded_post_types(): array {
599 /**
600 * Filter post type slugs to exclude from Instant Results.
601 *
602 * Excluded post types are hidden from the Post Type facet and
603 * removed from the search template so their content does not
604 * appear in results. The search template is generated and
605 * saved when Instant Results settings are saved,
606 * so after changing this filter, re-save the Instant Results
607 * or Weighting settings to regenerate the template.
608 *
609 * @hook ep_instant_results_excluded_post_types
610 * @since 5.3.4
611 * @param {string[]} $excluded Post type slugs to exclude (e.g., ['page']).
612 * @return {string[]} Filtered exclusions.
613 */
614 $post_types = apply_filters( 'ep_instant_results_excluded_post_types', [] );
615 $post_types = array_filter( (array) $post_types, 'is_scalar' );
616 $post_types = array_map( 'strval', $post_types );
617 $post_types = array_filter( $post_types );
618
619 return array_values( $post_types );
620 }
621
622 /**
623 * Get term IDs to exclude from all Instant Results taxonomy filters.
624 *
625 * @since 5.3.4
626 * @return int[] Term IDs to exclude.
627 */
628 public function get_excluded_term_ids(): array {
629 /**
630 * Filter term IDs to exclude from all Instant Results taxonomy filters.
631 *
632 * Excluded terms are hidden from every taxonomy facet
633 * (Category, Tag, etc.) but posts associated with those
634 * terms still appear in search results.
635 *
636 * @hook ep_instant_results_excluded_term_ids
637 * @since 5.3.4
638 * @param {int[]} $excluded Term IDs to exclude (e.g., [1, 120]).
639 * @return {int[]} Filtered exclusions.
640 */
641 $term_ids = apply_filters( 'ep_instant_results_excluded_term_ids', [] );
642 $term_ids = array_filter( (array) $term_ids, 'is_scalar' );
643 $term_ids = array_map( 'intval', $term_ids );
644 $term_ids = array_filter( $term_ids );
645
646 return array_values( $term_ids );
647 }
648
649 /**
650 * Get available facets.
651 *
652 * @return array Available facets.
653 */
654 public function get_facets() {
655 $facets = [];
656
657 /**
658 * Post type facet.
659 */
660 $facets['post_type'] = array(
661 'type' => 'post_type',
662 'post_types' => [],
663 'labels' => array(
664 'admin' => __( 'Post type', 'elasticpress' ),
665 'frontend' => __( 'Type', 'elasticpress' ),
666 ),
667 'aggs' => array(
668 'post_type' => array(
669 'terms' => array(
670 'field' => 'post_type.raw',
671 ),
672 ),
673 ),
674 /**
675 * The post_type arg needs to be supported regardless of whether
676 * the Post Type facet is present to be able to support setting the
677 * post type from the search form.
678 *
679 * @see ElasticPress\Feature\InstantResults::get_args_schema()
680 */
681 'args' => array(),
682 );
683
684 /**
685 * Taxonomy facets.
686 */
687 $taxonomies = get_taxonomies( array( 'public' => true ), 'object' );
688 $taxonomies = apply_filters( 'ep_facet_include_taxonomies', $taxonomies );
689
690 foreach ( $taxonomies as $slug => $taxonomy ) {
691 if ( is_string( $taxonomy ) ) {
692 $slug = $taxonomy;
693 $taxonomy = get_taxonomy( $slug );
694 }
695
696 if ( ! ( $taxonomy instanceof \WP_Taxonomy ) ) {
697 _doing_it_wrong(
698 __METHOD__,
699 sprintf(
700 /* translators: %s is a taxonomy slug. */
701 esc_html__(
702 'Invalid taxonomy "%s" returned via ep_facet_include_taxonomies filter',
703 'elasticpress'
704 ),
705 esc_html( $slug )
706 ),
707 'ElasticPress 5.3.3'
708 );
709 continue;
710 }
711
712 $name = 'tax-' . $slug;
713 $labels = get_taxonomy_labels( $taxonomy );
714
715 $admin_label = sprintf(
716 /* translators: $1$s: Taxonomy name. %2$s: Taxonomy slug. */
717 esc_html__( '%1$s (%2$s)' ),
718 $labels->singular_name,
719 $slug
720 );
721
722 $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types();
723 $post_types = array_intersect( $post_types, $taxonomy->object_type );
724 $post_types = array_values( $post_types );
725
726 $facets[ $name ] = array(
727 'type' => 'taxonomy',
728 'post_types' => $post_types,
729 'labels' => array(
730 'admin' => wp_specialchars_decode( $admin_label, ENT_QUOTES ),
731 'frontend' => wp_specialchars_decode( $labels->singular_name, ENT_QUOTES ),
732 ),
733 'aggs' => array(
734 $name => array(
735 'terms' => array(
736 'field' => 'terms.' . $slug . '.facet',
737 'size' => apply_filters( 'ep_facet_taxonomies_size', 10000, $taxonomy ),
738 ),
739 ),
740 ),
741 'args' => array(
742 $name => array(
743 'type' => 'strings',
744 ),
745 ),
746 );
747 }
748
749 /**
750 * Price facet.
751 */
752 if ( $this->is_woocommerce ) {
753 $facets['price_range'] = array(
754 'type' => 'price_range',
755 'post_types' => [ 'product' ],
756 'labels' => array(
757 'admin' => __( 'Price range', 'elasticpress' ),
758 'frontend' => __( 'Price', 'elasticpress' ),
759 ),
760 'aggs' => array(
761 'max_price' => array(
762 'max' => array(
763 'field' => 'meta._price.double',
764 ),
765 ),
766 'min_price' => array(
767 'min' => array(
768 'field' => 'meta._price.double',
769 ),
770 ),
771 ),
772 'args' => array(
773 'max_price' => array(
774 'type' => 'number',
775 ),
776 'min_price' => array(
777 'type' => 'number',
778 ),
779 ),
780 );
781 }
782
783 return $facets;
784 }
785
786 /**
787 * Get facet configuration for the front end.
788 *
789 * @return Array Facet configuration for the front end.
790 */
791 public function get_facets_for_frontend() {
792 $selected_facets = explode( ',', $this->settings['facets'] );
793 $available_facets = $this->get_facets();
794
795 $facets = [];
796
797 foreach ( $selected_facets as $key ) {
798 if ( isset( $available_facets[ $key ] ) ) {
799 $facet = $available_facets[ $key ];
800
801 $facets[] = array(
802 'name' => $key,
803 'label' => $facet['labels']['frontend'],
804 'type' => $facet['type'],
805 'postTypes' => $facet['post_types'],
806 );
807 }
808 }
809
810 return $facets;
811 }
812
813 /**
814 * Get facet configuration for the admin.
815 *
816 * @return Array Facet configuration for the admin.
817 */
818 public function get_facets_for_admin() {
819 $available_facets = $this->get_facets();
820
821 $facets = [];
822
823 foreach ( $available_facets as $key => $facet ) {
824 $facets[ $key ] = array(
825 'label' => $facet['labels']['admin'],
826 'value' => $key,
827 );
828 }
829
830 return $facets;
831 }
832
833 /**
834 * Get schema for search args.
835 *
836 * @return array Search args schema.
837 */
838 public function get_args_schema() {
839 /**
840 * The number of results per page for Instant Results.
841 *
842 * @since 4.5.0
843 * @hook ep_instant_results_per_page
844 * @param {int} $per_page Results per page.
845 */
846 $per_page = apply_filters( 'ep_instant_results_per_page', $this->settings['per_page'] );
847
848 $args_schema = array(
849 'highlight' => array(
850 'type' => 'string',
851 'default' => $this->settings['highlight_tag'],
852 'allowedValues' => [ $this->settings['highlight_tag'] ],
853 ),
854 'offset' => array(
855 'type' => 'number',
856 'default' => 0,
857 ),
858 'orderby' => array(
859 'type' => 'string',
860 'default' => 'relevance',
861 'allowedValues' => [ 'date', 'price', 'relevance' ],
862 ),
863 'order' => array(
864 'type' => 'string',
865 'default' => 'desc',
866 'allowedValues' => [ 'asc', 'desc' ],
867 ),
868 'per_page' => array(
869 'type' => 'number',
870 'default' => absint( $per_page ),
871 ),
872 'post_type' => array(
873 'type' => 'strings',
874 ),
875 'search' => array(
876 'type' => 'string',
877 'default' => '',
878 ),
879 'relation' => array(
880 'type' => 'string',
881 'default' => 'all' === $this->settings['match_type'] ? 'and' : 'or',
882 'allowedValues' => [ 'and', 'or' ],
883 ),
884 );
885
886 $selected_facets = explode( ',', $this->settings['facets'] );
887 $available_facets = $this->get_facets();
888
889 foreach ( $selected_facets as $key ) {
890 if ( isset( $available_facets[ $key ] ) ) {
891 $args_schema = array_merge( $args_schema, $available_facets[ $key ]['args'] );
892 }
893 }
894
895 /**
896 * The schema defining the API arguments used by Instant Results.
897 *
898 * The argument schema is used to configure the APISearchProvider
899 * component used by Instant Results, and should conform to what is
900 * supported by the API being used. The Instant Results UI expects
901 * the default list of arguments to be available, so caution is advised
902 * when adding or removing arguments.
903 *
904 * @since 4.5.1
905 * @hook ep_instant_results_args_schema
906 * @param {array} $args_schema Results per page.
907 */
908 return apply_filters( 'ep_instant_results_args_schema', $args_schema );
909 }
910
911 /**
912 * Set the `settings_schema` attribute
913 *
914 * @since 5.0.0
915 */
916 protected function set_settings_schema() {
917 $facets = $this->get_facets_for_admin();
918
919 $this->settings_schema = [
920 [
921 'default' => 'mark',
922 'help' => __( 'Select the HTML tag used to highlight search terms.', 'elasticpress' ),
923 'key' => 'highlight_tag',
924 'label' => __( 'Highlight tag', 'elasticpress' ),
925 'options' => [
926 [
927 'label' => __( 'None', 'elasticpress' ),
928 'value' => '',
929 ],
930 [
931 'label' => 'mark',
932 'value' => 'mark',
933 ],
934 [
935 'label' => 'span',
936 'value' => 'span',
937 ],
938 [
939 'label' => 'strong',
940 'value' => 'strong',
941 ],
942 [
943 'label' => 'em',
944 'value' => 'em',
945 ],
946 [
947 'label' => 'i',
948 'value' => 'i',
949 ],
950 ],
951 'type' => 'select',
952 ],
953 [
954 'default' => 'post_type,tax-category,tax-post_tag',
955 'key' => 'facets',
956 'label' => __( 'Filters', 'elasticpress' ),
957 'options' => array_values( $facets ),
958 'type' => 'multiple',
959 ],
960 [
961 'default' => 'all',
962 'key' => 'match_type',
963 'label' => __( 'Filter matching', 'elasticpress' ),
964 'options' => [
965 [
966 'label' => __( 'Show results that match <strong>all</strong> selected filters', 'elasticpress' ),
967 'value' => 'all',
968 ],
969 [
970 'label' => __( 'Show results that match <strong>any</strong> selected filter', 'elasticpress' ),
971 'value' => 'any',
972 ],
973 ],
974 'type' => 'radio',
975 ],
976 [
977 'default' => '1',
978 'help' => __( 'Enable to show the number of matching results next to filter options.', 'elasticpress' ),
979 'key' => 'term_count',
980 'label' => __( 'Show filter counts', 'elasticpress' ),
981 'type' => 'checkbox',
982 ],
983 [
984 'default' => '0',
985 'help' => __( 'Enable to show numbered pagination links instead of previous/next buttons.', 'elasticpress' ),
986 'key' => 'numbered_pagination',
987 'label' => __( 'Numbered pagination', 'elasticpress' ),
988 'type' => 'checkbox',
989 ],
990 [
991 'default' => get_option( 'posts_per_page', 6 ),
992 'key' => 'per_page',
993 'type' => 'hidden',
994 ],
995 [
996 'default' => '0',
997 'key' => 'search_behavior',
998 'label' => __( 'Search behavior when no result is found', 'elasticpress' ),
999 'options' => [
1000 [
1001 'label' => __( 'Display the top suggestion', 'elasticpress' ),
1002 'value' => '0',
1003 ],
1004 [
1005 'label' => __( 'Display all the suggestions', 'elasticpress' ),
1006 'value' => 'list',
1007 ],
1008 ],
1009 'requires_feature' => 'did-you-mean',
1010 'type' => 'radio',
1011 ],
1012 ];
1013 }
1014
1015 /**
1016 * Callback for ep_after_sync_index to save search templates.
1017 *
1018 * @param array $args Sync arguments containing network_wide flag.
1019 * @return void
1020 * @since 5.3.3
1021 */
1022 public function on_sync_complete( array $args ): void {
1023 $network_wide = isset( $args['network_wide'] ) && ! is_null( $args['network_wide'] );
1024 $this->epio_save_site_search_template( $network_wide );
1025 }
1026
1027 /**
1028 * Save the search template for the current site or all network sites.
1029 *
1030 * @param bool $network_wide Whether to save templates for all sites in the network.
1031 * @return void
1032 * @since 5.3.3
1033 */
1034 public function epio_save_site_search_template( bool $network_wide = false ): void {
1035 if ( ! $network_wide ) {
1036 $this->epio_save_search_template();
1037 return;
1038 }
1039
1040 $sites = Utils\get_sites( 0, true );
1041 foreach ( $sites as $site ) {
1042 switch_to_blog( $site['blog_id'] );
1043 $this->epio_save_search_template();
1044 restore_current_blog();
1045 }
1046 }
1047
1048 /**
1049 * Delete the search template for the current site or all network sites.
1050 *
1051 * @param bool $network_wide Whether to delete templates for all sites in the network.
1052 * @return void
1053 * @since 5.3.3
1054 */
1055 public function epio_delete_site_search_template( bool $network_wide = false ): void {
1056 if ( ! $network_wide ) {
1057 $this->epio_delete_search_template();
1058 return;
1059 }
1060
1061 $sites = Utils\get_sites( 0, true );
1062 foreach ( $sites as $site ) {
1063 switch_to_blog( $site['blog_id'] );
1064 $this->epio_delete_search_template();
1065 restore_current_blog();
1066 }
1067 }
1068 }
1069