PluginProbe
ElasticPress / 4.7.0
ElasticPress v4.7.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 / Facets / Facets.php

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

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