PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
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 / WooCommerce / Products.php

Products.php in ElasticPress 5.3.5, at includes/classes/Feature/WooCommerce/Products.php

1,163 lines 36.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Products
4 *
5 * @since 4.7.0
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Feature\WooCommerce;
10
11 use ElasticPress\Indexables;
12 use ElasticPress\IndexHelper;
13 use ElasticPress\Utils;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * WooCommerce Products
21 */
22 class Products {
23 /**
24 * WooCommerce feature object instance
25 *
26 * @var WooCommerce
27 */
28 protected $woocommerce;
29
30 /**
31 * Class constructor
32 *
33 * @param WooCommerce $woocommerce WooCommerce feature object instance
34 */
35 public function __construct( WooCommerce $woocommerce ) {
36 $this->woocommerce = $woocommerce;
37 }
38
39 /**
40 * Setup product related hooks
41 */
42 public function setup() {
43 add_action( 'ep_formatted_args', [ $this, 'price_filter' ], 10, 3 );
44 add_filter( 'ep_prepare_meta_allowed_protected_keys', [ $this, 'allow_meta_keys' ], 10, 2 );
45 add_filter( 'ep_sync_taxonomies', [ $this, 'sync_taxonomies' ] );
46 add_filter( 'ep_term_suggest_post_type', [ $this, 'suggest_wc_add_post_type' ] );
47 add_filter( 'ep_facet_include_taxonomies', [ $this, 'add_product_attributes' ] );
48 add_filter( 'ep_weighting_fields_for_post_type', [ $this, 'add_product_attributes_to_weighting' ], 10, 2 );
49 add_filter( 'ep_weighting_default_post_type_weights', [ $this, 'add_product_default_post_type_weights' ], 10, 2 );
50 add_filter( 'ep_prepare_meta_data', [ $this, 'add_variations_skus_meta' ], 10, 2 );
51 add_filter( 'request', [ $this, 'admin_product_list_request_query' ], 9 );
52 add_action( 'pre_get_posts', [ $this, 'translate_args' ], 11, 1 );
53 add_filter( 'ep_facet_tax_special_slug_taxonomies', [ $this, 'add_taxonomy_attributes' ] );
54
55 // Custom product ordering
56 add_action( 'ep_admin_notices', [ $this, 'maybe_display_notice_about_product_ordering' ] );
57 add_action( 'woocommerce_after_product_ordering', [ $this, 'action_sync_on_woocommerce_sort_single' ], 10, 2 );
58
59 // Settings for Weight results by date
60 add_action( 'ep_weight_settings_after_search', [ $this, 'add_weight_settings_search' ] );
61 add_filter( 'ep_feature_settings_schema', [ $this, 'add_weight_settings_search_schema' ], 10, 2 );
62 add_filter( 'ep_is_decaying_enabled', [ $this, 'maybe_disable_decaying' ], 10, 3 );
63 }
64
65 /**
66 * Un-setup product related hooks
67 *
68 * @since 5.0.0
69 */
70 public function tear_down() {
71 remove_action( 'ep_formatted_args', [ $this, 'price_filter' ] );
72 remove_filter( 'ep_prepare_meta_allowed_protected_keys', [ $this, 'allow_meta_keys' ] );
73 remove_filter( 'ep_sync_taxonomies', [ $this, 'sync_taxonomies' ] );
74 remove_filter( 'ep_term_suggest_post_type', [ $this, 'suggest_wc_add_post_type' ] );
75 remove_filter( 'ep_facet_include_taxonomies', [ $this, 'add_product_attributes' ] );
76 remove_filter( 'ep_weighting_fields_for_post_type', [ $this, 'add_product_attributes_to_weighting' ] );
77 remove_filter( 'ep_weighting_default_post_type_weights', [ $this, 'add_product_default_post_type_weights' ] );
78 remove_filter( 'ep_prepare_meta_data', [ $this, 'add_variations_skus_meta' ] );
79 remove_filter( 'request', [ $this, 'admin_product_list_request_query' ], 9 );
80 remove_action( 'pre_get_posts', [ $this, 'translate_args' ], 11 );
81 remove_filter( 'ep_facet_tax_special_slug_taxonomies', [ $this, 'add_taxonomy_attributes' ] );
82
83 // Custom product ordering
84 remove_action( 'ep_admin_notices', [ $this, 'maybe_display_notice_about_product_ordering' ] );
85 remove_action( 'woocommerce_after_product_ordering', [ $this, 'action_sync_on_woocommerce_sort_single' ] );
86
87 // Settings for Weight results by date
88 remove_action( 'ep_weight_settings_after_search', [ $this, 'add_weight_settings_search' ] );
89 remove_filter( 'ep_feature_settings_schema', [ $this, 'add_weight_settings_search_schema' ] );
90 remove_filter( 'ep_is_decaying_enabled', [ $this, 'maybe_disable_decaying' ] );
91 }
92
93 /**
94 * Modifies main query to allow filtering by price with WooCommerce "Filter by price" widget.
95 *
96 * @param array $args ES args
97 * @param array $query_args WP_Query args
98 * @param WP_Query $query WP_Query object
99 * @return array
100 */
101 public function price_filter( $args, $query_args, $query ) {
102 // Only can use widget on main query
103 if ( ! $query->is_main_query() ) {
104 return $args;
105 }
106
107 // Only can use widget on shop, product taxonomy, or search
108 if ( ! is_shop() && ! is_product_taxonomy() && ! is_search() ) {
109 return $args;
110 }
111
112 // phpcs:disable WordPress.Security.NonceVerification
113 if ( empty( $_GET['min_price'] ) && empty( $_GET['max_price'] ) ) {
114 return $args;
115 }
116
117 $min_price = ! empty( $_GET['min_price'] ) ? sanitize_text_field( wp_unslash( $_GET['min_price'] ) ) : null;
118 $max_price = ! empty( $_GET['max_price'] ) ? sanitize_text_field( wp_unslash( $_GET['max_price'] ) ) : null;
119 // phpcs:enable WordPress.Security.NonceVerification
120
121 // Align bounds with the excluding-tax price Elasticsearch indexes when the
122 // shop shows including-tax prices, matching WooCommerce core.
123 if ( null !== $min_price ) {
124 $min_price = $this->get_price_filter_tax_adjustment( (float) $min_price );
125 }
126 if ( null !== $max_price ) {
127 $max_price = $this->get_price_filter_tax_adjustment( (float) $max_price );
128 }
129
130 if ( $query->is_search() ) {
131 /**
132 * This logic is iffy but the WC price filter widget is not intended for use with search anyway
133 */
134 $old_query = $args['query']['bool'];
135 unset( $args['query']['bool']['should'] );
136
137 if ( ! empty( $min_price ) ) {
138 $args['query']['bool']['must'][0]['range']['meta._price.double']['gte'] = $min_price;
139 }
140
141 if ( ! empty( $max_price ) ) {
142 $args['query']['bool']['must'][0]['range']['meta._price.double']['lte'] = $max_price;
143 }
144
145 $args['query']['bool']['must'][0]['range']['meta._price.double']['boost'] = 2.0;
146 $args['query']['bool']['must'][1]['bool'] = $old_query;
147 } else {
148 unset( $args['query']['match_all'] );
149
150 $args['query']['range']['meta._price.double']['gte'] = ! empty( $min_price ) ? $min_price : 0;
151
152 if ( ! empty( $min_price ) ) {
153 $args['query']['range']['meta._price.double']['gte'] = $min_price;
154 }
155
156 if ( ! empty( $max_price ) ) {
157 $args['query']['range']['meta._price.double']['lte'] = $max_price;
158 }
159
160 $args['query']['range']['meta._price.double']['boost'] = 2.0;
161 }
162
163 return $args;
164 }
165
166 /**
167 * Subtract inclusive tax from a price filter value so it matches the
168 * excluding-tax price Elasticsearch stores.
169 *
170 * Mirrors WooCommerce core (WC_Query::price_filter_post_clauses). Only kicks
171 * in when prices are entered without tax but the shop shows including-tax
172 * prices, the case where the Filter by Price widget sends including-tax bounds.
173 *
174 * @since 5.3.4
175 * @param float $price Raw bound from min_price or max_price.
176 * @return float
177 */
178 protected function get_price_filter_tax_adjustment( $price ) {
179 if ( ! function_exists( 'wc_tax_enabled' ) || ! wc_tax_enabled() ) {
180 return $price;
181 }
182
183 if ( 'incl' !== get_option( 'woocommerce_tax_display_shop' ) ) {
184 return $price;
185 }
186
187 if ( function_exists( 'wc_prices_include_tax' ) && wc_prices_include_tax() ) {
188 return $price;
189 }
190
191 if ( ! method_exists( 'WC_Tax', 'get_rates' ) ) {
192 return $price;
193 }
194
195 $tax_class = apply_filters( 'woocommerce_price_filter_widget_tax_class', '' ); // Standard tax class.
196 $tax_rates = \WC_Tax::get_rates( $tax_class );
197
198 if ( empty( $tax_rates ) ) {
199 return $price;
200 }
201
202 return wc_format_decimal( $price - \WC_Tax::get_tax_total( \WC_Tax::calc_inclusive_tax( $price, $tax_rates ) ) );
203 }
204
205 /**
206 * Index WooCommerce products meta fields
207 *
208 * @param array $meta Existing post meta
209 * @param \WP_Post $post Post object.
210 * @return array
211 */
212 public function allow_meta_keys( $meta, $post ) {
213 if ( 'product' !== $post->post_type ) {
214 return $meta;
215 }
216
217 return array_unique(
218 array_merge(
219 $meta,
220 array(
221 '_thumbnail_id',
222 '_product_attributes',
223 '_wpb_vc_js_status',
224 '_swatch_type',
225 'total_sales',
226 '_downloadable',
227 '_virtual',
228 '_regular_price',
229 '_sale_price',
230 '_tax_status',
231 '_tax_class',
232 '_purchase_note',
233 '_featured',
234 '_weight',
235 '_length',
236 '_width',
237 '_height',
238 '_visibility',
239 '_sku',
240 '_sale_price_dates_from',
241 '_sale_price_dates_to',
242 '_price',
243 '_sold_individually',
244 '_manage_stock',
245 '_backorders',
246 '_stock',
247 '_upsell_ids',
248 '_crosssell_ids',
249 '_stock_status',
250 '_product_version',
251 '_product_tabs',
252 '_override_tab_layout',
253 '_suggested_price',
254 '_min_price',
255 '_variable_billing',
256 '_wc_average_rating',
257 '_product_image_gallery',
258 '_bj_lazy_load_skip_post',
259 '_min_variation_price',
260 '_max_variation_price',
261 '_min_price_variation_id',
262 '_max_price_variation_id',
263 '_min_variation_regular_price',
264 '_max_variation_regular_price',
265 '_min_regular_price_variation_id',
266 '_max_regular_price_variation_id',
267 '_min_variation_sale_price',
268 '_max_variation_sale_price',
269 '_min_sale_price_variation_id',
270 '_max_sale_price_variation_id',
271 '_default_attributes',
272 '_swatch_type_options',
273 '_variations_skus',
274 )
275 )
276 );
277 }
278
279 /**
280 * Index WooCommerce taxonomies
281 *
282 * @param array $taxonomies Index taxonomies array
283 * @return array
284 */
285 public function sync_taxonomies( $taxonomies ) {
286 $woo_taxonomies = [];
287
288 $product_type = get_taxonomy( 'product_type' );
289 if ( false !== $product_type ) {
290 $woo_taxonomies[] = $product_type;
291 }
292
293 $product_visibility = get_taxonomy( 'product_visibility' );
294 if ( false !== $product_visibility ) {
295 $woo_taxonomies[] = $product_visibility;
296 }
297
298 /**
299 * Note product_shipping_class, product_cat, and product_tag are already public. Make
300 * sure to index non-attribute taxonomies.
301 */
302 $attribute_taxonomies = wc_get_attribute_taxonomies();
303
304 if ( ! empty( $attribute_taxonomies ) ) {
305 foreach ( $attribute_taxonomies as $tax ) {
306 $name = wc_attribute_taxonomy_name( $tax->attribute_name );
307
308 if ( ! empty( $name ) ) {
309 if ( empty( $tax->attribute_ ) ) {
310 $woo_taxonomies[] = get_taxonomy( $name );
311 }
312 }
313 }
314 }
315
316 return array_merge( $taxonomies, $woo_taxonomies );
317 }
318
319 /**
320 * Add WC product post type to autosuggest
321 *
322 * @param array $post_types Array of post types (e.g. post, page)
323 * @return array
324 */
325 public function suggest_wc_add_post_type( $post_types ) {
326 if ( ! in_array( 'product', $post_types, true ) ) {
327 $post_types[] = 'product';
328 }
329
330 return $post_types;
331 }
332
333 /**
334 * Add WooCommerce Product Attributes to EP Facets.
335 *
336 * @param array $taxonomies Taxonomies array
337 * @return array
338 */
339 public function add_product_attributes( $taxonomies = [] ) {
340 $attribute_names = wc_get_attribute_taxonomy_names();
341
342 foreach ( $attribute_names as $name ) {
343 if ( ! taxonomy_exists( $name ) ) {
344 continue;
345 }
346 $taxonomies[ $name ] = get_taxonomy( $name );
347 }
348
349 return $taxonomies;
350 }
351
352 /**
353 * Add WooCommerce Fields to the Weighting Dashboard.
354 *
355 * @param array $fields Current weighting fields.
356 * @param string $post_type Current post type.
357 * @return array New fields.
358 */
359 public function add_product_attributes_to_weighting( $fields, $post_type ) {
360 if ( 'product' !== $post_type ) {
361 return $fields;
362 }
363
364 if ( ! empty( $fields['attributes']['children']['author_name'] ) ) {
365 unset( $fields['attributes']['children']['author_name'] );
366 }
367
368 $sku_key = 'meta._sku.value';
369
370 unset( $fields['ep_metadata']['children'][ $sku_key ] );
371
372 $fields['attributes']['children'][ $sku_key ] = array(
373 'key' => $sku_key,
374 'label' => __( 'SKU', 'elasticpress' ),
375 );
376
377 $variations_skus_key = 'meta._variations_skus.value';
378
379 unset( $fields['ep_metadata']['children'][ $variations_skus_key ] );
380
381 $fields['attributes']['children'][ $variations_skus_key ] = array(
382 'key' => $variations_skus_key,
383 'label' => __( 'Variations SKUs', 'elasticpress' ),
384 );
385
386 return $fields;
387 }
388
389 /**
390 * Add WooCommerce Fields to the default values of the Weighting Dashboard.
391 *
392 * @param array $defaults Default values for the post type.
393 * @param string $post_type Current post type.
394 * @return array
395 */
396 public function add_product_default_post_type_weights( $defaults, $post_type ) {
397 if ( 'product' !== $post_type ) {
398 return $defaults;
399 }
400
401 if ( ! empty( $defaults['author_name'] ) ) {
402 unset( $defaults['author_name'] );
403 }
404
405 $defaults['meta._sku.value'] = array(
406 'enabled' => true,
407 'weight' => 1,
408 );
409
410 $defaults['meta._variations_skus.value'] = array(
411 'enabled' => true,
412 'weight' => 1,
413 );
414
415 return $defaults;
416 }
417
418 /**
419 * Add a new `_variations_skus` meta field to the product to be indexed in Elasticsearch.
420 *
421 * @param array $post_meta Post meta
422 * @param WP_Post $post Post object
423 * @return array
424 */
425 public function add_variations_skus_meta( $post_meta, $post ) {
426 if ( 'product' !== $post->post_type ) {
427 return $post_meta;
428 }
429
430 $product = wc_get_product( $post );
431 if ( ! $product ) {
432 return $post_meta;
433 }
434
435 $variations_ids = $product->get_children();
436
437 $post_meta['_variations_skus'] = array_reduce(
438 $variations_ids,
439 function ( $variations_skus, $current_id ) {
440 $variation = wc_get_product( $current_id );
441 if ( ! $variation || ! $variation->exists() ) {
442 return $variations_skus;
443 }
444 $variation_sku = $variation->get_sku();
445 if ( ! $variation_sku ) {
446 return $variations_skus;
447 }
448 $variations_skus[] = $variation_sku;
449 return $variations_skus;
450 },
451 []
452 );
453
454 return $post_meta;
455 }
456
457 /**
458 * Integrate ElasticPress with the WooCommerce Admin Product List.
459 *
460 * WooCommerce uses its `WC_Admin_List_Table_Products` class to control that screen. This
461 * function adds all necessary hooks to bypass the default behavior and integrate with ElasticPress.
462 * By default, WC runs a SQL query to get the Product IDs that match the list criteria and passes
463 * that list of IDs to the main WP_Query. This integration changes that process to a single query, run
464 * by ElasticPress.
465 *
466 * @param array $query_vars Query vars.
467 * @return array
468 */
469 public function admin_product_list_request_query( $query_vars ) {
470 global $typenow, $wc_list_table;
471
472 // Return if not in the correct screen.
473 if ( ! is_a( $wc_list_table, 'WC_Admin_List_Table_Products' ) || 'product' !== $typenow ) {
474 return $query_vars;
475 }
476
477 // Return if admin WP_Query integration is not turned on, i.e., Protect Content is not enabled.
478 if ( ! has_filter( 'ep_admin_wp_query_integration', '__return_true' ) ) {
479 return $query_vars;
480 }
481
482 /**
483 * Filter to skip integration with WooCommerce Admin Product List.
484 *
485 * @hook ep_woocommerce_integrate_admin_products_list
486 * @since 4.2.0
487 * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true.
488 * @param {array} $query_vars Query vars.
489 * @return {bool} New integrate value
490 */
491 if ( ! apply_filters( 'ep_woocommerce_integrate_admin_products_list', true, $query_vars ) ) {
492 return $query_vars;
493 }
494
495 add_action( 'pre_get_posts', [ $this, 'translate_args_admin_products_list' ], 12 );
496
497 // This short-circuits WooCommerce search for product IDs.
498 add_filter( 'woocommerce_product_pre_search_products', '__return_empty_array' );
499
500 return $query_vars;
501 }
502
503 /**
504 * Apply the necessary changes to WP_Query in WooCommerce Admin Product List.
505 *
506 * @param WP_Query $query The WP Query being executed.
507 */
508 public function translate_args_admin_products_list( $query ) {
509 // The `translate_args()` method sets it to `true` if we should integrate it.
510 if ( ! $query->get( 'ep_integrate', false ) ) {
511 return;
512 }
513
514 // WooCommerce unsets the search term right after using it to fetch product IDs. Here we add it back.
515 $search_term = ! empty( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
516 if ( ! empty( $search_term ) ) {
517 $query->set( 's', sanitize_text_field( $search_term ) );
518
519 /**
520 * Filter the fields used in WooCommerce Admin Product Search.
521 *
522 * ```
523 * add_filter(
524 * 'ep_woocommerce_admin_products_list_search_fields',
525 * function ( $wc_admin_search_fields ) {
526 * $wc_admin_search_fields['meta'][] = 'custom_field';
527 * return $wc_admin_search_fields;
528 * }
529 * );
530 * ```
531 *
532 * @hook ep_woocommerce_admin_products_list_search_fields
533 * @since 4.2.0
534 * @param {array} $wc_admin_search_fields Fields to be used in the WooCommerce Admin Product Search
535 * @return {array} New fields
536 */
537 $search_fields = apply_filters(
538 'ep_woocommerce_admin_products_list_search_fields',
539 [
540 'post_title',
541 'post_content',
542 'post_excerpt',
543 'meta' => [
544 '_sku',
545 '_variations_skus',
546 ],
547 ]
548 );
549
550 $query->set( 'search_fields', $search_fields );
551 }
552
553 // Sets the meta query for `product_type` if needed. Also removed from the WP_Query by WC in `WC_Admin_List_Table_Products::query_filters()`.
554 $product_type_query = $query->get( 'product_type', '' );
555 $product_type_url = ! empty( $_GET['product_type'] ) ? sanitize_text_field( wp_unslash( $_GET['product_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
556 $allowed_prod_types = [ 'virtual', 'downloadable' ];
557 if ( empty( $product_type_query ) && ! empty( $product_type_url ) && in_array( $product_type_url, $allowed_prod_types, true ) ) {
558 $meta_query = $query->get( 'meta_query', [] );
559 $meta_query[] = [
560 'key' => "_{$product_type_url}",
561 'value' => 'yes',
562 ];
563 $query->set( 'meta_query', $meta_query );
564 }
565
566 // Sets the meta query for `stock_status` if needed.
567 $stock_status_query = $query->get( 'stock_status', '' );
568 $stock_status_url = ! empty( $_GET['stock_status'] ) ? sanitize_text_field( wp_unslash( $_GET['stock_status'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
569 $allowed_stock_status = [ 'instock', 'outofstock', 'onbackorder' ];
570 if ( empty( $stock_status_query ) && ! empty( $stock_status_url ) && in_array( $stock_status_url, $allowed_stock_status, true ) ) {
571 $meta_query = $query->get( 'meta_query', [] );
572 $meta_query[] = [
573 'key' => '_stock_status',
574 'value' => $stock_status_url,
575 ];
576 $query->set( 'meta_query', $meta_query );
577 }
578 }
579
580 /**
581 * Depending on the number of products display an admin notice in the custom sort screen for WooCommerce Products
582 *
583 * @param array $notices Current ElasticPress admin notices
584 * @return array
585 */
586 public function maybe_display_notice_about_product_ordering( $notices ) {
587 global $pagenow, $wp_query;
588
589 /**
590 * Make sure we're on edit.php in admin dashboard.
591 */
592 if ( ! is_admin() || 'edit.php' !== $pagenow || empty( $wp_query->query['orderby'] ) || 'menu_order title' !== $wp_query->query['orderby'] ) {
593 return $notices;
594 }
595
596 $documents_per_page_sync = IndexHelper::factory()->get_index_default_per_page();
597 if ( $documents_per_page_sync >= $wp_query->found_posts ) {
598 return $notices;
599 }
600
601 $notices['woocommerce_custom_sort'] = [
602 'html' => sprintf(
603 /* translators: Sync Page URL */
604 __( 'Due to the number of products in the site, you will need to <a href="%s">resync</a> after applying a custom sort order.', 'elasticpress' ),
605 Utils\get_sync_url()
606 ),
607 'type' => 'warning',
608 'dismiss' => true,
609 ];
610
611 return $notices;
612 }
613
614 /**
615 * Conditionally resync products after applying a custom order.
616 *
617 * @param int $sorting_id ID of post dragged and dropped
618 * @param array $menu_orders Post IDs and their new menu_order value
619 */
620 public function action_sync_on_woocommerce_sort_single( $sorting_id, $menu_orders ) {
621 $documents_per_page_sync = IndexHelper::factory()->get_index_default_per_page();
622 if ( $documents_per_page_sync < count( $menu_orders ) ) {
623 return;
624 }
625
626 $sync_manager = Indexables::factory()->get( 'post' )->sync_manager;
627 foreach ( $menu_orders as $post_id => $order ) {
628 $sync_manager->add_to_queue( $post_id );
629 }
630 }
631
632 /**
633 * Add weight by date settings related to WooCommerce
634 *
635 * @param array $settings Current settings.
636 */
637 public function add_weight_settings_search( $settings ) {
638 ?>
639 <label><input name="settings[decaying_enabled]" type="radio" <?php checked( $settings['decaying_enabled'], 'disabled_only_products' ); ?> value="disabled_only_products"><?php esc_html_e( 'Disabled for product only queries', 'elasticpress' ); ?></label><br>
640 <label><input name="settings[decaying_enabled]" type="radio" <?php checked( $settings['decaying_enabled'], 'disabled_includes_products' ); ?> value="disabled_includes_products"><?php esc_html_e( 'Disabled for any query that includes products', 'elasticpress' ); ?></label>
641 <?php
642 }
643
644 /**
645 * Conditionally disable decaying by date based on WooCommerce Decay settings.
646 *
647 * @param bool $is_decaying_enabled Whether decay by date is enabled or not
648 * @param array $settings Settings
649 * @param array $args WP_Query args
650 * @return bool
651 */
652 public function maybe_disable_decaying( $is_decaying_enabled, $settings, $args ) {
653 // If the decay setting isn't a WooCommerce related option, return
654 if ( ! in_array( $settings['decaying_enabled'], [ 'disabled_only_products', 'disabled_includes_products' ], true ) ) {
655 return $is_decaying_enabled;
656 }
657
658 // If the query is not dealing with products, return
659 if ( ! isset( $args['post_type'] ) || ! in_array( 'product', (array) $args['post_type'], true ) ) {
660 return $is_decaying_enabled;
661 }
662
663 $post_types = (array) $args['post_type'];
664
665 // If set to disable decay on product-only queries and have more than one post type, return
666 if ( 'disabled_only_products' === $settings['decaying_enabled'] && count( $post_types ) > 1 ) {
667 return $is_decaying_enabled;
668 }
669
670 return false;
671 }
672
673 /**
674 * Translate args to ElasticPress compat format. This is the meat of what the feature does
675 *
676 * @param \WP_Query $query WP Query
677 */
678 public function translate_args( $query ) {
679 if ( ! $this->woocommerce->should_integrate_with_query( $query ) ) {
680 return;
681 }
682
683 if ( ! $this->should_integrate_with_query( $query ) ) {
684 return;
685 }
686
687 /**
688 * Make sure filters are suppressed
689 */
690 $query->query['suppress_filters'] = false;
691 $query->set( 'suppress_filters', false );
692
693 $query->set( 'ep_integrate', true );
694
695 $this->maybe_update_tax_query( $query );
696 $this->maybe_update_post_type( $query );
697 $this->maybe_update_meta_query( $query );
698
699 $this->maybe_handle_top_rated( $query );
700
701 $this->maybe_set_search_fields( $query );
702 $this->maybe_set_orderby( $query );
703 }
704
705 /**
706 * Determines whether or not ES should be integrating with the provided query.
707 *
708 * A product-related query will be integrated if:
709 * * Is the main query OR is a search OR has `ep_integrate` set as true
710 * * Is querying a supported taxonomy like product attributes
711 * * Is querying a supported post type like `product`
712 *
713 * @param \WP_Query $query Query we might integrate with
714 * @return bool
715 */
716 public function should_integrate_with_query( \WP_Query $query ): bool {
717 $has_ep_integrate = isset( $query->query_vars['ep_integrate'] ) && filter_var( $query->query_vars['ep_integrate'], FILTER_VALIDATE_BOOLEAN );
718 $is_search = '' !== $this->woocommerce->get_search_term( $query );
719
720 if ( ! $query->is_main_query() && ! $is_search && ! $has_ep_integrate ) {
721 return false;
722 }
723
724 /**
725 * Check for taxonomies
726 */
727 $supported_taxonomies = array_diff( $this->get_supported_taxonomies(), [ 'product_visibility' ] );
728 $tax_query = $query->get( 'tax_query', [] );
729 $taxonomies_queried = array_merge(
730 array_column( $tax_query, 'taxonomy' ),
731 array_keys( $query->query_vars )
732 );
733 if ( ! empty( array_intersect( $supported_taxonomies, $taxonomies_queried ) ) ) {
734 return true;
735 }
736
737 /**
738 * Check the post type
739 */
740 $supported_post_types = $this->get_supported_post_types( $query );
741 $post_type = $query->get( 'post_type', false );
742 if ( ! empty( $post_type ) && ( in_array( $post_type, $supported_post_types, true ) || ( is_array( $post_type ) && ! array_diff( $post_type, $supported_post_types ) ) ) ) {
743 return true;
744 }
745
746 return false;
747 }
748
749 /**
750 * Get the WooCommerce supported taxonomies (related to products.)
751 *
752 * @return array
753 */
754 public function get_supported_taxonomies(): array {
755 $supported_taxonomies = array(
756 'product_cat',
757 'product_tag',
758 'product_type',
759 'product_visibility',
760 'product_shipping_class',
761 );
762
763 // Add in any attribute taxonomies that exist
764 $attribute_taxonomies = wc_get_attribute_taxonomy_names();
765
766 $supported_taxonomies = array_merge( $supported_taxonomies, $attribute_taxonomies );
767
768 /**
769 * DEPRECATED. Filter supported custom taxonomies for WooCommerce integration.
770 *
771 * @param {array} $supported_taxonomies An array of default taxonomies.
772 * @hook ep_woocommerce_supported_taxonomies
773 * @since 2.3.0
774 * @return {array} New taxonomies
775 */
776 $supported_taxonomies = apply_filters_deprecated(
777 'ep_woocommerce_supported_taxonomies',
778 [ $supported_taxonomies ],
779 '4.7.0',
780 'ep_woocommerce_products_supported_taxonomies'
781 );
782
783 /**
784 * Filter supported custom taxonomies for WooCommerce product queries integration
785 *
786 * @param {array} $supported_taxonomies An array of default taxonomies.
787 * @hook ep_woocommerce_products_supported_taxonomies
788 * @since 4.7.0
789 * @return {array} New taxonomies
790 */
791 return apply_filters( 'ep_woocommerce_products_supported_taxonomies', $supported_taxonomies );
792 }
793
794 /**
795 * Get the WooCommerce supported post types (related to products.)
796 *
797 * @param \WP_Query $query The WP_Query object
798 * @return array
799 */
800 public function get_supported_post_types( \WP_Query $query ): array {
801 $post_types = [ 'product_variation' ];
802
803 $is_main_post_type_archive = $query->is_main_query() && $query->is_post_type_archive( 'product' );
804 $has_ep_integrate_set_true = isset( $query->query_vars['ep_integrate'] ) && filter_var( $query->query_vars['ep_integrate'], FILTER_VALIDATE_BOOLEAN );
805 if ( $is_main_post_type_archive || $has_ep_integrate_set_true ) {
806 $post_types[] = 'product';
807 }
808
809 /**
810 * DEPRECATED. Expands or contracts the post_types eligible for indexing.
811 *
812 * @hook ep_woocommerce_default_supported_post_types
813 * @since 4.4.0
814 * @param {array} $post_types Post types
815 * @return {array} New post types
816 */
817 $supported_post_types = apply_filters_deprecated(
818 'ep_woocommerce_default_supported_post_types',
819 [ $post_types ],
820 '4.7.0',
821 'ep_woocommerce_products_supported_post_types'
822 );
823
824 /**
825 * Expands or contracts the post_types related to products eligible for indexing.
826 *
827 * @hook ep_woocommerce_products_supported_post_types
828 * @since 4.7.0
829 * @param {array} $supported_post_types Post types
830 * @param {WP_Query} $query The WP_Query object
831 * @return {array} New post types
832 */
833 $supported_post_types = apply_filters( 'ep_woocommerce_products_supported_post_types', $supported_post_types, $query );
834
835 $supported_post_types = array_intersect(
836 $supported_post_types,
837 Indexables::factory()->get( 'post' )->get_indexable_post_types()
838 );
839
840 return $supported_post_types;
841 }
842
843 /**
844 * If needed, update the `'tax_query'` parameter
845 *
846 * If a supported taxonomy was added in the root of the args array,
847 * this method moves it to the `'tax_query'`
848 *
849 * @param \WP_Query $query The WP_Query object
850 */
851 protected function maybe_update_tax_query( \WP_Query $query ) {
852 $supported_taxonomies = $this->get_supported_taxonomies();
853 $tax_query = $query->get( 'tax_query', [] );
854
855 foreach ( $supported_taxonomies as $taxonomy ) {
856 $term = $query->get( $taxonomy, false );
857
858 if ( ! empty( $term ) ) {
859 $tax_query[] = array(
860 'taxonomy' => $taxonomy,
861 'field' => 'slug',
862 'terms' => (array) $term,
863 );
864 }
865 }
866
867 /*
868 * If the site is set to use the product attributes lookup table for catalog filtering
869 * we need to add filters back to WP_Query, so EP can handle the filtering.
870 */
871 $filterer_class = 'Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer';
872 if (
873 function_exists( 'wc_get_container' ) &&
874 class_exists( $filterer_class ) &&
875 method_exists( 'WC_Query', 'get_layered_nav_chosen_attributes' )
876 ) {
877 $filterer = wc_get_container()->get( $filterer_class );
878
879 if ( $filterer->filtering_via_lookup_table_is_active() ) {
880 foreach ( \WC_Query::get_layered_nav_chosen_attributes() as $taxonomy => $data ) {
881 $tax_query[] = array(
882 'taxonomy' => $taxonomy,
883 'field' => 'slug',
884 'terms' => $data['terms'],
885 'operator' => 'and' === $data['query_type'] ? 'AND' : 'IN',
886 'include_children' => false,
887 );
888 }
889 }
890 }
891
892 $query->set( 'tax_query', $tax_query );
893 }
894
895 /**
896 * Set the post_type to product if empty
897 *
898 * @param \WP_Query $query The WP_Query object
899 */
900 protected function maybe_update_post_type( \WP_Query $query ) {
901 $post_type = $query->get( 'post_type', false );
902
903 if ( empty( $post_type ) ) {
904 $query->set( 'post_type', 'product' );
905 }
906 }
907
908 /**
909 * If the `'meta_key'` or `'meta_value'` parameters were set,
910 * move them to `'meta_query'`
911 *
912 * @param \WP_Query $query The WP_Query object
913 */
914 protected function maybe_update_meta_query( \WP_Query $query ) {
915 /**
916 * Handle meta queries
917 */
918 $meta_query = $query->get( 'meta_query', [] );
919 $meta_key = $query->get( 'meta_key', false );
920 $meta_value = $query->get( 'meta_value', false );
921
922 if ( ! empty( $meta_key ) && ! empty( $meta_value ) ) {
923 $meta_query[] = array(
924 'key' => $meta_key,
925 'value' => $meta_value,
926 );
927
928 $query->set( 'meta_query', $meta_query );
929 }
930 }
931
932 /**
933 * Handle the WC Top Rated Widget
934 *
935 * @param \WP_Query $query The WP_Query object
936 * @return void
937 */
938 protected function maybe_handle_top_rated( \WP_Query $query ) {
939 if ( ! has_filter( 'posts_clauses', array( WC()->query, 'order_by_rating_post_clauses' ) ) ) {
940 return;
941 }
942
943 remove_filter( 'posts_clauses', array( WC()->query, 'order_by_rating_post_clauses' ) );
944 $query->set( 'orderby', 'meta_value_num' );
945 $query->set( 'meta_key', '_wc_average_rating' );
946 }
947
948 /**
949 * If the query has a search term and the weighting dashboard is not
950 * available, add the needed fields
951 *
952 * @param \WP_Query $query The WP_Query
953 * @return \WP_Query
954 */
955 protected function maybe_set_search_fields( \WP_Query $query ) {
956 $search_term = $this->woocommerce->get_search_term( $query );
957 if ( empty( $search_term ) ) {
958 return $query;
959 }
960
961 $post_type = $query->get( 'post_type', false );
962 if ( 'product' !== $post_type || ! defined( 'EP_IS_NETWORK' ) || ! EP_IS_NETWORK ) {
963 return;
964 }
965
966 $search_fields = $query->get( 'search_fields', array( 'post_title', 'post_content', 'post_excerpt' ) );
967
968 // Remove author_name from this search.
969 $search_fields = $this->remove_author( $search_fields );
970
971 $search_fields['meta'] = ( ! empty( $search_fields['meta'] ) ) ? $search_fields['meta'] : [];
972 $search_fields['taxonomies'] = ( ! empty( $search_fields['taxonomies'] ) ) ? $search_fields['taxonomies'] : [];
973
974 $search_fields['meta'] = array_merge( $search_fields['meta'], array( '_sku' ) );
975 $search_fields['taxonomies'] = array_merge( $search_fields['taxonomies'], array( 'category', 'post_tag', 'product_tag', 'product_cat' ) );
976
977 $query->set( 'search_fields', $search_fields );
978 }
979
980 /**
981 * Remove the author_name from search fields.
982 *
983 * @param array $search_fields Array of search fields.
984 * @return array
985 */
986 public function remove_author( array $search_fields ): array {
987 foreach ( $search_fields as $field_key => $field ) {
988 if ( 'author_name' === $field ) {
989 unset( $search_fields[ $field_key ] );
990 }
991 }
992
993 return $search_fields;
994 }
995
996 /**
997 * If needed, set the `'order'` and `'orderby'` parameters
998 *
999 * @param \WP_Query $query The WP_Query object
1000 */
1001 protected function maybe_set_orderby( \WP_Query $query ) {
1002 $search_term = $this->woocommerce->get_search_term( $query );
1003
1004 if ( empty( $search_term ) ) {
1005 /**
1006 * For default sorting by popularity (total_sales) and rating
1007 * WooCommerce doesn't set the orderby correctly.
1008 * These lines will check the meta_key and correct the orderby based on that.
1009 * And this won't run in search result and only run in main query
1010 */
1011 $meta_key = $query->get( 'meta_key', false );
1012 if ( $meta_key && $query->is_main_query() ) {
1013 switch ( $meta_key ) {
1014 case 'total_sales':
1015 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
1016 $query->set( 'order', 'DESC' );
1017 break;
1018 case '_wc_average_rating':
1019 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_wc_average_rating' ) );
1020 $query->set( 'order', 'DESC' );
1021 break;
1022 }
1023 }
1024 }
1025
1026 /**
1027 * Set orderby and order for price/popularity when GET param not set
1028 */
1029 $orderby = $query->get( 'orderby', null );
1030 if ( $orderby && in_array( $orderby, [ 'price', 'popularity' ], true ) ) {
1031 switch ( $orderby ) {
1032 case 'price':
1033 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
1034 $query->set( 'order', $query->get( 'order' ) );
1035 break;
1036 case 'popularity':
1037 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
1038 $query->set( 'order', 'DESC' );
1039 break;
1040 }
1041 }
1042
1043 /**
1044 * Set orderby from GET param
1045 * Also make sure the orderby param affects only the main query
1046 */
1047 if ( ! empty( $_GET['orderby'] ) && $query->is_main_query() ) { // phpcs:ignore WordPress.Security.NonceVerification
1048 $orderby = sanitize_text_field( wp_unslash( $_GET['orderby'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
1049 switch ( $orderby ) {
1050 case 'popularity':
1051 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
1052 $query->set( 'order', 'DESC' );
1053 break;
1054 case 'price':
1055 $query->set( 'order', $query->get( 'order', 'ASC' ) );
1056 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
1057 break;
1058 case 'price-desc':
1059 $query->set( 'order', 'DESC' );
1060 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
1061 break;
1062 case 'rating':
1063 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_wc_average_rating' ) );
1064 $query->set( 'order', 'DESC' );
1065 break;
1066 case 'date':
1067 case 'title':
1068 case 'ID':
1069 $query->set( 'orderby', $this->get_orderby_meta_mapping( $orderby ) );
1070 break;
1071 case 'sku':
1072 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_sku' ) );
1073 break;
1074 default:
1075 $query->set( 'orderby', $this->get_orderby_meta_mapping( $orderby ) );
1076 }
1077 }
1078 }
1079
1080 /**
1081 * Fetch the ES related meta mapping for orderby
1082 *
1083 * @param array $meta_key The meta key to get the mapping for.
1084 * @return string|array The mapped meta key.
1085 */
1086 public function get_orderby_meta_mapping( $meta_key ) {
1087 /**
1088 * Filter WooCommerce to Elasticsearch meta mapping
1089 *
1090 * @hook orderby_meta_mapping
1091 * @param {array} $mapping Meta mapping
1092 * @return {array} New mapping
1093 */
1094 $mapping = apply_filters(
1095 'orderby_meta_mapping',
1096 array(
1097 'ID' => 'ID',
1098 'title' => 'title date',
1099 'menu_order' => 'menu_order title date',
1100 'menu_order title' => 'menu_order title date',
1101 'total_sales' => 'meta.total_sales.double date',
1102 '_wc_average_rating' => 'meta._wc_average_rating.double date',
1103 '_price' => 'meta._price.double date',
1104 '_sku' => 'meta._sku.value.sortable date',
1105 'date' => 'date',
1106 )
1107 );
1108
1109 return isset( $mapping[ $meta_key ] ) ? $mapping[ $meta_key ] : $mapping['menu_order'];
1110 }
1111
1112 /**
1113 * Add taxonomies that should be woocommerce attributes.
1114 *
1115 * @param array $attribute_taxonomies Attribute taxonomies.
1116 * @return array $attribute_taxonomies Attribute taxonomies.
1117 */
1118 public function add_taxonomy_attributes( array $attribute_taxonomies ): array {
1119 $all_attr_taxonomies = wc_get_attribute_taxonomies();
1120
1121 foreach ( $all_attr_taxonomies as $attr_taxonomy ) {
1122 $attribute_taxonomies[ $attr_taxonomy->attribute_name ] = wc_attribute_taxonomy_name( $attr_taxonomy->attribute_name );
1123 }
1124 return $attribute_taxonomies;
1125 }
1126
1127 /**
1128 * Add weight by date settings related to WooCommerce
1129 *
1130 * @since 5.0.0
1131 * @param array $settings_schema Settings schema
1132 * @param string $feature_slug Feature slug
1133 * @return array New settings schema
1134 */
1135 public function add_weight_settings_search_schema( $settings_schema, $feature_slug ) {
1136 if ( 'search' !== $feature_slug ) {
1137 return $settings_schema;
1138 }
1139
1140 foreach ( $settings_schema as &$setting_schema ) {
1141 if ( 'decaying_enabled' !== $setting_schema['key'] ) {
1142 continue;
1143 }
1144
1145 $setting_schema['options'] = array_merge(
1146 $setting_schema['options'],
1147 [
1148 [
1149 'label' => __( 'Weight results by date, except for product-only queries', 'elasticpress' ),
1150 'value' => 'disabled_only_products',
1151 ],
1152 [
1153 'label' => __( 'Weight results by date, except for any query that includes products', 'elasticpress' ),
1154 'value' => 'disabled_includes_products',
1155 ],
1156 ]
1157 );
1158 }
1159
1160 return $settings_schema;
1161 }
1162 }
1163