PluginProbe
ElasticPress / 4.6.1
ElasticPress v4.6.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 4.6.1, at includes/classes/Feature/Facets/Facets.php

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