PluginProbe
ElasticPress / 5.0.1
ElasticPress v5.0.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 / InstantResults / InstantResults.php

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

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