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

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