PluginProbe
ElasticPress / 4.2.0
ElasticPress v4.2.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 / WooCommerce / WooCommerce.php

WooCommerce.php in ElasticPress 4.2.0, at includes/classes/Feature/WooCommerce/WooCommerce.php

1,148 lines 34.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ElasticPress WooCommerce feature
4 *
5 * @since 2.1
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Feature\WooCommerce;
10
11 use ElasticPress\Feature as Feature;
12 use ElasticPress\FeatureRequirementsStatus as FeatureRequirementsStatus;
13 use ElasticPress\Indexables as Indexables;
14 use ElasticPress\Utils as Utils;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * WooCommerce feature class
22 */
23 class WooCommerce extends Feature {
24 /**
25 * Initialize feature setting it's config
26 *
27 * @since 3.0
28 */
29 public function __construct() {
30 $this->slug = 'woocommerce';
31
32 $this->title = esc_html__( 'WooCommerce', 'elasticpress' );
33
34 $this->summary = __( '“I want a cotton, woman’s t-shirt, for under $15 that’s in stock.” Faceted product browsing strains servers and increases load times. Your buyers can find the perfect product quickly, and buy it quickly.', 'elasticpress' );
35
36 $this->docs_url = __( 'https://elasticpress.zendesk.com/hc/en-us/articles/360050447492-Configuring-ElasticPress-via-the-Plugin-Dashboard#woocommerce', 'elasticpress' );
37
38 $this->requires_install_reindex = true;
39
40 $this->available_during_installation = true;
41
42 parent::__construct();
43 }
44
45 /**
46 * Index Woocommerce meta
47 *
48 * @param array $meta Existing post meta.
49 * @param array $post Post arguments array.
50 * @since 2.1
51 * @return array
52 */
53 public function whitelist_meta_keys( $meta, $post ) {
54 return array_unique(
55 array_merge(
56 $meta,
57 array(
58 '_thumbnail_id',
59 '_product_attributes',
60 '_wpb_vc_js_status',
61 '_swatch_type',
62 'total_sales',
63 '_downloadable',
64 '_virtual',
65 '_regular_price',
66 '_sale_price',
67 '_tax_status',
68 '_tax_class',
69 '_purchase_note',
70 '_featured',
71 '_weight',
72 '_length',
73 '_width',
74 '_height',
75 '_visibility',
76 '_sku',
77 '_sale_price_dates_from',
78 '_sale_price_dates_to',
79 '_price',
80 '_sold_individually',
81 '_manage_stock',
82 '_backorders',
83 '_stock',
84 '_upsell_ids',
85 '_crosssell_ids',
86 '_stock_status',
87 '_product_version',
88 '_product_tabs',
89 '_override_tab_layout',
90 '_suggested_price',
91 '_min_price',
92 '_customer_user',
93 '_variable_billing',
94 '_wc_average_rating',
95 '_product_image_gallery',
96 '_bj_lazy_load_skip_post',
97 '_min_variation_price',
98 '_max_variation_price',
99 '_min_price_variation_id',
100 '_max_price_variation_id',
101 '_min_variation_regular_price',
102 '_max_variation_regular_price',
103 '_min_regular_price_variation_id',
104 '_max_regular_price_variation_id',
105 '_min_variation_sale_price',
106 '_max_variation_sale_price',
107 '_min_sale_price_variation_id',
108 '_max_sale_price_variation_id',
109 '_default_attributes',
110 '_swatch_type_options',
111 '_order_key',
112 '_billing_company',
113 '_billing_address_1',
114 '_billing_address_2',
115 '_billing_city',
116 '_billing_postcode',
117 '_billing_country',
118 '_billing_state',
119 '_billing_email',
120 '_billing_phone',
121 '_shipping_address_1',
122 '_shipping_address_2',
123 '_shipping_city',
124 '_shipping_postcode',
125 '_shipping_country',
126 '_shipping_state',
127 '_billing_last_name',
128 '_billing_first_name',
129 '_shipping_first_name',
130 '_shipping_last_name',
131 '_variations_skus',
132 )
133 )
134 );
135 }
136
137 /**
138 * Make sure all loop shop post ins are IDS. We have to pass post objects here since we override
139 * the fields=>id query for the layered filter nav query
140 *
141 * @param array $posts Post object array.
142 * @since 2.1
143 * @return array
144 */
145 public function convert_post_object_to_id( $posts ) {
146 $new_posts = [];
147
148 foreach ( $posts as $post ) {
149 if ( is_object( $post ) ) {
150 $new_posts[] = $post->ID;
151 } else {
152 $new_posts[] = $post;
153 }
154 }
155
156 return $new_posts;
157 }
158
159 /**
160 * Index Woocommerce taxonomies
161 *
162 * @param array $taxonomies Index taxonomies array.
163 * @param array $post Post properties array.
164 * @since 2.1
165 * @return array
166 */
167 public function whitelist_taxonomies( $taxonomies, $post ) {
168 $woo_taxonomies = [];
169
170 $product_type = get_taxonomy( 'product_type' );
171 if ( false !== $product_type ) {
172 $woo_taxonomies[] = $product_type;
173 }
174
175 $product_visibility = get_taxonomy( 'product_visibility' );
176 if ( false !== $product_visibility ) {
177 $woo_taxonomies[] = $product_visibility;
178 }
179
180 /**
181 * Note product_shipping_class, product_cat, and product_tag are already public. Make
182 * sure to index non-attribute taxonomies.
183 */
184 $attribute_taxonomies = wc_get_attribute_taxonomies();
185
186 if ( ! empty( $attribute_taxonomies ) ) {
187 foreach ( $attribute_taxonomies as $tax ) {
188 $name = wc_attribute_taxonomy_name( $tax->attribute_name );
189
190 if ( ! empty( $name ) ) {
191 if ( empty( $tax->attribute_ ) ) {
192 $woo_taxonomies[] = get_taxonomy( $name );
193 }
194 }
195 }
196 }
197
198 return array_merge( $taxonomies, $woo_taxonomies );
199 }
200
201 /**
202 * Disallow duplicated ES queries on Orders page.
203 *
204 * @since 2.4
205 *
206 * @param array $value Original filter values.
207 * @param WP_Query $query WP_Query
208 *
209 * @return array
210 */
211 public function disallow_duplicated_query( $value, $query ) {
212 global $pagenow;
213
214 /**
215 * Make sure we're on edit.php in admin dashboard.
216 */
217 if ( 'edit.php' !== $pagenow || ! is_admin() || 'shop_order' !== $query->get( 'post_type' ) ) {
218 return $value;
219 }
220
221 /**
222 * Check if EP API request was already done. If request was sent return its results.
223 */
224 if ( isset( $query->elasticsearch_success ) && $query->elasticsearch_success ) {
225 return $query->posts;
226 }
227
228 return $value;
229
230 }
231
232 /**
233 * Translate args to ElasticPress compat format. This is the meat of what the feature does
234 *
235 * @param WP_Query $query WP Query
236 * @since 2.1
237 */
238 public function translate_args( $query ) {
239 if ( ! $this->should_integrate_with_query( $query ) ) {
240 return;
241 }
242
243 // Flag to check and make sure we are in a WooCommerce specific query
244 $integrate = false;
245
246 /**
247 * Force ElasticPress if we are querying WC taxonomy
248 */
249 $tax_query = $query->get( 'tax_query', [] );
250
251 $supported_taxonomies = array(
252 'product_cat',
253 'product_tag',
254 'product_type',
255 'product_visibility',
256 'product_shipping_class',
257 );
258
259 // Add in any attribute taxonomies that exist
260 $attribute_taxonomies = wc_get_attribute_taxonomy_names();
261
262 $supported_taxonomies = array_merge( $supported_taxonomies, $attribute_taxonomies );
263
264 /**
265 * Filter supported custom taxonomies for WooCommerce integration
266 *
267 * @param {array} $supported_taxonomies An array of default taxonomies.
268 * @hook ep_woocommerce_supported_taxonomies
269 * @since 2.3.0
270 * @return {array} New taxonomies
271 */
272 $supported_taxonomies = apply_filters( 'ep_woocommerce_supported_taxonomies', $supported_taxonomies );
273
274 if ( ! empty( $tax_query ) ) {
275
276 /**
277 * First check if already set taxonomies are supported WC taxes
278 */
279 foreach ( $tax_query as $taxonomy_array ) {
280 if ( isset( $taxonomy_array['taxonomy'] ) && in_array( $taxonomy_array['taxonomy'], $supported_taxonomies, true ) ) {
281 $integrate = true;
282 }
283 }
284 }
285
286 /**
287 * Next check if any taxonomies are in the root of query vars (shorthand form)
288 */
289 foreach ( $supported_taxonomies as $taxonomy ) {
290 $term = $query->get( $taxonomy, false );
291
292 if ( ! empty( $term ) ) {
293 $integrate = true;
294
295 $tax_query[] = array(
296 'taxonomy' => $taxonomy,
297 'field' => 'slug',
298 'terms' => (array) $term,
299 );
300 }
301 }
302
303 /**
304 * Force ElasticPress if product post type query
305 */
306 $post_type = $query->get( 'post_type', false );
307
308 // Act only on a defined subset of all indexable post types here
309 $supported_post_types = array_intersect(
310 array(
311 'product',
312 'shop_order',
313 'shop_order_refund',
314 'product_variation',
315 ),
316 Indexables::factory()->get( 'post' )->get_indexable_post_types()
317 );
318
319 // For orders it queries an array of shop_order and shop_order_refund post types, hence an array_diff
320 if ( ! empty( $post_type ) && ( in_array( $post_type, $supported_post_types, true ) || ( is_array( $post_type ) && ! array_diff( $post_type, $supported_post_types ) ) ) ) {
321 $integrate = true;
322 }
323
324 /**
325 * If we have a WooCommerce specific query, lets hook it to ElasticPress and make the query ElasticSearch friendly
326 */
327 if ( ! $integrate ) {
328 return;
329 }
330
331 // Set tax_query again since we may have added things
332 $query->set( 'tax_query', $tax_query );
333
334 // Default to product if no post type is set
335 if ( empty( $post_type ) ) {
336 $post_type = 'product';
337 $query->set( 'post_type', 'product' );
338 }
339
340 // Handles the WC Top Rated Widget
341 if ( has_filter( 'posts_clauses', array( WC()->query, 'order_by_rating_post_clauses' ) ) ) {
342 remove_filter( 'posts_clauses', array( WC()->query, 'order_by_rating_post_clauses' ) );
343 $query->set( 'orderby', 'meta_value_num' );
344 $query->set( 'meta_key', '_wc_average_rating' );
345 }
346
347 /**
348 * WordPress have to be version 4.6 or newer to have "fields" support
349 * since it requires the "posts_pre_query" filter.
350 *
351 * @see WP_Query::get_posts
352 */
353 $fields = $query->get( 'fields', false );
354 if ( ! version_compare( get_bloginfo( 'version' ), '4.6', '>=' ) && ( 'ids' === $fields || 'id=>parent' === $fields ) ) {
355 $query->set( 'fields', 'default' );
356 }
357
358 /**
359 * Handle meta queries
360 */
361 $meta_query = $query->get( 'meta_query', [] );
362 $meta_key = $query->get( 'meta_key', false );
363 $meta_value = $query->get( 'meta_value', false );
364
365 if ( ! empty( $meta_key ) && ! empty( $meta_value ) ) {
366 $meta_query[] = array(
367 'key' => $meta_key,
368 'value' => $meta_value,
369 );
370
371 $query->set( 'meta_query', $meta_query );
372 }
373
374 /**
375 * Make sure filters are suppressed
376 */
377 $query->query['suppress_filters'] = false;
378 $query->set( 'suppress_filters', false );
379
380 // Integrate with WooCommerce custom searches as well
381 $search = $query->get( 'search' );
382 if ( ! empty( $search ) ) {
383 $s = $search;
384 $query->set( 's', $s );
385 } else {
386 $s = $query->get( 's' );
387 }
388
389 $query->query_vars['ep_integrate'] = true;
390 $query->query['ep_integrate'] = true;
391
392 if ( ! empty( $s ) ) {
393 // Search query
394 if ( 'shop_order' === $post_type ) {
395 $default_search_fields = array( 'post_title', 'post_content', 'post_excerpt' );
396 if ( ctype_digit( $s ) ) {
397 $default_search_fields[] = 'ID';
398 }
399 $search_fields = $query->get( 'search_fields', $default_search_fields );
400
401 $search_fields['meta'] = array_map(
402 'wc_clean',
403 /**
404 * Filter shop order meta fields to search for WooCommerce
405 *
406 * @hook shop_order_search_fields
407 * @param {array} $fields Shop order fields
408 * @return {array} New fields
409 */
410 apply_filters(
411 'shop_order_search_fields',
412 array(
413 '_order_key',
414 '_billing_company',
415 '_billing_address_1',
416 '_billing_address_2',
417 '_billing_city',
418 '_billing_postcode',
419 '_billing_country',
420 '_billing_state',
421 '_billing_email',
422 '_billing_phone',
423 '_shipping_address_1',
424 '_shipping_address_2',
425 '_shipping_city',
426 '_shipping_postcode',
427 '_shipping_country',
428 '_shipping_state',
429 '_billing_last_name',
430 '_billing_first_name',
431 '_shipping_first_name',
432 '_shipping_last_name',
433 '_items',
434 )
435 )
436 );
437
438 $query->set(
439 'search_fields',
440 /**
441 * Filter all the shop order fields to search for WooCommerce
442 *
443 * @hook ep_woocommerce_shop_order_search_fields
444 * @since 4.0.0
445 * @param {array} $fields Shop order fields
446 * @param {WP_Query} $query WP Query
447 * @return {array} New fields
448 */
449 apply_filters( 'ep_woocommerce_shop_order_search_fields', $search_fields, $query )
450 );
451 } elseif ( 'product' === $post_type && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
452 $search_fields = $query->get( 'search_fields', array( 'post_title', 'post_content', 'post_excerpt' ) );
453
454 // Remove author_name from this search.
455 $search_fields = $this->remove_author( $search_fields );
456
457 foreach ( $search_fields as $field_key => $field ) {
458 if ( 'author_name' === $field ) {
459 unset( $search_fields[ $field_key ] );
460 }
461 }
462
463 $search_fields['meta'] = ( ! empty( $search_fields['meta'] ) ) ? $search_fields['meta'] : [];
464 $search_fields['taxonomies'] = ( ! empty( $search_fields['taxonomies'] ) ) ? $search_fields['taxonomies'] : [];
465
466 $search_fields['meta'] = array_merge( $search_fields['meta'], array( '_sku' ) );
467 $search_fields['taxonomies'] = array_merge( $search_fields['taxonomies'], array( 'category', 'post_tag', 'product_tag', 'product_cat' ) );
468
469 $query->set( 'search_fields', $search_fields );
470 }
471 } else {
472 /**
473 * For default sorting by popularity (total_sales) and rating
474 * Woocommerce doesn't set the orderby correctly.
475 * These lines will check the meta_key and correct the orderby based on that.
476 * And this won't run in search result and only run in main query
477 */
478 $meta_key = $query->get( 'meta_key', false );
479 if ( $meta_key && $query->is_main_query() ) {
480 switch ( $meta_key ) {
481 case 'total_sales':
482 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
483 $query->set( 'order', 'DESC' );
484 break;
485 case '_wc_average_rating':
486 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_wc_average_rating' ) );
487 $query->set( 'order', 'DESC' );
488 break;
489 }
490 }
491 }
492
493 /**
494 * Set orderby and order for price/popularity when GET param not set
495 */
496 if ( isset( $query->query_vars['orderby'], $query->query_vars['order'] ) && $query->is_main_query() ) {
497 switch ( $query->query_vars['orderby'] ) {
498 case 'price':
499 $query->set( 'order', $query->query_vars['order'] );
500 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
501 break;
502 case 'popularity':
503 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
504 $query->set( 'order', 'DESC' );
505 break;
506 }
507 }
508
509 /**
510 * Set orderby from GET param
511 * Also make sure the orderby param affects only the main query
512 */
513 if ( ! empty( $_GET['orderby'] ) && $query->is_main_query() ) { // phpcs:ignore WordPress.Security.NonceVerification
514 $orderby = sanitize_text_field( $_GET['orderby'] ); // phpcs:ignore WordPress.Security.NonceVerification
515 switch ( $orderby ) { // phpcs:ignore WordPress.Security.NonceVerification
516 case 'popularity':
517 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
518 $query->set( 'order', 'DESC' );
519 break;
520 case 'price':
521 $query->set( 'order', $query->get( 'order', 'ASC' ) );
522 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
523 break;
524 case 'price-desc':
525 $query->set( 'order', 'DESC' );
526 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
527 break;
528 case 'rating':
529 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_wc_average_rating' ) );
530 $query->set( 'order', 'DESC' );
531 break;
532 case 'date':
533 case 'title':
534 case 'ID':
535 $query->set( 'orderby', $this->get_orderby_meta_mapping( $orderby ) );
536 break;
537 case 'sku':
538 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_sku' ) );
539 break;
540 default:
541 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'menu_order' ) ); // Order by menu and title.
542 }
543 }
544 }
545
546 /**
547 * Fetch the ES related meta mapping for orderby
548 *
549 * @param array $meta_key The meta key to get the mapping for.
550 * @since 2.1
551 * @return string The mapped meta key.
552 */
553 public function get_orderby_meta_mapping( $meta_key ) {
554 /**
555 * Filter WooCommerce to Elasticsearch meta mapping
556 *
557 * @hook orderby_meta_mapping
558 * @param {array} $mapping Meta mapping
559 * @return {array} New mapping
560 */
561 $mapping = apply_filters(
562 'orderby_meta_mapping',
563 array(
564 'ID' => 'ID',
565 'title' => 'title date',
566 'menu_order' => 'menu_order title date',
567 'menu_order title' => 'menu_order title date',
568 'total_sales' => 'meta.total_sales.double date',
569 '_wc_average_rating' => 'meta._wc_average_rating.double date',
570 '_price' => 'meta._price.double date',
571 '_sku' => 'meta._sku.value.sortable date',
572 )
573 );
574
575 if ( isset( $mapping[ $meta_key ] ) ) {
576 return $mapping[ $meta_key ];
577 }
578
579 return 'date';
580 }
581
582 /**
583 * Make search coupons don't go through ES
584 *
585 * @param bool $enabled Coupons enabled or not
586 * @param WP_Query $query WP Query
587 * @since 2.1
588 * @return bool
589 */
590 public function blacklist_coupons( $enabled, $query ) {
591 if ( method_exists( $query, 'get' ) && 'shop_coupon' === $query->get( 'post_type' ) ) {
592 return false;
593 }
594
595 return $enabled;
596 }
597
598 /**
599 * Allow order creations on the front end to get synced
600 *
601 * @since 2.1
602 * @param bool $override Original order perms check value
603 * @param int $post_id Post ID
604 * @return bool
605 */
606 public function bypass_order_permissions_check( $override, $post_id ) {
607 if ( 'shop_order' === get_post_type( $post_id ) ) {
608 return true;
609 }
610
611 return $override;
612 }
613
614 /**
615 * Sets woocommerce meta search fields to an empty array if we are integrating the main query with ElasticSearch
616 *
617 * Woocommerce calls this action as part of its own callback on parse_query. We add this filter only if the query
618 * is integrated with ElasticSearch.
619 * If we were to always return array() on this filter, we'd break admin searches when WooCommerce module is activated
620 * without the Protected Content Module
621 *
622 * @param \WP_Query $query Current query
623 */
624 public function maybe_hook_woocommerce_search_fields( $query ) {
625 global $pagenow, $wp, $wc_list_table;
626
627 if ( ! $this->should_integrate_with_query( $query ) ) {
628 return;
629 }
630
631 if ( 'edit.php' !== $pagenow || empty( $wp->query_vars['s'] ) || 'shop_order' !== $wp->query_vars['post_type'] || ! isset( $_GET['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
632 return;
633 }
634
635 remove_action( 'parse_query', [ $wc_list_table, 'search_custom_fields' ] );
636 }
637
638 /**
639 * Enhance WooCommerce search order by order id, email, phone number, name, etc..
640 * What this function does:
641 * 1. Reverse the woocommerce shop_order_search_custom_fields query
642 * 2. If the search key is integer and it is an Order Id, just query with post__in
643 * 3. If the search key is integer but not an order id ( might be phone number ), use ES to find it
644 *
645 * @param WP_Query $wp WP Query
646 * @since 2.3
647 */
648 public function search_order( $wp ) {
649 if ( ! $this->should_integrate_with_query( $wp ) ) {
650 return;
651 }
652
653 global $pagenow;
654 if ( 'edit.php' !== $pagenow || empty( $wp->query_vars['post_type'] ) || 'shop_order' !== $wp->query_vars['post_type'] ||
655 ( empty( $wp->query_vars['s'] ) && empty( $wp->query_vars['shop_order_search'] ) ) ) {
656 return;
657 }
658
659 $search_key_safe = str_replace( array( 'Order #', '#' ), '', wc_clean( $_GET['s'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
660 unset( $wp->query_vars['post__in'] );
661 $wp->query_vars['s'] = $search_key_safe;
662 }
663
664 /**
665 * Add order items as a searchable string.
666 *
667 * This mimics how WooCommerce currently does in the order_itemmeta
668 * table. They combine the titles of the products and put them in a
669 * meta field called "Items".
670 *
671 * @since 2.4
672 *
673 * @param array $post_args Post arguments
674 * @param string|int $post_id Post id
675 *
676 * @return array
677 */
678 public function add_order_items_search( $post_args, $post_id ) {
679 // Make sure it is only WooCommerce orders we touch.
680 if ( 'shop_order' !== $post_args['post_type'] ) {
681 return $post_args;
682 }
683
684 $post_indexable = Indexables::factory()->get( 'post' );
685
686 // Get order items.
687 $order = wc_get_order( $post_id );
688 $item_meta = [];
689 foreach ( $order->get_items() as $delta => $product_item ) {
690 // WooCommerce 3.x uses WC_Order_Item_Product instance while 2.x an array
691 if ( is_object( $product_item ) && method_exists( $product_item, 'get_name' ) ) {
692 $item_meta['_items'][] = $product_item->get_name( 'edit' );
693 } elseif ( is_array( $product_item ) && isset( $product_item['name'] ) ) {
694 $item_meta['_items'][] = $product_item['name'];
695 }
696 }
697
698 // Prepare order items.
699 $item_meta['_items'] = empty( $item_meta['_items'] ) ? '' : implode( '|', $item_meta['_items'] );
700 $post_args['meta'] = array_merge( $post_args['meta'], $post_indexable->prepare_meta_types( $item_meta ) );
701
702 return $post_args;
703 }
704
705 /**
706 * Add WooCommerce Product Attributes to EP Facets.
707 *
708 * @param array $taxonomies Taxonomies array
709 * @return array
710 */
711 public function add_product_attributes( $taxonomies = [] ) {
712 $attribute_names = wc_get_attribute_taxonomy_names();
713
714 foreach ( $attribute_names as $name ) {
715 if ( ! taxonomy_exists( $name ) ) {
716 continue;
717 }
718 $taxonomies[ $name ] = get_taxonomy( $name );
719 }
720
721 return $taxonomies;
722 }
723
724 /**
725 * Add WooCommerce Fields to the Weighting Dashboard.
726 *
727 * @since 3.x
728 *
729 * @param array $fields Current weighting fields.
730 * @param string $post_type Current post type.
731 * @return array New fields.
732 */
733 public function add_product_attributes_to_weighting( $fields, $post_type ) {
734 if ( 'product' === $post_type ) {
735 if ( ! empty( $fields['attributes']['children']['author_name'] ) ) {
736 unset( $fields['attributes']['children']['author_name'] );
737 }
738
739 $sku_key = 'meta._sku.value';
740
741 $fields['attributes']['children'][ $sku_key ] = array(
742 'key' => $sku_key,
743 'label' => __( 'SKU', 'elasticpress' ),
744 );
745 }
746 return $fields;
747 }
748
749 /**
750 * Add WooCommerce Fields to the default values of the Weighting Dashboard.
751 *
752 * @since 3.x
753 *
754 * @param array $defaults Default values for the post type.
755 * @param string $post_type Current post type.
756 * @return array
757 */
758 public function add_product_default_post_type_weights( $defaults, $post_type ) {
759 if ( 'product' === $post_type ) {
760 if ( ! empty( $defaults['author_name'] ) ) {
761 unset( $defaults['author_name'] );
762 }
763
764 $defaults['meta._sku.value'] = array(
765 'enabled' => true,
766 'weight' => 1,
767 );
768 }
769 return $defaults;
770 }
771
772 /**
773 * Add WC post type to autosuggest
774 *
775 * @param array $post_types Array of post types (e.g. post, page).
776 * @since 2.6
777 * @return array
778 */
779 public function suggest_wc_add_post_type( $post_types ) {
780 if ( ! in_array( 'product', $post_types, true ) ) {
781 $post_types[] = 'product';
782 }
783
784 return $post_types;
785 }
786
787 /**
788 * Setup all feature filters
789 *
790 * @since 2.1
791 */
792 public function setup() {
793 if ( ! function_exists( 'WC' ) ) {
794 return;
795 }
796 add_action( 'ep_formatted_args', [ $this, 'price_filter' ], 10, 3 );
797 add_filter( 'ep_sync_insert_permissions_bypass', [ $this, 'bypass_order_permissions_check' ], 10, 2 );
798 add_filter( 'ep_elasticpress_enabled', [ $this, 'blacklist_coupons' ], 10, 2 );
799 add_filter( 'ep_prepare_meta_allowed_protected_keys', [ $this, 'whitelist_meta_keys' ], 10, 2 );
800 add_filter( 'woocommerce_layered_nav_query_post_ids', [ $this, 'convert_post_object_to_id' ], 10, 4 );
801 add_filter( 'woocommerce_unfiltered_product_ids', [ $this, 'convert_post_object_to_id' ], 10, 4 );
802 add_filter( 'ep_sync_taxonomies', [ $this, 'whitelist_taxonomies' ], 10, 2 );
803 add_filter( 'ep_post_sync_args_post_prepare_meta', [ $this, 'add_order_items_search' ], 20, 2 );
804 add_filter( 'ep_pc_skip_post_content_cleanup', [ $this, 'keep_order_fields' ], 20, 2 );
805 add_action( 'pre_get_posts', [ $this, 'translate_args' ], 11, 1 );
806 add_action( 'ep_wp_query_search_cached_posts', [ $this, 'disallow_duplicated_query' ], 10, 2 );
807 add_action( 'parse_query', [ $this, 'maybe_hook_woocommerce_search_fields' ], 1 );
808 add_action( 'parse_query', [ $this, 'search_order' ], 11 );
809 add_filter( 'ep_term_suggest_post_type', [ $this, 'suggest_wc_add_post_type' ] );
810 add_filter( 'ep_facet_include_taxonomies', [ $this, 'add_product_attributes' ] );
811 add_filter( 'ep_weighting_fields_for_post_type', [ $this, 'add_product_attributes_to_weighting' ], 10, 2 );
812 add_filter( 'ep_weighting_default_post_type_weights', [ $this, 'add_product_default_post_type_weights' ], 10, 2 );
813 add_filter( 'ep_prepare_meta_data', [ $this, 'add_variations_skus_meta' ], 10, 2 );
814 add_filter( 'request', [ $this, 'admin_product_list_request_query' ], 9 );
815 }
816
817 /**
818 * Output feature box long
819 *
820 * @since 2.1
821 */
822 public function output_feature_box_long() {
823 ?>
824 <p><?php esc_html_e( 'Most caching and performance tools can’t keep up with the nearly infinite ways your visitors might filter or navigate your products. No matter how many products, filters, or customers you have, ElasticPress will keep your online store performing quickly. If used in combination with the Protected Content feature, ElasticPress will also accelerate order searches and back end product management.', 'elasticpress' ); ?></p>
825 <?php
826 }
827
828 /**
829 * Remove the author_name from search fields.
830 *
831 * @param array $search_fields Array of search fields.
832 * @since 3.0
833 * @return array
834 */
835 public function remove_author( $search_fields ) {
836 foreach ( $search_fields as $field_key => $field ) {
837 if ( 'author_name' === $field ) {
838 unset( $search_fields[ $field_key ] );
839 }
840 }
841
842 return $search_fields;
843 }
844
845 /**
846 * Determine WC feature reqs status
847 *
848 * @since 2.2
849 * @return EP_Feature_Requirements_Status
850 */
851 public function requirements_status() {
852 $status = new FeatureRequirementsStatus( 0 );
853
854 if ( ! class_exists( 'WooCommerce' ) ) {
855 $status->code = 2;
856 $status->message = esc_html__( 'WooCommerce not installed.', 'elasticpress' );
857 }
858
859 return $status;
860 }
861
862 /**
863 * Modifies main query to allow filtering by price with WooCommerce "Filter by price" widget.
864 *
865 * @param array $args ES args
866 * @param array $query_args WP_Query args
867 * @param WP_Query $query WP_Query object
868 * @since 3.2
869 * @return array
870 */
871 public function price_filter( $args, $query_args, $query ) {
872 // Only can use widget on main query
873 if ( ! $query->is_main_query() ) {
874 return $args;
875 }
876
877 // Only can use widget on shop, product taxonomy, or search
878 if ( ! is_shop() && ! is_product_taxonomy() && ! is_search() ) {
879 return $args;
880 }
881
882 // phpcs:disable WordPress.Security.NonceVerification
883 if ( empty( $_GET['min_price'] ) && empty( $_GET['max_price'] ) ) {
884 return $args;
885 }
886
887 if ( $query->is_search() ) {
888 /**
889 * This logic is iffy but the WC price filter widget is not intended for use with search anyway
890 */
891 $old_query = $args['query']['bool'];
892 unset( $args['query']['bool']['should'] );
893
894 if ( ! empty( $_GET['min_price'] ) ) {
895 $args['query']['bool']['must'][0]['range']['meta._price.long']['gte'] = $_GET['min_price'];
896 }
897
898 if ( ! empty( $_GET['max_price'] ) ) {
899 $args['query']['bool']['must'][0]['range']['meta._price.long']['lte'] = $_GET['max_price'];
900 }
901
902 $args['query']['bool']['must'][0]['range']['meta._price.long']['boost'] = 2.0;
903 $args['query']['bool']['must'][1]['bool'] = $old_query;
904 } else {
905 unset( $args['query']['match_all'] );
906
907 $args['query']['range']['meta._price.long']['gte'] = ! empty( $_GET['min_price'] ) ? $_GET['min_price'] : 0;
908
909 if ( ! empty( $_GET['min_price'] ) ) {
910 $args['query']['range']['meta._price.long']['gte'] = $_GET['min_price'];
911 }
912
913 if ( ! empty( $_GET['max_price'] ) ) {
914 $args['query']['range']['meta._price.long']['lte'] = $_GET['max_price'];
915 }
916
917 $args['query']['range']['meta._price.long']['boost'] = 2.0;
918 }
919 // phpcs:enable WordPress.Security.NonceVerification
920
921 return $args;
922 }
923
924 /**
925 * Prevent order fields from being removed.
926 *
927 * When Protected Content is enabled, all posts with password have their content removed.
928 * This can't happen for orders, as the order key is added in that field.
929 *
930 * @see https://github.com/10up/ElasticPress/issues/2726
931 *
932 * @since 4.2.0
933 * @param bool $skip Whether the password protected content should have their content, and meta removed
934 * @param array $post_args Post arguments
935 * @return bool
936 */
937 public function keep_order_fields( $skip, $post_args ) {
938 if ( 'shop_order' === $post_args['post_type'] ) {
939 return true;
940 }
941
942 return $skip;
943 }
944
945 /**
946 * Add a new `_variations_skus` meta field to the product to be indexed in Elasticsearch.
947 *
948 * @since 4.2.0
949 * @param array $post_meta Post meta
950 * @param WP_Post $post Post object
951 * @return array
952 */
953 public function add_variations_skus_meta( $post_meta, $post ) {
954 if ( 'product' !== $post->post_type ) {
955 return $post_meta;
956 }
957
958 $product = wc_get_product( $post );
959 $variations_ids = $product->get_children();
960
961 $post_meta['_variations_skus'] = array_reduce(
962 $variations_ids,
963 function ( $variations_skus, $current_id ) {
964 $variation = wc_get_product( $current_id );
965 if ( ! $variation || ! $variation->exists() ) {
966 return $variations_skus;
967 }
968 $variation_sku = $variation->get_sku();
969 if ( ! $variation_sku ) {
970 return $variations_skus;
971 }
972 $variations_skus[] = $variation_sku;
973 return $variations_skus;
974 },
975 []
976 );
977
978 return $post_meta;
979 }
980
981 /**
982 * Integrate ElasticPress with the WooCommerce Admin Product List.
983 *
984 * WooCommerce uses its `WC_Admin_List_Table_Products` class to control that screen. This
985 * function adds all necessary hooks to bypass the default behavior and integrate with ElasticPress.
986 * By default, WC runs a SQL query to get the Product IDs that match the list criteria and passes
987 * that list of IDs to the main WP_Query. This integration changes that process to a single query, run
988 * by ElasticPress.
989 *
990 * @since 4.2.0
991 * @param array $query_vars Query vars.
992 * @return array
993 */
994 public function admin_product_list_request_query( $query_vars ) {
995 global $typenow, $wc_list_table;
996
997 // Return if not in the correct screen.
998 if ( ! is_a( $wc_list_table, 'WC_Admin_List_Table_Products' ) || 'product' !== $typenow ) {
999 return $query_vars;
1000 }
1001
1002 // Return if admin WP_Query integration is not turned on, i.e., Protect Content is not enabled.
1003 if ( ! has_filter( 'ep_admin_wp_query_integration', '__return_true' ) ) {
1004 return $query_vars;
1005 }
1006
1007 /**
1008 * Filter to skip integration with WooCommerce Admin Product List.
1009 *
1010 * @hook ep_woocommerce_integrate_admin_products_list
1011 * @since 4.2.0
1012 * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true.
1013 * @param {array} $query_vars Query vars.
1014 * @return {bool} New integrate value
1015 */
1016 if ( ! apply_filters( 'ep_woocommerce_integrate_admin_products_list', true, $query_vars ) ) {
1017 return $query_vars;
1018 }
1019
1020 add_action( 'pre_get_posts', [ $this, 'translate_args_admin_products_list' ], 12 );
1021
1022 // This short-circuits WooCommerce search for product IDs.
1023 add_filter( 'woocommerce_product_pre_search_products', '__return_empty_array' );
1024
1025 return $query_vars;
1026 }
1027
1028 /**
1029 * Apply the necessary changes to WP_Query in WooCommerce Admin Product List.
1030 *
1031 * @param WP_Query $query The WP Query being executed.
1032 */
1033 public function translate_args_admin_products_list( $query ) {
1034 // The `translate_args()` method sets it to `true` if we should integrate it.
1035 if ( ! $query->get( 'ep_integrate', false ) ) {
1036 return;
1037 }
1038
1039 // WooCommerce unsets the search term right after using it to fetch product IDs. Here we add it back.
1040 $search_term = ! empty( $_GET['s'] ) ? sanitize_text_field( $_GET['s'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1041 if ( ! empty( $search_term ) ) {
1042 $query->set( 's', sanitize_text_field( $search_term ) ); // phpcs:ignore WordPress.Security.NonceVerification
1043
1044 /**
1045 * Filter to skip integration with WooCommerce Admin Product List.
1046 *
1047 * @hook ep_woocommerce_admin_products_list_search_fields
1048 * @since 4.2.0
1049 * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true.
1050 * @param {array} $query_vars Query vars.
1051 * @return {bool} New integrate value
1052 */
1053 $search_fields = apply_filters(
1054 'ep_woocommerce_admin_products_list_search_fields',
1055 [
1056 'post_title',
1057 'post_content',
1058 'post_excerpt',
1059 'meta' => [
1060 '_sku',
1061 '_variations_skus',
1062 ],
1063 ]
1064 );
1065
1066 $query->set( 'search_fields', $search_fields );
1067 }
1068
1069 // 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()`.
1070 $product_type_query = $query->get( 'product_type', '' );
1071 $product_type_url = ! empty( $_GET['product_type'] ) ? sanitize_text_field( $_GET['product_type'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1072 $allowed_prod_types = [ 'virtual', 'downloadable' ];
1073 if ( empty( $product_type_query ) && ! empty( $product_type_url ) && in_array( $product_type_url, $allowed_prod_types, true ) ) {
1074 $meta_query = $query->get( 'meta_query', [] );
1075 $meta_query[] = [
1076 'key' => "_{$product_type_url}",
1077 'value' => 'yes',
1078 ];
1079 $query->set( 'meta_query', $meta_query );
1080 }
1081 }
1082
1083 /**
1084 * Determines whether or not ES should be integrating with the provided query
1085 *
1086 * @param \WP_Query $query Query we might integrate with
1087 *
1088 * @return bool
1089 */
1090 protected function should_integrate_with_query( $query ) {
1091 // Lets make sure this doesn't interfere with the CLI
1092 if ( defined( 'WP_CLI' ) && WP_CLI ) {
1093 return false;
1094 }
1095
1096 /**
1097 * Filter to skip WP Query integration
1098 *
1099 * @hook ep_skip_query_integration
1100 * @param {bool} $skip True to skip
1101 * @param {WP_Query} $query WP Query to evaluate
1102 * @return {bool} New skip value
1103 */
1104 if ( apply_filters( 'ep_skip_query_integration', false, $query ) ||
1105 ( isset( $query->query_vars['ep_integrate'] ) && ! filter_var( $query->query_vars['ep_integrate'], FILTER_VALIDATE_BOOLEAN ) ) ) {
1106 return false;
1107 }
1108
1109 if ( ! Utils\is_integrated_request( $this->slug ) ) {
1110 return false;
1111 }
1112
1113 $product_name = $query->get( 'product', false );
1114
1115 $post_parent = $query->get( 'post_parent', false );
1116
1117 /**
1118 * Do nothing for single product queries
1119 */
1120 if ( ! empty( $product_name ) || $query->is_single() ) {
1121 return false;
1122 }
1123
1124 /**
1125 * ElasticPress does not yet support post_parent queries
1126 */
1127 if ( ! empty( $post_parent ) ) {
1128 return false;
1129 }
1130
1131 /**
1132 * If this is just a preview, let's not use Elasticsearch.
1133 */
1134 if ( $query->get( 'preview', false ) ) {
1135 return false;
1136 }
1137
1138 /**
1139 * Cant hook into WC API yet
1140 */
1141 if ( defined( 'WC_API_REQUEST' ) && WC_API_REQUEST ) {
1142 return false;
1143 }
1144
1145 return true;
1146 }
1147 }
1148