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 / Facets / Facets.php

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

713 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Facets feature
4 *
5 * @since 2.5
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Feature\Facets;
10
11 use ElasticPress\Feature;
12 use ElasticPress\Features;
13 use ElasticPress\Indexables;
14 use ElasticPress\REST;
15 use ElasticPress\Utils;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit; // Exit if accessed directly.
19 }
20
21 /**
22 * Facets feature class
23 */
24 class Facets extends Feature {
25 /**
26 * Facet types (taxonomy, meta fields, etc.)
27 *
28 * @since 4.3.0
29 * @var array
30 */
31 public $types = [];
32
33 /**
34 * Initialize feature setting it's config
35 *
36 * @since 3.0
37 */
38 public function __construct() {
39 $this->slug = 'facets';
40
41 $this->title = esc_html__( 'Filters', 'elasticpress' );
42
43 $this->summary = '<p>' .
44 wp_is_block_theme()
45 ? sprintf(
46 /* translators: Site Editor URL */
47 __( 'Adds <a href="%s">filter blocks</a> that administrators can add to the website’s templates and template parts, so that visitors can filter applicable content and search results by one or more taxonomy terms, metafields, and date ranges.', 'elasticpress' ),
48 esc_url( admin_url( 'site-editor.php' ) )
49 )
50 : sprintf(
51 /* translators: Widgets Edit Screen URL */
52 __( 'Adds <a href="%s">filter widgets</a> that administrators can add to the website’s sidebars (widgetized areas), so that visitors can filter applicable content and search results by one or more taxonomy terms, metafields, and date ranges.', 'elasticpress' ),
53 esc_url( admin_url( 'widgets.php' ) )
54 )
55 . '</p>';
56
57 $this->docs_url = __( 'https://elasticpress.zendesk.com/hc/en-us/articles/360050447492-Configuring-ElasticPress-via-the-Plugin-Dashboard#facets', 'elasticpress' );
58
59 $this->requires_install_reindex = false;
60
61 $this->default_settings = [
62 'match_type' => 'all',
63 ];
64
65 $types = [
66 'taxonomy' => __NAMESPACE__ . '\Types\Taxonomy\FacetType',
67 ];
68
69 if ( version_compare( get_bloginfo( 'version' ), '5.8', '>=' ) ) {
70 $types['meta'] = __NAMESPACE__ . '\Types\Meta\FacetType';
71 $types['meta-range'] = __NAMESPACE__ . '\Types\MetaRange\FacetType';
72 $types['post-type'] = __NAMESPACE__ . '\Types\PostType\FacetType';
73 $types['date'] = __NAMESPACE__ . '\Types\Date\FacetType';
74
75 }
76
77 /**
78 * Filter the Facet types available.
79 *
80 * ```
81 * add_filter(
82 * 'ep_facet_types',
83 * function ( $types ) {
84 * $types['post_type'] = '\MyPlugin\PostType';
85 * return $types;
86 * }
87 * );
88 * ```
89 *
90 * @since 4.3.0
91 * @hook ep_facet_types
92 * @param {array} $types Array of types available. Keys are slugs, values are class names.
93 * @return {array} New array of types available
94 */
95 $types = apply_filters( 'ep_facet_types', $types );
96
97 foreach ( $types as $type => $class ) {
98 if ( is_a( $class, __NAMESPACE__ . '\FacetType', true ) ) {
99 $this->types[ $type ] = new $class();
100 }
101 }
102
103 parent::__construct();
104 }
105
106 /**
107 * Setup hooks and filters for feature
108 *
109 * @since 2.5
110 */
111 public function setup() {
112 global $pagenow;
113
114 // This feature should not run while in the editor.
115 if ( in_array( $pagenow, [ 'post-new.php', 'post.php' ], true ) ) {
116 return;
117 }
118
119 foreach ( $this->types as $type => $class ) {
120 $this->types[ $type ]->setup();
121 }
122
123 add_filter( 'widget_types_to_hide_from_legacy_widget_block', [ $this, 'hide_legacy_widget' ] );
124 add_action( 'ep_valid_response', [ $this, 'get_aggs' ], 10, 4 );
125 add_action( 'wp_enqueue_scripts', [ $this, 'front_scripts' ] );
126 add_action( 'enqueue_block_editor_assets', [ $this, 'front_scripts' ] );
127 add_action( 'ep_feature_box_settings_facets', [ $this, 'settings' ], 10, 1 );
128 add_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 );
129 add_action( 'pre_get_posts', [ $this, 'facet_query' ] );
130 add_filter( 'ep_post_filters', [ $this, 'apply_facets_filters' ], 10, 3 );
131 add_action( 'rest_api_init', [ $this, 'setup_endpoints' ] );
132 }
133
134 /**
135 * Dashboard facet settings
136 *
137 * @since 2.5
138 */
139 public function output_feature_box_settings() {
140 $settings = $this->get_settings();
141 ?>
142 <div class="field">
143 <div class="field-name status"><?php esc_html_e( 'Match Type', 'elasticpress' ); ?></div>
144 <div class="input-wrap">
145 <label><input name="settings[match_type]" type="radio" <?php checked( $settings['match_type'], 'all' ); ?> value="all"><?php echo wp_kses_post( __( 'Show any content tagged to <strong>all</strong> selected terms', 'elasticpress' ) ); ?></label><br>
146 <label><input name="settings[match_type]" type="radio" <?php checked( $settings['match_type'], 'any' ); ?> value="any"><?php echo wp_kses_post( __( 'Show all content tagged to <strong>any</strong> selected term', 'elasticpress' ) ); ?></label>
147 <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>
148 </div>
149 </div>
150 <?php
151 }
152
153 /**
154 * If we are doing `or` matches, we need to remove filters from aggs.
155 *
156 * By default, the same filters applied to the main query are applied to aggregations.
157 * If doing `or` matches, those should be removed so we get a broader set of results.
158 *
159 * @param array $args ES arguments
160 * @param array $query_args Query arguments
161 * @param WP_Query $query WP Query instance
162 * @since 2.5
163 * @return array
164 */
165 public function set_agg_filters( $args, $query_args, $query ) {
166 // Not a facetable query
167 if ( empty( $query_args['ep_facet'] ) ) {
168 return $args;
169 }
170
171 if ( 'any' === $this->get_match_type() ) {
172 add_filter( 'ep_post_filters', [ $this, 'remove_facets_filter' ], 11 );
173 }
174
175 /**
176 * This flag is used to differentiate filters being applied to the query and to its aggregations.
177 */
178 $query_args['ep_facet_adding_agg_filters'] = true;
179
180 /**
181 * Filter WP query arguments that will be used to build the aggregations filter.
182 *
183 * The returned `$query_args` will be used to build the aggregations filter passing
184 * it through `Indexable\Post\Post::format_args()`.
185 *
186 * @hook ep_facet_agg_filters
187 * @since 4.3.0
188 * @param {array} $query_args Query arguments
189 * @param {array} $args ES arguments
190 * @param {array} $query WP Query instance
191 * @return {array} New facets aggregations
192 */
193 $query_args = apply_filters( 'ep_facet_agg_filters', $query_args, $args, $query );
194
195 remove_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 );
196 $facet_formatted_args = Indexables::factory()->get( 'post' )->format_args( $query_args, $query );
197 add_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 );
198
199 remove_filter( 'ep_post_filters', [ $this, 'remove_facets_filter' ], 11 );
200
201 $args['aggs']['terms']['filter'] = $facet_formatted_args['post_filter'];
202
203 return $args;
204 }
205
206 /**
207 * Output scripts for widget admin
208 *
209 * @param string $hook WP hook
210 * @since 2.5
211 */
212 public function admin_scripts( $hook ) {
213 _doing_it_wrong(
214 __METHOD__,
215 esc_html__( 'Facets no longer require admin styles.', 'elasticpress' ),
216 '4.7.0'
217 );
218 }
219
220 /**
221 * Output front end facets styles
222 *
223 * @since 2.5
224 */
225 public function front_scripts() {
226 wp_register_script(
227 'elasticpress-facets',
228 EP_URL . 'dist/js/facets-script.js',
229 Utils\get_asset_info( 'facets-script', 'dependencies' ),
230 Utils\get_asset_info( 'facets-script', 'version' ),
231 true
232 );
233
234 wp_set_script_translations( 'elasticpress-facets', 'elasticpress' );
235
236 wp_register_style(
237 'elasticpress-facets',
238 EP_URL . 'dist/css/facets-styles.css',
239 Utils\get_asset_info( 'facets-styles', 'dependencies' ),
240 Utils\get_asset_info( 'facets-styles', 'version' )
241 );
242 }
243
244 /**
245 * Figure out if we can/should facet the query
246 *
247 * @param WP_Query $query WP Query
248 * @since 2.5
249 * @return bool
250 */
251 public function is_facetable( $query ) {
252
253 /**
254 * Bypass the standard checks and set a query to be facetable
255 *
256 * @hook ep_is_facetable
257 * @param {bool} $bypass Defaults to false.
258 * @param {WP_Query} $query The current WP_Query.
259 * @return {bool} true to bypass, false to ignore
260 */
261 if ( \apply_filters( 'ep_is_facetable', false, $query ) ) {
262 return true;
263 }
264
265 if ( is_admin() || is_feed() ) {
266 return false;
267 }
268
269 if ( defined( 'WP_CLI' ) && WP_CLI ) {
270 return false;
271 }
272
273 if ( ! $query->is_main_query() ) {
274 return false;
275 }
276
277 $ep_integrate = $query->get( 'ep_integrate', null );
278
279 if ( false === $ep_integrate ) {
280 return false;
281 }
282
283 $woocommerce = Features::factory()->get_registered_feature( 'woocommerce' );
284
285 if ( ! $woocommerce->is_active() && ( function_exists( 'is_product_category' ) && is_product_category() ) ) {
286 return false;
287 }
288
289 if ( ! $this->is_facetable_page( $query ) ) {
290 return false;
291 }
292
293 return true;
294 }
295
296 /**
297 * We enable ElasticPress facet on all archive/search queries as well as non-static home pages. There is no way to know
298 * when a facet widget is used before the main query is executed so we enable EP
299 * everywhere where a facet widget could be used.
300 *
301 * @param WP_Query $query WP Query
302 * @since 2.5
303 */
304 public function facet_query( $query ) {
305 if ( ! $this->is_facetable( $query ) ) {
306 return;
307 }
308
309 // If any filter was selected, there is no reason to prepend the list with sticky posts.
310 $selected_filters = $this->get_selected();
311 if ( ! empty( array_filter( $selected_filters ) ) ) {
312 $query->set( 'ignore_sticky_posts', true );
313 }
314
315 /**
316 * Filter facet aggregations.
317 *
318 * This is used by facet types to add their own aggregations to the
319 * general facet.
320 *
321 * @hook ep_facet_wp_query_aggs_facet
322 * @since 4.3.0
323 * @param {array} $facets Facets aggregations
324 * @return {array} New facets aggregations
325 */
326 $facets = apply_filters( 'ep_facet_wp_query_aggs_facet', [] );
327
328 if ( empty( $facets ) ) {
329 return;
330 }
331
332 $query->set( 'ep_integrate', true );
333 $query->set( 'ep_facet', true );
334
335 $aggs = array(
336 'name' => 'terms',
337 'use-filter' => true,
338 'aggs' => $facets,
339 );
340
341 $query->set( 'aggs', $aggs );
342 }
343
344 /**
345 * Hacky. Save aggregation data for later in a global
346 *
347 * @param array $response ES response
348 * @param array $query Prepared Elasticsearch query
349 * @param array $query_args Current WP Query arguments
350 * @param mixed $query_object Could be WP_Query, WP_User_Query, etc.
351 * @since 2.5
352 */
353 public function get_aggs( $response, $query, $query_args, $query_object ) {
354 if ( empty( $query_object ) || 'WP_Query' !== get_class( $query_object ) || ! $this->is_facetable( $query_object ) ) {
355 return;
356 }
357
358 $GLOBALS['ep_facet_aggs'] = false;
359
360 if ( ! empty( $response['aggregations'] ) ) {
361 $GLOBALS['ep_facet_aggs'] = [];
362
363 if ( isset( $response['aggregations']['terms'] ) && is_array( $response['aggregations']['terms'] ) ) {
364 foreach ( $response['aggregations']['terms'] as $key => $agg ) {
365 if ( 'doc_count' === $key ) {
366 continue;
367 }
368
369 if ( ! is_array( $agg ) || ( empty( $agg['buckets'] ) && empty( $agg['value'] ) ) ) {
370 continue;
371 }
372
373 $GLOBALS['ep_facet_aggs'][ $key ] = [];
374
375 if ( ! empty( $agg['value'] ) ) {
376 $GLOBALS['ep_facet_aggs'][ $key ] = $agg['value'];
377 continue;
378 }
379
380 foreach ( $agg['buckets'] as $bucket ) {
381 $GLOBALS['ep_facet_aggs'][ $key ][ $bucket['key'] ] = $bucket['doc_count'];
382 }
383 }
384 }
385 }
386 }
387
388 /**
389 * Get currently selected facets from query args
390 *
391 * @since 2.5
392 * @return array
393 */
394 public function get_selected() {
395 $allowed_args = $this->get_allowed_query_args();
396
397 $filters = [];
398 $filter_names = [];
399 foreach ( $this->types as $type_obj ) {
400 $filter_names[ $type_obj->get_filter_name() ] = $type_obj;
401 }
402
403 foreach ( $_GET as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification
404 $key = sanitize_key( $key );
405
406 foreach ( $filter_names as $filter_name => $type_obj ) {
407 if ( 0 === strpos( $key, $filter_name ) ) {
408 if ( empty( $value ) ) {
409 continue;
410 }
411 $facet = str_replace( $filter_name, '', $key );
412
413 $filters = $type_obj->format_selected( $facet, $value, $filters );
414 }
415 }
416
417 if ( in_array( $key, $allowed_args, true ) ) {
418 $filters[ $key ] = $value;
419 }
420 }
421
422 return $filters;
423 }
424
425 /**
426 * Build query url
427 *
428 * @since 2.5
429 * @param array $filters Facet filters
430 * @return string
431 */
432 public function build_query_url( $filters ) {
433 $query_params = array();
434
435 foreach ( $this->types as $type_obj ) {
436 if ( empty( $filters[ $type_obj->get_filter_type() ] ) ) {
437 continue;
438 }
439 $query_params = $type_obj->add_query_params( $query_params, $filters );
440 }
441
442 $feature = Features::factory()->get_registered_feature( 'facets' );
443 $allowed_args = $feature->get_allowed_query_args();
444
445 if ( ! empty( $filters ) ) {
446 foreach ( $filters as $filter => $value ) {
447 if ( in_array( $filter, $allowed_args, true ) ) {
448 $query_params[ $filter ] = $value;
449 }
450 }
451 }
452
453 $query_string = build_query( $query_params );
454
455 /**
456 * Filter facet query string
457 *
458 * @hook ep_facet_query_string
459 * @param {string} $query_string Current query string
460 * @param {array} $query_params Query parameters
461 * @return {string} New query string
462 */
463 $query_string = apply_filters( 'ep_facet_query_string', $query_string, $query_params );
464
465 $url = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
466 $pagination = strpos( $url, '/page' );
467 if ( false !== $pagination ) {
468 $url = substr( $url, 0, $pagination );
469 }
470
471 return strtok( trailingslashit( $url ), '?' ) . ( ( ! empty( $query_string ) ) ? '?' . $query_string : '' );
472 }
473
474 /**
475 * Register facet widget(s)
476 *
477 * @since 2.5, deprecated in 4.3.0
478 */
479 public function register_widgets() {
480 _deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types[ \$type ]->register_widgets()" );
481 }
482
483 /**
484 * Hide the legacy widget.
485 *
486 * Hides the legacy widget in favor of the Block when the block editor
487 * is in use and the legacy widget has not been used.
488 *
489 * @since 4.3
490 * @param array $widgets An array of excluded widget-type IDs.
491 * @return array array of excluded widget-type IDs to hide.
492 */
493 public function hide_legacy_widget( $widgets ) {
494 $widgets[] = 'ep-facet';
495
496 return $widgets;
497 }
498
499 /**
500 * Output feature box long
501 *
502 * @since 2.5
503 */
504 public function output_feature_box_long() {
505 if ( current_theme_supports( 'widgets' ) ) {
506 $message = sprintf(
507 /* translators: Widgets Edit Screen URL */
508 __( "Adds <a href='%s'>filter widgets</a> that administrators can add to the website's sidebars (widgetized areas), so that visitors can filter applicable content and search results by one or more taxonomy terms.", 'elasticpress' ),
509 esc_url( admin_url( 'widgets.php' ) )
510 );
511 }
512
513 if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
514 $message = sprintf(
515 /* translators: Site Editor URL */
516 __( "Adds <a href='%s'>filter blocks</a> that administrators can add to the website's templates and template parts, so that visitors can filter applicable content and search results by one or more taxonomy terms.", 'elasticpress' ),
517 esc_url( admin_url( 'site-editor.php' ) )
518 );
519 }
520
521 if ( ! isset( $message ) ) {
522 return;
523 }
524 ?>
525 <p><?php echo wp_kses_post( $message ); ?></p>
526 <?php
527 }
528
529 /**
530 * Returns allowed query args for facets
531 *
532 * @return mixed|void
533 * @since 3.6.0
534 */
535 public function get_allowed_query_args() {
536 $args = array( 's', 'post_type', 'orderby' );
537
538 // Retrieve all registered query variables for public taxonomies
539 $taxonomies = get_taxonomies( [ 'public' => true ], 'objects' );
540 foreach ( $taxonomies as $taxonomy ) {
541 if ( $taxonomy->query_var ) {
542 $args[] = $taxonomy->query_var;
543 }
544 }
545 /**
546 * To keep backward compatibility, WordPress uses `'cat'` for default categories.
547 * It also allows access using the `?taxonomy=<tax>&term=<term>` format.
548 *
549 * @see get_term_link()
550 */
551 $args = array_merge( $args, [ 'cat', 'taxonomy', 'term' ] );
552
553 /**
554 * Filter allowed query args
555 *
556 * @hook ep_facet_allowed_query_args
557 * @since 3.6.0
558 * @param {array} $args Post types
559 * @return {array} New post types
560 */
561 return apply_filters( 'ep_facet_allowed_query_args', $args );
562 }
563
564 /**
565 * Get the facet filter name.
566 *
567 * @return string The filter name.
568 */
569 protected function get_filter_name() {
570 _deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types['taxonomy']->get_filter_name()" );
571
572 return $this->types['taxonomy']->get_filter_name();
573 }
574
575 /**
576 * Get all taxonomies that could be selected for a facet.
577 *
578 * @since 4.2.0, deprecated in 4.3.0
579 * @return array
580 */
581 public function get_facetable_taxonomies() {
582 _deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types['taxonomy']->get_facetable_taxonomies()" );
583
584 return $this->types['taxonomy']->get_filter_name();
585
586 }
587
588 /**
589 * Add a new filter to the ES query with selected facets
590 *
591 * @since 4.4.0
592 * @param array $filters Current filters
593 * @param array $args WP Query args
594 * @param WP_Query $query WP Query object
595 * @return array
596 */
597 public function apply_facets_filters( $filters, $args, $query ) {
598 if ( ! $this->is_facetable( $query ) ) {
599 return $filters;
600 }
601
602 /**
603 * Filter facet selection filters to be applied to the ES query
604 *
605 * @hook ep_facet_query_filters
606 * @since 4.4.0
607 * @param {array} $filters Current filters
608 * @param {array} $args WP Query args
609 * @param {WP_Query} $query WP Query object
610 * @return {array} New filters
611 */
612 $facets_filters = apply_filters( 'ep_facet_query_filters', [], $args, $query );
613
614 if ( empty( $facets_filters ) ) {
615 return $filters;
616 }
617
618 $es_operator = ( 'any' === $this->get_match_type() ) ? 'should' : 'must';
619
620 $filters['facets'] = [
621 'bool' => [
622 $es_operator => $facets_filters,
623 ],
624 ];
625
626 return $filters;
627 }
628
629 /**
630 * Utilitary function to retrieve the match type selected by the user.
631 *
632 * @since 4.4.0
633 * @return string
634 */
635 public function get_match_type() {
636 $settings = $this->get_settings();
637
638 /**
639 * Filter the match type of all facets. Can be 'all' or 'any'.
640 *
641 * @hook ep_facet_match_type
642 * @since 4.4.0
643 * @param {string} $match_type Current selection
644 * @return {string} New selection
645 */
646 return apply_filters( 'ep_facet_match_type', $settings['match_type'] );
647 }
648
649 /**
650 * Given an array of filters, remove the facets filter.
651 *
652 * This is used when the user wants posts matching ANY criteria, so aggregations should not restrict their results.
653 *
654 * @since 4.4.0
655 * @param array $filters Filters to be applied to the ES query
656 * @return array
657 */
658 public function remove_facets_filter( $filters ) {
659 unset( $filters['facets'] );
660 return $filters;
661 }
662
663 /**
664 * Set the `settings_schema` attribute
665 *
666 * @since 5.0.0
667 */
668 protected function set_settings_schema() {
669 $this->settings_schema[] = [
670 'key' => 'match_type',
671 'label' => __( 'Filter matching', 'elasticpress' ),
672 'options' => [
673 [
674 'label' => __( 'Show results that match <strong>all</strong> selected filters', 'elasticpress' ),
675 'value' => 'all',
676 ],
677 [
678 'label' => __( 'Show results that match <strong>any</strong> selected filter', 'elasticpress' ),
679 'value' => 'any',
680 ],
681 ],
682 'type' => 'radio',
683 ];
684 }
685
686 /**
687 * Figure out if Facet widget can display on page.
688 *
689 * @param WP_Query $query WP Query
690 * @since 4.2.1
691 * @return bool
692 */
693 protected function is_facetable_page( $query ) {
694 return $query->is_home() || $query->is_search() || $query->is_tax() || $query->is_tag() || $query->is_category() || $query->is_post_type_archive();
695 }
696
697 /**
698 * Setup REST endpoints
699 *
700 * @since 5.0.0
701 */
702 public function setup_endpoints() {
703 $meta_keys = new REST\MetaKeys();
704 $meta_keys->register_routes();
705
706 $meta_range = new REST\MetaRange();
707 $meta_range->register_routes();
708
709 $taxonomies = new REST\Taxonomies();
710 $taxonomies->register_routes();
711 }
712 }
713