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

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