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

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