PluginProbe
ElasticPress / 4.3.0
ElasticPress v4.3.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.3.0, at includes/classes/Feature/WooCommerce/WooCommerce.php

1,160 lines 34.4 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 $variations_skus_key = 'meta._variations_skus.value';
747
748 $fields['attributes']['children'][ $variations_skus_key ] = array(
749 'key' => $variations_skus_key,
750 'label' => __( 'Variations SKUs', 'elasticpress' ),
751 );
752 }
753 return $fields;
754 }
755
756 /**
757 * Add WooCommerce Fields to the default values of the Weighting Dashboard.
758 *
759 * @since 3.x
760 *
761 * @param array $defaults Default values for the post type.
762 * @param string $post_type Current post type.
763 * @return array
764 */
765 public function add_product_default_post_type_weights( $defaults, $post_type ) {
766 if ( 'product' === $post_type ) {
767 if ( ! empty( $defaults['author_name'] ) ) {
768 unset( $defaults['author_name'] );
769 }
770
771 $defaults['meta._sku.value'] = array(
772 'enabled' => true,
773 'weight' => 1,
774 );
775
776 $defaults['meta._variations_skus.value'] = array(
777 'enabled' => true,
778 'weight' => 1,
779 );
780 }
781 return $defaults;
782 }
783
784 /**
785 * Add WC post type to autosuggest
786 *
787 * @param array $post_types Array of post types (e.g. post, page).
788 * @since 2.6
789 * @return array
790 */
791 public function suggest_wc_add_post_type( $post_types ) {
792 if ( ! in_array( 'product', $post_types, true ) ) {
793 $post_types[] = 'product';
794 }
795
796 return $post_types;
797 }
798
799 /**
800 * Setup all feature filters
801 *
802 * @since 2.1
803 */
804 public function setup() {
805 if ( ! function_exists( 'WC' ) ) {
806 return;
807 }
808 add_action( 'ep_formatted_args', [ $this, 'price_filter' ], 10, 3 );
809 add_filter( 'ep_sync_insert_permissions_bypass', [ $this, 'bypass_order_permissions_check' ], 10, 2 );
810 add_filter( 'ep_elasticpress_enabled', [ $this, 'blacklist_coupons' ], 10, 2 );
811 add_filter( 'ep_prepare_meta_allowed_protected_keys', [ $this, 'whitelist_meta_keys' ], 10, 2 );
812 add_filter( 'woocommerce_layered_nav_query_post_ids', [ $this, 'convert_post_object_to_id' ], 10, 4 );
813 add_filter( 'woocommerce_unfiltered_product_ids', [ $this, 'convert_post_object_to_id' ], 10, 4 );
814 add_filter( 'ep_sync_taxonomies', [ $this, 'whitelist_taxonomies' ], 10, 2 );
815 add_filter( 'ep_post_sync_args_post_prepare_meta', [ $this, 'add_order_items_search' ], 20, 2 );
816 add_filter( 'ep_pc_skip_post_content_cleanup', [ $this, 'keep_order_fields' ], 20, 2 );
817 add_action( 'pre_get_posts', [ $this, 'translate_args' ], 11, 1 );
818 add_action( 'ep_wp_query_search_cached_posts', [ $this, 'disallow_duplicated_query' ], 10, 2 );
819 add_action( 'parse_query', [ $this, 'maybe_hook_woocommerce_search_fields' ], 1 );
820 add_action( 'parse_query', [ $this, 'search_order' ], 11 );
821 add_filter( 'ep_term_suggest_post_type', [ $this, 'suggest_wc_add_post_type' ] );
822 add_filter( 'ep_facet_include_taxonomies', [ $this, 'add_product_attributes' ] );
823 add_filter( 'ep_weighting_fields_for_post_type', [ $this, 'add_product_attributes_to_weighting' ], 10, 2 );
824 add_filter( 'ep_weighting_default_post_type_weights', [ $this, 'add_product_default_post_type_weights' ], 10, 2 );
825 add_filter( 'ep_prepare_meta_data', [ $this, 'add_variations_skus_meta' ], 10, 2 );
826 add_filter( 'request', [ $this, 'admin_product_list_request_query' ], 9 );
827 }
828
829 /**
830 * Output feature box long
831 *
832 * @since 2.1
833 */
834 public function output_feature_box_long() {
835 ?>
836 <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>
837 <?php
838 }
839
840 /**
841 * Remove the author_name from search fields.
842 *
843 * @param array $search_fields Array of search fields.
844 * @since 3.0
845 * @return array
846 */
847 public function remove_author( $search_fields ) {
848 foreach ( $search_fields as $field_key => $field ) {
849 if ( 'author_name' === $field ) {
850 unset( $search_fields[ $field_key ] );
851 }
852 }
853
854 return $search_fields;
855 }
856
857 /**
858 * Determine WC feature reqs status
859 *
860 * @since 2.2
861 * @return EP_Feature_Requirements_Status
862 */
863 public function requirements_status() {
864 $status = new FeatureRequirementsStatus( 0 );
865
866 if ( ! class_exists( 'WooCommerce' ) ) {
867 $status->code = 2;
868 $status->message = esc_html__( 'WooCommerce not installed.', 'elasticpress' );
869 }
870
871 return $status;
872 }
873
874 /**
875 * Modifies main query to allow filtering by price with WooCommerce "Filter by price" widget.
876 *
877 * @param array $args ES args
878 * @param array $query_args WP_Query args
879 * @param WP_Query $query WP_Query object
880 * @since 3.2
881 * @return array
882 */
883 public function price_filter( $args, $query_args, $query ) {
884 // Only can use widget on main query
885 if ( ! $query->is_main_query() ) {
886 return $args;
887 }
888
889 // Only can use widget on shop, product taxonomy, or search
890 if ( ! is_shop() && ! is_product_taxonomy() && ! is_search() ) {
891 return $args;
892 }
893
894 // phpcs:disable WordPress.Security.NonceVerification
895 if ( empty( $_GET['min_price'] ) && empty( $_GET['max_price'] ) ) {
896 return $args;
897 }
898
899 if ( $query->is_search() ) {
900 /**
901 * This logic is iffy but the WC price filter widget is not intended for use with search anyway
902 */
903 $old_query = $args['query']['bool'];
904 unset( $args['query']['bool']['should'] );
905
906 if ( ! empty( $_GET['min_price'] ) ) {
907 $args['query']['bool']['must'][0]['range']['meta._price.long']['gte'] = $_GET['min_price'];
908 }
909
910 if ( ! empty( $_GET['max_price'] ) ) {
911 $args['query']['bool']['must'][0]['range']['meta._price.long']['lte'] = $_GET['max_price'];
912 }
913
914 $args['query']['bool']['must'][0]['range']['meta._price.long']['boost'] = 2.0;
915 $args['query']['bool']['must'][1]['bool'] = $old_query;
916 } else {
917 unset( $args['query']['match_all'] );
918
919 $args['query']['range']['meta._price.long']['gte'] = ! empty( $_GET['min_price'] ) ? $_GET['min_price'] : 0;
920
921 if ( ! empty( $_GET['min_price'] ) ) {
922 $args['query']['range']['meta._price.long']['gte'] = $_GET['min_price'];
923 }
924
925 if ( ! empty( $_GET['max_price'] ) ) {
926 $args['query']['range']['meta._price.long']['lte'] = $_GET['max_price'];
927 }
928
929 $args['query']['range']['meta._price.long']['boost'] = 2.0;
930 }
931 // phpcs:enable WordPress.Security.NonceVerification
932
933 return $args;
934 }
935
936 /**
937 * Prevent order fields from being removed.
938 *
939 * When Protected Content is enabled, all posts with password have their content removed.
940 * This can't happen for orders, as the order key is added in that field.
941 *
942 * @see https://github.com/10up/ElasticPress/issues/2726
943 *
944 * @since 4.2.0
945 * @param bool $skip Whether the password protected content should have their content, and meta removed
946 * @param array $post_args Post arguments
947 * @return bool
948 */
949 public function keep_order_fields( $skip, $post_args ) {
950 if ( 'shop_order' === $post_args['post_type'] ) {
951 return true;
952 }
953
954 return $skip;
955 }
956
957 /**
958 * Add a new `_variations_skus` meta field to the product to be indexed in Elasticsearch.
959 *
960 * @since 4.2.0
961 * @param array $post_meta Post meta
962 * @param WP_Post $post Post object
963 * @return array
964 */
965 public function add_variations_skus_meta( $post_meta, $post ) {
966 if ( 'product' !== $post->post_type ) {
967 return $post_meta;
968 }
969
970 $product = wc_get_product( $post );
971 $variations_ids = $product->get_children();
972
973 $post_meta['_variations_skus'] = array_reduce(
974 $variations_ids,
975 function ( $variations_skus, $current_id ) {
976 $variation = wc_get_product( $current_id );
977 if ( ! $variation || ! $variation->exists() ) {
978 return $variations_skus;
979 }
980 $variation_sku = $variation->get_sku();
981 if ( ! $variation_sku ) {
982 return $variations_skus;
983 }
984 $variations_skus[] = $variation_sku;
985 return $variations_skus;
986 },
987 []
988 );
989
990 return $post_meta;
991 }
992
993 /**
994 * Integrate ElasticPress with the WooCommerce Admin Product List.
995 *
996 * WooCommerce uses its `WC_Admin_List_Table_Products` class to control that screen. This
997 * function adds all necessary hooks to bypass the default behavior and integrate with ElasticPress.
998 * By default, WC runs a SQL query to get the Product IDs that match the list criteria and passes
999 * that list of IDs to the main WP_Query. This integration changes that process to a single query, run
1000 * by ElasticPress.
1001 *
1002 * @since 4.2.0
1003 * @param array $query_vars Query vars.
1004 * @return array
1005 */
1006 public function admin_product_list_request_query( $query_vars ) {
1007 global $typenow, $wc_list_table;
1008
1009 // Return if not in the correct screen.
1010 if ( ! is_a( $wc_list_table, 'WC_Admin_List_Table_Products' ) || 'product' !== $typenow ) {
1011 return $query_vars;
1012 }
1013
1014 // Return if admin WP_Query integration is not turned on, i.e., Protect Content is not enabled.
1015 if ( ! has_filter( 'ep_admin_wp_query_integration', '__return_true' ) ) {
1016 return $query_vars;
1017 }
1018
1019 /**
1020 * Filter to skip integration with WooCommerce Admin Product List.
1021 *
1022 * @hook ep_woocommerce_integrate_admin_products_list
1023 * @since 4.2.0
1024 * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true.
1025 * @param {array} $query_vars Query vars.
1026 * @return {bool} New integrate value
1027 */
1028 if ( ! apply_filters( 'ep_woocommerce_integrate_admin_products_list', true, $query_vars ) ) {
1029 return $query_vars;
1030 }
1031
1032 add_action( 'pre_get_posts', [ $this, 'translate_args_admin_products_list' ], 12 );
1033
1034 // This short-circuits WooCommerce search for product IDs.
1035 add_filter( 'woocommerce_product_pre_search_products', '__return_empty_array' );
1036
1037 return $query_vars;
1038 }
1039
1040 /**
1041 * Apply the necessary changes to WP_Query in WooCommerce Admin Product List.
1042 *
1043 * @param WP_Query $query The WP Query being executed.
1044 */
1045 public function translate_args_admin_products_list( $query ) {
1046 // The `translate_args()` method sets it to `true` if we should integrate it.
1047 if ( ! $query->get( 'ep_integrate', false ) ) {
1048 return;
1049 }
1050
1051 // WooCommerce unsets the search term right after using it to fetch product IDs. Here we add it back.
1052 $search_term = ! empty( $_GET['s'] ) ? sanitize_text_field( $_GET['s'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1053 if ( ! empty( $search_term ) ) {
1054 $query->set( 's', sanitize_text_field( $search_term ) ); // phpcs:ignore WordPress.Security.NonceVerification
1055
1056 /**
1057 * Filter to skip integration with WooCommerce Admin Product List.
1058 *
1059 * @hook ep_woocommerce_admin_products_list_search_fields
1060 * @since 4.2.0
1061 * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true.
1062 * @param {array} $query_vars Query vars.
1063 * @return {bool} New integrate value
1064 */
1065 $search_fields = apply_filters(
1066 'ep_woocommerce_admin_products_list_search_fields',
1067 [
1068 'post_title',
1069 'post_content',
1070 'post_excerpt',
1071 'meta' => [
1072 '_sku',
1073 '_variations_skus',
1074 ],
1075 ]
1076 );
1077
1078 $query->set( 'search_fields', $search_fields );
1079 }
1080
1081 // 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()`.
1082 $product_type_query = $query->get( 'product_type', '' );
1083 $product_type_url = ! empty( $_GET['product_type'] ) ? sanitize_text_field( $_GET['product_type'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1084 $allowed_prod_types = [ 'virtual', 'downloadable' ];
1085 if ( empty( $product_type_query ) && ! empty( $product_type_url ) && in_array( $product_type_url, $allowed_prod_types, true ) ) {
1086 $meta_query = $query->get( 'meta_query', [] );
1087 $meta_query[] = [
1088 'key' => "_{$product_type_url}",
1089 'value' => 'yes',
1090 ];
1091 $query->set( 'meta_query', $meta_query );
1092 }
1093 }
1094
1095 /**
1096 * Determines whether or not ES should be integrating with the provided query
1097 *
1098 * @param \WP_Query $query Query we might integrate with
1099 *
1100 * @return bool
1101 */
1102 protected function should_integrate_with_query( $query ) {
1103 // Lets make sure this doesn't interfere with the CLI
1104 if ( defined( 'WP_CLI' ) && WP_CLI ) {
1105 return false;
1106 }
1107
1108 /**
1109 * Filter to skip WP Query integration
1110 *
1111 * @hook ep_skip_query_integration
1112 * @param {bool} $skip True to skip
1113 * @param {WP_Query} $query WP Query to evaluate
1114 * @return {bool} New skip value
1115 */
1116 if ( apply_filters( 'ep_skip_query_integration', false, $query ) ||
1117 ( isset( $query->query_vars['ep_integrate'] ) && ! filter_var( $query->query_vars['ep_integrate'], FILTER_VALIDATE_BOOLEAN ) ) ) {
1118 return false;
1119 }
1120
1121 if ( ! Utils\is_integrated_request( $this->slug ) ) {
1122 return false;
1123 }
1124
1125 $product_name = $query->get( 'product', false );
1126
1127 $post_parent = $query->get( 'post_parent', false );
1128
1129 /**
1130 * Do nothing for single product queries
1131 */
1132 if ( ! empty( $product_name ) || $query->is_single() ) {
1133 return false;
1134 }
1135
1136 /**
1137 * ElasticPress does not yet support post_parent queries
1138 */
1139 if ( ! empty( $post_parent ) ) {
1140 return false;
1141 }
1142
1143 /**
1144 * If this is just a preview, let's not use Elasticsearch.
1145 */
1146 if ( $query->get( 'preview', false ) ) {
1147 return false;
1148 }
1149
1150 /**
1151 * Cant hook into WC API yet
1152 */
1153 if ( defined( 'WC_API_REQUEST' ) && WC_API_REQUEST ) {
1154 return false;
1155 }
1156
1157 return true;
1158 }
1159 }
1160