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

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

658 lines 17.9 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 $facet = str_replace( $filter_name, '', $key );
405
406 $filters = $type_obj->format_selected( $facet, $value, $filters );
407 }
408 }
409
410 if ( in_array( $key, $allowed_args, true ) ) {
411 $filters[ $key ] = $value;
412 }
413 }
414
415 return $filters;
416 }
417
418 /**
419 * Build query url
420 *
421 * @since 2.5
422 * @param array $filters Facet filters
423 * @return string
424 */
425 public function build_query_url( $filters ) {
426 $query_params = array();
427
428 foreach ( $this->types as $type_obj ) {
429 if ( empty( $filters[ $type_obj->get_filter_type() ] ) ) {
430 continue;
431 }
432 $query_params = $type_obj->add_query_params( $query_params, $filters );
433 }
434
435 $feature = Features::factory()->get_registered_feature( 'facets' );
436 $allowed_args = $feature->get_allowed_query_args();
437
438 if ( ! empty( $filters ) ) {
439 foreach ( $filters as $filter => $value ) {
440 if ( in_array( $filter, $allowed_args, true ) ) {
441 $query_params[ $filter ] = $value;
442 }
443 }
444 }
445
446 $query_string = build_query( $query_params );
447
448 /**
449 * Filter facet query string
450 *
451 * @hook ep_facet_query_string
452 * @param {string} $query_string Current query string
453 * @param {array} $query_params Query parameters
454 * @return {string} New query string
455 */
456 $query_string = apply_filters( 'ep_facet_query_string', $query_string, $query_params );
457
458 $url = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
459 $pagination = strpos( $url, '/page' );
460 if ( false !== $pagination ) {
461 $url = substr( $url, 0, $pagination );
462 }
463
464 return strtok( trailingslashit( $url ), '?' ) . ( ( ! empty( $query_string ) ) ? '?' . $query_string : '' );
465 }
466
467 /**
468 * Register facet widget(s)
469 *
470 * @since 2.5, deprecated in 4.3.0
471 */
472 public function register_widgets() {
473 _deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types[ \$type ]->register_widgets()" );
474 }
475
476 /**
477 * Hide the legacy widget.
478 *
479 * Hides the legacy widget in favor of the Block when the block editor
480 * is in use and the legacy widget has not been used.
481 *
482 * @since 4.3
483 * @param array $widgets An array of excluded widget-type IDs.
484 * @return array array of excluded widget-type IDs to hide.
485 */
486 public function hide_legacy_widget( $widgets ) {
487 $widgets[] = 'ep-facet';
488
489 return $widgets;
490 }
491
492 /**
493 * Output feature box long
494 *
495 * @since 2.5
496 */
497 public function output_feature_box_long() {
498 ?>
499 <p>
500 <?php
501 // translators: URL
502 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' ) ) ) );
503 ?>
504 </p>
505 <?php
506 }
507
508 /**
509 * Returns allowed query args for facets
510 *
511 * @return mixed|void
512 * @since 3.6.0
513 */
514 public function get_allowed_query_args() {
515 $args = array( 's', 'post_type', 'orderby' );
516
517 // Retrieve all registered query variables for public taxonomies
518 $taxonomies = get_taxonomies( [ 'public' => true ], 'objects' );
519 foreach ( $taxonomies as $taxonomy ) {
520 if ( $taxonomy->query_var ) {
521 $args[] = $taxonomy->query_var;
522 }
523 }
524 /**
525 * To keep backward compatibility, WordPress uses `'cat'` for default categories.
526 * It also allows access using the `?taxonomy=<tax>&term=<term>` format.
527 *
528 * @see get_term_link()
529 */
530 $args = array_merge( $args, [ 'cat', 'taxonomy', 'term' ] );
531
532 /**
533 * Filter allowed query args
534 *
535 * @hook ep_facet_allowed_query_args
536 * @since 3.6.0
537 * @param {array} $args Post types
538 * @return {array} New post types
539 */
540 return apply_filters( 'ep_facet_allowed_query_args', $args );
541 }
542
543 /**
544 * Get the facet filter name.
545 *
546 * @return string The filter name.
547 */
548 protected function get_filter_name() {
549 _deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types['taxonomy']->get_filter_name()" );
550
551 return $this->types['taxonomy']->get_filter_name();
552 }
553
554 /**
555 * Get all taxonomies that could be selected for a facet.
556 *
557 * @since 4.2.0, deprecated in 4.3.0
558 * @return array
559 */
560 public function get_facetable_taxonomies() {
561 _deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types['taxonomy']->get_facetable_taxonomies()" );
562
563 return $this->types['taxonomy']->get_filter_name();
564
565 }
566
567 /**
568 * Add a new filter to the ES query with selected facets
569 *
570 * @since 4.4.0
571 * @param array $filters Current filters
572 * @param array $args WP Query args
573 * @param WP_Query $query WP Query object
574 * @return array
575 */
576 public function apply_facets_filters( $filters, $args, $query ) {
577 if ( ! $this->is_facetable( $query ) ) {
578 return $filters;
579 }
580
581 /**
582 * Filter facet selection filters to be applied to the ES query
583 *
584 * @hook ep_facet_query_filters
585 * @since 4.4.0
586 * @param {array} $filters Current filters
587 * @param {array} $args WP Query args
588 * @param {WP_Query} $query WP Query object
589 * @return {array} New filters
590 */
591 $facets_filters = apply_filters( 'ep_facet_query_filters', [], $args, $query );
592
593 if ( empty( $facets_filters ) ) {
594 return $filters;
595 }
596
597 $es_operator = ( 'any' === $this->get_match_type() ) ? 'should' : 'must';
598
599 $filters['facets'] = [
600 'bool' => [
601 $es_operator => $facets_filters,
602 ],
603 ];
604
605 return $filters;
606 }
607
608 /**
609 * Utilitary function to retrieve the match type selected by the user.
610 *
611 * @since 4.4.0
612 * @return string
613 */
614 public function get_match_type() {
615 $settings = wp_parse_args(
616 $this->get_settings(),
617 array(
618 'match_type' => 'all',
619 )
620 );
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