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

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

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