PluginProbe
ElasticPress / 4.6.0
ElasticPress v4.6.0
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.6.0, at includes/classes/Feature/InstantResults/InstantResults.php

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