PluginProbe
ElasticPress / 4.4.0
ElasticPress v4.4.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.4.0, at includes/classes/Feature/WooCommerce/WooCommerce.php

1,300 lines 39.2 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 $searchable_post_types = $this->get_admin_searchable_post_types();
215
216 /**
217 * Make sure we're on edit.php in admin dashboard.
218 */
219 if ( 'edit.php' !== $pagenow || ! is_admin() || ! in_array( $query->get( 'post_type' ), $searchable_post_types, true ) ) {
220 return $value;
221 }
222
223 /**
224 * Check if EP API request was already done. If request was sent return its results.
225 */
226 if ( isset( $query->elasticsearch_success ) && $query->elasticsearch_success ) {
227 return $query->posts;
228 }
229
230 return $value;
231
232 }
233
234 /**
235 * Translate args to ElasticPress compat format. This is the meat of what the feature does
236 *
237 * @param WP_Query $query WP Query
238 * @since 2.1
239 */
240 public function translate_args( $query ) {
241 if ( ! $this->should_integrate_with_query( $query ) ) {
242 return;
243 }
244
245 // Flag to check and make sure we are in a WooCommerce specific query
246 $integrate = false;
247
248 /**
249 * Force ElasticPress if we are querying WC taxonomy
250 */
251 $tax_query = $query->get( 'tax_query', [] );
252
253 $supported_taxonomies = array(
254 'product_cat',
255 'product_tag',
256 'product_type',
257 'product_visibility',
258 'product_shipping_class',
259 );
260
261 // Add in any attribute taxonomies that exist
262 $attribute_taxonomies = wc_get_attribute_taxonomy_names();
263
264 $supported_taxonomies = array_merge( $supported_taxonomies, $attribute_taxonomies );
265
266 /**
267 * Filter supported custom taxonomies for WooCommerce integration
268 *
269 * @param {array} $supported_taxonomies An array of default taxonomies.
270 * @hook ep_woocommerce_supported_taxonomies
271 * @since 2.3.0
272 * @return {array} New taxonomies
273 */
274 $supported_taxonomies = apply_filters( 'ep_woocommerce_supported_taxonomies', $supported_taxonomies );
275
276 if ( ! empty( $tax_query ) ) {
277
278 /**
279 * First check if already set taxonomies are supported WC taxes
280 */
281 foreach ( $tax_query as $taxonomy_array ) {
282 if ( isset( $taxonomy_array['taxonomy'] ) && in_array( $taxonomy_array['taxonomy'], $supported_taxonomies, true ) ) {
283 $integrate = true;
284 }
285 }
286 }
287
288 /**
289 * Next check if any taxonomies are in the root of query vars (shorthand form)
290 */
291 foreach ( $supported_taxonomies as $taxonomy ) {
292 $term = $query->get( $taxonomy, false );
293
294 if ( ! empty( $term ) ) {
295 $integrate = true;
296
297 $tax_query[] = array(
298 'taxonomy' => $taxonomy,
299 'field' => 'slug',
300 'terms' => (array) $term,
301 );
302 }
303 }
304
305 /**
306 * Force ElasticPress if product post type query
307 */
308 $post_type = $query->get( 'post_type', false );
309
310 // Act only on a defined subset of all indexable post types here
311 $post_types = array(
312 'product',
313 'shop_order',
314 'shop_order_refund',
315 'product_variation',
316 );
317
318 /**
319 * Expands or contracts the post_types eligible for indexing.
320 *
321 * @hook ep_woocommerce_default_supported_post_types
322 * @since 4.4.0
323 * @param {array} $post_types Post types
324 * @return {array} New post types
325 */
326 $supported_post_types = apply_filters( 'ep_woocommerce_default_supported_post_types', $post_types );
327
328 $supported_post_types = array_intersect(
329 $supported_post_types,
330 Indexables::factory()->get( 'post' )->get_indexable_post_types()
331 );
332
333 // For orders it queries an array of shop_order and shop_order_refund post types, hence an array_diff
334 if ( ! empty( $post_type ) && ( in_array( $post_type, $supported_post_types, true ) || ( is_array( $post_type ) && ! array_diff( $post_type, $supported_post_types ) ) ) ) {
335 $integrate = true;
336 }
337
338 /**
339 * If we have a WooCommerce specific query, lets hook it to ElasticPress and make the query ElasticSearch friendly
340 */
341 if ( ! $integrate ) {
342 return;
343 }
344
345 // Set tax_query again since we may have added things
346 $query->set( 'tax_query', $tax_query );
347
348 // Default to product if no post type is set
349 if ( empty( $post_type ) ) {
350 $post_type = 'product';
351 $query->set( 'post_type', 'product' );
352 }
353
354 // Handles the WC Top Rated Widget
355 if ( has_filter( 'posts_clauses', array( WC()->query, 'order_by_rating_post_clauses' ) ) ) {
356 remove_filter( 'posts_clauses', array( WC()->query, 'order_by_rating_post_clauses' ) );
357 $query->set( 'orderby', 'meta_value_num' );
358 $query->set( 'meta_key', '_wc_average_rating' );
359 }
360
361 /**
362 * WordPress have to be version 4.6 or newer to have "fields" support
363 * since it requires the "posts_pre_query" filter.
364 *
365 * @see WP_Query::get_posts
366 */
367 $fields = $query->get( 'fields', false );
368 if ( ! version_compare( get_bloginfo( 'version' ), '4.6', '>=' ) && ( 'ids' === $fields || 'id=>parent' === $fields ) ) {
369 $query->set( 'fields', 'default' );
370 }
371
372 /**
373 * Handle meta queries
374 */
375 $meta_query = $query->get( 'meta_query', [] );
376 $meta_key = $query->get( 'meta_key', false );
377 $meta_value = $query->get( 'meta_value', false );
378
379 if ( ! empty( $meta_key ) && ! empty( $meta_value ) ) {
380 $meta_query[] = array(
381 'key' => $meta_key,
382 'value' => $meta_value,
383 );
384
385 $query->set( 'meta_query', $meta_query );
386 }
387
388 /**
389 * Make sure filters are suppressed
390 */
391 $query->query['suppress_filters'] = false;
392 $query->set( 'suppress_filters', false );
393
394 // Integrate with WooCommerce custom searches as well
395 $search = $query->get( 'search' );
396 if ( ! empty( $search ) ) {
397 $s = $search;
398 $query->set( 's', $s );
399 } else {
400 $s = $query->get( 's' );
401 }
402
403 $query->query_vars['ep_integrate'] = true;
404 $query->query['ep_integrate'] = true;
405
406 if ( ! empty( $s ) ) {
407
408 $searchable_post_types = $this->get_admin_searchable_post_types();
409
410 if ( in_array( $post_type, $searchable_post_types, true ) ) {
411 $default_search_fields = array( 'post_title', 'post_content', 'post_excerpt' );
412 if ( ctype_digit( $s ) ) {
413 $default_search_fields[] = 'ID';
414 }
415 $search_fields = $query->get( 'search_fields', $default_search_fields );
416
417 $search_fields['meta'] = array_map(
418 'wc_clean',
419 /**
420 * Filter shop order meta fields to search for WooCommerce
421 *
422 * @hook shop_order_search_fields
423 * @param {array} $fields Shop order fields
424 * @return {array} New fields
425 */
426 apply_filters(
427 'shop_order_search_fields',
428 array(
429 '_order_key',
430 '_billing_company',
431 '_billing_address_1',
432 '_billing_address_2',
433 '_billing_city',
434 '_billing_postcode',
435 '_billing_country',
436 '_billing_state',
437 '_billing_email',
438 '_billing_phone',
439 '_shipping_address_1',
440 '_shipping_address_2',
441 '_shipping_city',
442 '_shipping_postcode',
443 '_shipping_country',
444 '_shipping_state',
445 '_billing_last_name',
446 '_billing_first_name',
447 '_shipping_first_name',
448 '_shipping_last_name',
449 '_items',
450 )
451 )
452 );
453
454 $query->set(
455 'search_fields',
456 /**
457 * Filter all the shop order fields to search for WooCommerce
458 *
459 * @hook ep_woocommerce_shop_order_search_fields
460 * @since 4.0.0
461 * @param {array} $fields Shop order fields
462 * @param {WP_Query} $query WP Query
463 * @return {array} New fields
464 */
465 apply_filters( 'ep_woocommerce_shop_order_search_fields', $search_fields, $query )
466 );
467 } elseif ( 'product' === $post_type && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
468 $search_fields = $query->get( 'search_fields', array( 'post_title', 'post_content', 'post_excerpt' ) );
469
470 // Remove author_name from this search.
471 $search_fields = $this->remove_author( $search_fields );
472
473 foreach ( $search_fields as $field_key => $field ) {
474 if ( 'author_name' === $field ) {
475 unset( $search_fields[ $field_key ] );
476 }
477 }
478
479 $search_fields['meta'] = ( ! empty( $search_fields['meta'] ) ) ? $search_fields['meta'] : [];
480 $search_fields['taxonomies'] = ( ! empty( $search_fields['taxonomies'] ) ) ? $search_fields['taxonomies'] : [];
481
482 $search_fields['meta'] = array_merge( $search_fields['meta'], array( '_sku' ) );
483 $search_fields['taxonomies'] = array_merge( $search_fields['taxonomies'], array( 'category', 'post_tag', 'product_tag', 'product_cat' ) );
484
485 $query->set( 'search_fields', $search_fields );
486 }
487 } else {
488 /**
489 * For default sorting by popularity (total_sales) and rating
490 * Woocommerce doesn't set the orderby correctly.
491 * These lines will check the meta_key and correct the orderby based on that.
492 * And this won't run in search result and only run in main query
493 */
494 $meta_key = $query->get( 'meta_key', false );
495 if ( $meta_key && $query->is_main_query() ) {
496 switch ( $meta_key ) {
497 case 'total_sales':
498 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
499 $query->set( 'order', 'DESC' );
500 break;
501 case '_wc_average_rating':
502 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_wc_average_rating' ) );
503 $query->set( 'order', 'DESC' );
504 break;
505 }
506 }
507 }
508
509 /**
510 * Set orderby and order for price/popularity when GET param not set
511 */
512 if ( isset( $query->query_vars['orderby'], $query->query_vars['order'] ) && $query->is_main_query() ) {
513 switch ( $query->query_vars['orderby'] ) {
514 case 'price':
515 $query->set( 'order', $query->query_vars['order'] );
516 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
517 break;
518 case 'popularity':
519 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
520 $query->set( 'order', 'DESC' );
521 break;
522 }
523 }
524
525 /**
526 * Set orderby from GET param
527 * Also make sure the orderby param affects only the main query
528 */
529 if ( ! empty( $_GET['orderby'] ) && $query->is_main_query() ) { // phpcs:ignore WordPress.Security.NonceVerification
530 $orderby = sanitize_text_field( $_GET['orderby'] ); // phpcs:ignore WordPress.Security.NonceVerification
531 switch ( $orderby ) { // phpcs:ignore WordPress.Security.NonceVerification
532 case 'popularity':
533 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'total_sales' ) );
534 $query->set( 'order', 'DESC' );
535 break;
536 case 'price':
537 $query->set( 'order', $query->get( 'order', 'ASC' ) );
538 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
539 break;
540 case 'price-desc':
541 $query->set( 'order', 'DESC' );
542 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_price' ) );
543 break;
544 case 'rating':
545 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_wc_average_rating' ) );
546 $query->set( 'order', 'DESC' );
547 break;
548 case 'date':
549 case 'title':
550 case 'ID':
551 $query->set( 'orderby', $this->get_orderby_meta_mapping( $orderby ) );
552 break;
553 case 'sku':
554 $query->set( 'orderby', $this->get_orderby_meta_mapping( '_sku' ) );
555 break;
556 default:
557 $query->set( 'orderby', $this->get_orderby_meta_mapping( 'menu_order' ) ); // Order by menu and title.
558 }
559 }
560 }
561
562 /**
563 * Fetch the ES related meta mapping for orderby
564 *
565 * @param array $meta_key The meta key to get the mapping for.
566 * @since 2.1
567 * @return string The mapped meta key.
568 */
569 public function get_orderby_meta_mapping( $meta_key ) {
570 /**
571 * Filter WooCommerce to Elasticsearch meta mapping
572 *
573 * @hook orderby_meta_mapping
574 * @param {array} $mapping Meta mapping
575 * @return {array} New mapping
576 */
577 $mapping = apply_filters(
578 'orderby_meta_mapping',
579 array(
580 'ID' => 'ID',
581 'title' => 'title date',
582 'menu_order' => 'menu_order title date',
583 'menu_order title' => 'menu_order title date',
584 'total_sales' => 'meta.total_sales.double date',
585 '_wc_average_rating' => 'meta._wc_average_rating.double date',
586 '_price' => 'meta._price.double date',
587 '_sku' => 'meta._sku.value.sortable date',
588 )
589 );
590
591 if ( isset( $mapping[ $meta_key ] ) ) {
592 return $mapping[ $meta_key ];
593 }
594
595 return 'date';
596 }
597
598
599 /**
600 * Returns the WooCommerce-oriented post types in admin that EP will search
601 *
602 * @since 4.4.0
603 * @return mixed|void
604 */
605 public function get_admin_searchable_post_types() {
606 $searchable_post_types = array( 'shop_order' );
607
608 /**
609 * Filter admin searchable WooCommerce post types
610 *
611 * @hook ep_woocommerce_admin_searchable_post_types
612 * @since 4.4.0
613 * @param {array} $post_types Post types
614 * @return {array} New post types
615 */
616 return apply_filters( 'ep_woocommerce_admin_searchable_post_types', $searchable_post_types );
617 }
618
619 /**
620 * Make search coupons don't go through ES
621 *
622 * @param bool $enabled Coupons enabled or not
623 * @param WP_Query $query WP Query
624 * @since 2.1
625 * @return bool
626 */
627 public function blacklist_coupons( $enabled, $query ) {
628 if ( method_exists( $query, 'get' ) && 'shop_coupon' === $query->get( 'post_type' ) ) {
629 return false;
630 }
631
632 return $enabled;
633 }
634
635 /**
636 * Allow order creations on the front end to get synced
637 *
638 * @since 2.1
639 * @param bool $override Original order perms check value
640 * @param int $post_id Post ID
641 * @return bool
642 */
643 public function bypass_order_permissions_check( $override, $post_id ) {
644 $searchable_post_types = $this->get_admin_searchable_post_types();
645
646 if ( in_array( get_post_type( $post_id ), $searchable_post_types, true ) ) {
647 return true;
648 }
649
650 return $override;
651 }
652
653 /**
654 * Sets woocommerce meta search fields to an empty array if we are integrating the main query with ElasticSearch
655 *
656 * Woocommerce calls this action as part of its own callback on parse_query. We add this filter only if the query
657 * is integrated with ElasticSearch.
658 * If we were to always return array() on this filter, we'd break admin searches when WooCommerce module is activated
659 * without the Protected Content Module
660 *
661 * @param \WP_Query $query Current query
662 */
663 public function maybe_hook_woocommerce_search_fields( $query ) {
664 global $pagenow, $wp, $wc_list_table, $wp_filter;
665
666 if ( ! $this->should_integrate_with_query( $query ) ) {
667 return;
668 }
669
670 /**
671 * Determines actions to be applied, or removed, if doing a WooCommerce serarch
672 *
673 * @hook ep_woocommerce_hook_search_fields
674 * @since 4.4.0
675 */
676 do_action( 'ep_woocommerce_hook_search_fields' );
677
678 if ( 'edit.php' !== $pagenow || empty( $wp->query_vars['s'] ) || 'shop_order' !== $wp->query_vars['post_type'] || ! isset( $_GET['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
679 return;
680 }
681
682 remove_action( 'parse_query', [ $wc_list_table, 'search_custom_fields' ] );
683 }
684
685 /**
686 * Enhance WooCommerce search order by order id, email, phone number, name, etc..
687 * What this function does:
688 * 1. Reverse the woocommerce shop_order_search_custom_fields query
689 * 2. If the search key is integer and it is an Order Id, just query with post__in
690 * 3. If the search key is integer but not an order id ( might be phone number ), use ES to find it
691 *
692 * @param WP_Query $wp WP Query
693 * @since 2.3
694 */
695 public function search_order( $wp ) {
696 if ( ! $this->should_integrate_with_query( $wp ) ) {
697 return;
698 }
699
700 global $pagenow;
701
702 $searchable_post_types = $this->get_admin_searchable_post_types();
703
704 if ( 'edit.php' !== $pagenow || empty( $wp->query_vars['post_type'] ) || ! in_array( $wp->query_vars['post_type'], $searchable_post_types, true ) ||
705 ( empty( $wp->query_vars['s'] ) && empty( $wp->query_vars['shop_order_search'] ) ) ) {
706 return;
707 }
708
709 $search_key_safe = str_replace( array( 'Order #', '#' ), '', wc_clean( $_GET['s'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
710 unset( $wp->query_vars['post__in'] );
711 $wp->query_vars['s'] = $search_key_safe;
712 }
713
714 /**
715 * Add order items as a searchable string.
716 *
717 * This mimics how WooCommerce currently does in the order_itemmeta
718 * table. They combine the titles of the products and put them in a
719 * meta field called "Items".
720 *
721 * @since 2.4
722 *
723 * @param array $post_args Post arguments
724 * @param string|int $post_id Post id
725 *
726 * @return array
727 */
728 public function add_order_items_search( $post_args, $post_id ) {
729 $searchable_post_types = $this->get_admin_searchable_post_types();
730
731 // Make sure it is only WooCommerce orders we touch.
732 if ( ! in_array( $post_args['post_type'], $searchable_post_types, true ) ) {
733 return $post_args;
734 }
735
736 $post_indexable = Indexables::factory()->get( 'post' );
737
738 // Get order items.
739 $order = wc_get_order( $post_id );
740 $item_meta = [];
741 foreach ( $order->get_items() as $delta => $product_item ) {
742 // WooCommerce 3.x uses WC_Order_Item_Product instance while 2.x an array
743 if ( is_object( $product_item ) && method_exists( $product_item, 'get_name' ) ) {
744 $item_meta['_items'][] = $product_item->get_name( 'edit' );
745 } elseif ( is_array( $product_item ) && isset( $product_item['name'] ) ) {
746 $item_meta['_items'][] = $product_item['name'];
747 }
748 }
749
750 // Prepare order items.
751 $item_meta['_items'] = empty( $item_meta['_items'] ) ? '' : implode( '|', $item_meta['_items'] );
752 $post_args['meta'] = array_merge( $post_args['meta'], $post_indexable->prepare_meta_types( $item_meta ) );
753
754 return $post_args;
755 }
756
757 /**
758 * Add WooCommerce Product Attributes to EP Facets.
759 *
760 * @param array $taxonomies Taxonomies array
761 * @return array
762 */
763 public function add_product_attributes( $taxonomies = [] ) {
764 $attribute_names = wc_get_attribute_taxonomy_names();
765
766 foreach ( $attribute_names as $name ) {
767 if ( ! taxonomy_exists( $name ) ) {
768 continue;
769 }
770 $taxonomies[ $name ] = get_taxonomy( $name );
771 }
772
773 return $taxonomies;
774 }
775
776 /**
777 * Add WooCommerce Fields to the Weighting Dashboard.
778 *
779 * @since 3.x
780 *
781 * @param array $fields Current weighting fields.
782 * @param string $post_type Current post type.
783 * @return array New fields.
784 */
785 public function add_product_attributes_to_weighting( $fields, $post_type ) {
786 if ( 'product' === $post_type ) {
787 if ( ! empty( $fields['attributes']['children']['author_name'] ) ) {
788 unset( $fields['attributes']['children']['author_name'] );
789 }
790
791 $sku_key = 'meta._sku.value';
792
793 $fields['attributes']['children'][ $sku_key ] = array(
794 'key' => $sku_key,
795 'label' => __( 'SKU', 'elasticpress' ),
796 );
797
798 $variations_skus_key = 'meta._variations_skus.value';
799
800 $fields['attributes']['children'][ $variations_skus_key ] = array(
801 'key' => $variations_skus_key,
802 'label' => __( 'Variations SKUs', 'elasticpress' ),
803 );
804 }
805 return $fields;
806 }
807
808 /**
809 * Add WooCommerce Fields to the default values of the Weighting Dashboard.
810 *
811 * @since 3.x
812 *
813 * @param array $defaults Default values for the post type.
814 * @param string $post_type Current post type.
815 * @return array
816 */
817 public function add_product_default_post_type_weights( $defaults, $post_type ) {
818 if ( 'product' === $post_type ) {
819 if ( ! empty( $defaults['author_name'] ) ) {
820 unset( $defaults['author_name'] );
821 }
822
823 $defaults['meta._sku.value'] = array(
824 'enabled' => true,
825 'weight' => 1,
826 );
827
828 $defaults['meta._variations_skus.value'] = array(
829 'enabled' => true,
830 'weight' => 1,
831 );
832 }
833 return $defaults;
834 }
835
836 /**
837 * Add WC post type to autosuggest
838 *
839 * @param array $post_types Array of post types (e.g. post, page).
840 * @since 2.6
841 * @return array
842 */
843 public function suggest_wc_add_post_type( $post_types ) {
844 if ( ! in_array( 'product', $post_types, true ) ) {
845 $post_types[] = 'product';
846 }
847
848 return $post_types;
849 }
850
851 /**
852 * Setup all feature filters
853 *
854 * @since 2.1
855 */
856 public function setup() {
857 if ( ! function_exists( 'WC' ) ) {
858 return;
859 }
860 add_action( 'ep_formatted_args', [ $this, 'price_filter' ], 10, 3 );
861 add_filter( 'ep_sync_insert_permissions_bypass', [ $this, 'bypass_order_permissions_check' ], 10, 2 );
862 add_filter( 'ep_elasticpress_enabled', [ $this, 'blacklist_coupons' ], 10, 2 );
863 add_filter( 'ep_prepare_meta_allowed_protected_keys', [ $this, 'whitelist_meta_keys' ], 10, 2 );
864 add_filter( 'woocommerce_layered_nav_query_post_ids', [ $this, 'convert_post_object_to_id' ], 10, 4 );
865 add_filter( 'woocommerce_unfiltered_product_ids', [ $this, 'convert_post_object_to_id' ], 10, 4 );
866 add_filter( 'ep_sync_taxonomies', [ $this, 'whitelist_taxonomies' ], 10, 2 );
867 add_filter( 'ep_post_sync_args_post_prepare_meta', [ $this, 'add_order_items_search' ], 20, 2 );
868 add_filter( 'ep_pc_skip_post_content_cleanup', [ $this, 'keep_order_fields' ], 20, 2 );
869 add_action( 'pre_get_posts', [ $this, 'translate_args' ], 11, 1 );
870 add_action( 'ep_wp_query_search_cached_posts', [ $this, 'disallow_duplicated_query' ], 10, 2 );
871 add_action( 'parse_query', [ $this, 'maybe_hook_woocommerce_search_fields' ], 1 );
872 add_action( 'parse_query', [ $this, 'search_order' ], 11 );
873 add_filter( 'ep_term_suggest_post_type', [ $this, 'suggest_wc_add_post_type' ] );
874 add_filter( 'ep_facet_include_taxonomies', [ $this, 'add_product_attributes' ] );
875 add_filter( 'ep_weighting_fields_for_post_type', [ $this, 'add_product_attributes_to_weighting' ], 10, 2 );
876 add_filter( 'ep_weighting_default_post_type_weights', [ $this, 'add_product_default_post_type_weights' ], 10, 2 );
877 add_filter( 'ep_prepare_meta_data', [ $this, 'add_variations_skus_meta' ], 10, 2 );
878 add_filter( 'request', [ $this, 'admin_product_list_request_query' ], 9 );
879
880 // Custom product ordering
881 add_action( 'ep_admin_notices', [ $this, 'maybe_display_notice_about_product_ordering' ] );
882 add_action( 'woocommerce_after_product_ordering', [ $this, 'action_sync_on_woocommerce_sort_single' ], 10, 2 );
883 }
884
885 /**
886 * Output feature box long
887 *
888 * @since 2.1
889 */
890 public function output_feature_box_long() {
891 ?>
892 <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>
893 <?php
894 }
895
896 /**
897 * Remove the author_name from search fields.
898 *
899 * @param array $search_fields Array of search fields.
900 * @since 3.0
901 * @return array
902 */
903 public function remove_author( $search_fields ) {
904 foreach ( $search_fields as $field_key => $field ) {
905 if ( 'author_name' === $field ) {
906 unset( $search_fields[ $field_key ] );
907 }
908 }
909
910 return $search_fields;
911 }
912
913 /**
914 * Determine WC feature reqs status
915 *
916 * @since 2.2
917 * @return EP_Feature_Requirements_Status
918 */
919 public function requirements_status() {
920 $status = new FeatureRequirementsStatus( 0 );
921
922 if ( ! class_exists( 'WooCommerce' ) ) {
923 $status->code = 2;
924 $status->message = esc_html__( 'WooCommerce not installed.', 'elasticpress' );
925 }
926
927 return $status;
928 }
929
930 /**
931 * Modifies main query to allow filtering by price with WooCommerce "Filter by price" widget.
932 *
933 * @param array $args ES args
934 * @param array $query_args WP_Query args
935 * @param WP_Query $query WP_Query object
936 * @since 3.2
937 * @return array
938 */
939 public function price_filter( $args, $query_args, $query ) {
940 // Only can use widget on main query
941 if ( ! $query->is_main_query() ) {
942 return $args;
943 }
944
945 // Only can use widget on shop, product taxonomy, or search
946 if ( ! is_shop() && ! is_product_taxonomy() && ! is_search() ) {
947 return $args;
948 }
949
950 // phpcs:disable WordPress.Security.NonceVerification
951 if ( empty( $_GET['min_price'] ) && empty( $_GET['max_price'] ) ) {
952 return $args;
953 }
954
955 if ( $query->is_search() ) {
956 /**
957 * This logic is iffy but the WC price filter widget is not intended for use with search anyway
958 */
959 $old_query = $args['query']['bool'];
960 unset( $args['query']['bool']['should'] );
961
962 if ( ! empty( $_GET['min_price'] ) ) {
963 $args['query']['bool']['must'][0]['range']['meta._price.long']['gte'] = $_GET['min_price'];
964 }
965
966 if ( ! empty( $_GET['max_price'] ) ) {
967 $args['query']['bool']['must'][0]['range']['meta._price.long']['lte'] = $_GET['max_price'];
968 }
969
970 $args['query']['bool']['must'][0]['range']['meta._price.long']['boost'] = 2.0;
971 $args['query']['bool']['must'][1]['bool'] = $old_query;
972 } else {
973 unset( $args['query']['match_all'] );
974
975 $args['query']['range']['meta._price.long']['gte'] = ! empty( $_GET['min_price'] ) ? $_GET['min_price'] : 0;
976
977 if ( ! empty( $_GET['min_price'] ) ) {
978 $args['query']['range']['meta._price.long']['gte'] = $_GET['min_price'];
979 }
980
981 if ( ! empty( $_GET['max_price'] ) ) {
982 $args['query']['range']['meta._price.long']['lte'] = $_GET['max_price'];
983 }
984
985 $args['query']['range']['meta._price.long']['boost'] = 2.0;
986 }
987 // phpcs:enable WordPress.Security.NonceVerification
988
989 return $args;
990 }
991
992 /**
993 * Prevent order fields from being removed.
994 *
995 * When Protected Content is enabled, all posts with password have their content removed.
996 * This can't happen for orders, as the order key is added in that field.
997 *
998 * @see https://github.com/10up/ElasticPress/issues/2726
999 *
1000 * @since 4.2.0
1001 * @param bool $skip Whether the password protected content should have their content, and meta removed
1002 * @param array $post_args Post arguments
1003 * @return bool
1004 */
1005 public function keep_order_fields( $skip, $post_args ) {
1006 $searchable_post_types = $this->get_admin_searchable_post_types();
1007
1008 if ( in_array( $post_args['post_type'], $searchable_post_types, true ) ) {
1009 return true;
1010 }
1011
1012 return $skip;
1013 }
1014
1015 /**
1016 * Add a new `_variations_skus` meta field to the product to be indexed in Elasticsearch.
1017 *
1018 * @since 4.2.0
1019 * @param array $post_meta Post meta
1020 * @param WP_Post $post Post object
1021 * @return array
1022 */
1023 public function add_variations_skus_meta( $post_meta, $post ) {
1024 if ( 'product' !== $post->post_type ) {
1025 return $post_meta;
1026 }
1027
1028 $product = wc_get_product( $post );
1029 if ( ! $product ) {
1030 return $post_meta;
1031 }
1032
1033 $variations_ids = $product->get_children();
1034
1035 $post_meta['_variations_skus'] = array_reduce(
1036 $variations_ids,
1037 function ( $variations_skus, $current_id ) {
1038 $variation = wc_get_product( $current_id );
1039 if ( ! $variation || ! $variation->exists() ) {
1040 return $variations_skus;
1041 }
1042 $variation_sku = $variation->get_sku();
1043 if ( ! $variation_sku ) {
1044 return $variations_skus;
1045 }
1046 $variations_skus[] = $variation_sku;
1047 return $variations_skus;
1048 },
1049 []
1050 );
1051
1052 return $post_meta;
1053 }
1054
1055 /**
1056 * Integrate ElasticPress with the WooCommerce Admin Product List.
1057 *
1058 * WooCommerce uses its `WC_Admin_List_Table_Products` class to control that screen. This
1059 * function adds all necessary hooks to bypass the default behavior and integrate with ElasticPress.
1060 * By default, WC runs a SQL query to get the Product IDs that match the list criteria and passes
1061 * that list of IDs to the main WP_Query. This integration changes that process to a single query, run
1062 * by ElasticPress.
1063 *
1064 * @since 4.2.0
1065 * @param array $query_vars Query vars.
1066 * @return array
1067 */
1068 public function admin_product_list_request_query( $query_vars ) {
1069 global $typenow, $wc_list_table;
1070
1071 // Return if not in the correct screen.
1072 if ( ! is_a( $wc_list_table, 'WC_Admin_List_Table_Products' ) || 'product' !== $typenow ) {
1073 return $query_vars;
1074 }
1075
1076 // Return if admin WP_Query integration is not turned on, i.e., Protect Content is not enabled.
1077 if ( ! has_filter( 'ep_admin_wp_query_integration', '__return_true' ) ) {
1078 return $query_vars;
1079 }
1080
1081 /**
1082 * Filter to skip integration with WooCommerce Admin Product List.
1083 *
1084 * @hook ep_woocommerce_integrate_admin_products_list
1085 * @since 4.2.0
1086 * @param {bool} $integrate True to integrate, false to preserve original behavior. Defaults to true.
1087 * @param {array} $query_vars Query vars.
1088 * @return {bool} New integrate value
1089 */
1090 if ( ! apply_filters( 'ep_woocommerce_integrate_admin_products_list', true, $query_vars ) ) {
1091 return $query_vars;
1092 }
1093
1094 add_action( 'pre_get_posts', [ $this, 'translate_args_admin_products_list' ], 12 );
1095
1096 // This short-circuits WooCommerce search for product IDs.
1097 add_filter( 'woocommerce_product_pre_search_products', '__return_empty_array' );
1098
1099 return $query_vars;
1100 }
1101
1102 /**
1103 * Apply the necessary changes to WP_Query in WooCommerce Admin Product List.
1104 *
1105 * @param WP_Query $query The WP Query being executed.
1106 */
1107 public function translate_args_admin_products_list( $query ) {
1108 // The `translate_args()` method sets it to `true` if we should integrate it.
1109 if ( ! $query->get( 'ep_integrate', false ) ) {
1110 return;
1111 }
1112
1113 // WooCommerce unsets the search term right after using it to fetch product IDs. Here we add it back.
1114 $search_term = ! empty( $_GET['s'] ) ? sanitize_text_field( $_GET['s'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1115 if ( ! empty( $search_term ) ) {
1116 $query->set( 's', sanitize_text_field( $search_term ) ); // phpcs:ignore WordPress.Security.NonceVerification
1117
1118 /**
1119 * Filter the fields used in WooCommerce Admin Product Search.
1120 *
1121 * ```
1122 * add_filter(
1123 * 'ep_woocommerce_admin_products_list_search_fields',
1124 * function ( $wc_admin_search_fields ) {
1125 * $wc_admin_search_fields['meta'][] = 'custom_field';
1126 * return $wc_admin_search_fields;
1127 * }
1128 * );
1129 * ```
1130 *
1131 * @hook ep_woocommerce_admin_products_list_search_fields
1132 * @since 4.2.0
1133 * @param {array} $wc_admin_search_fields Fields to be used in the WooCommerce Admin Product Search
1134 * @return {array} New fields
1135 */
1136 $search_fields = apply_filters(
1137 'ep_woocommerce_admin_products_list_search_fields',
1138 [
1139 'post_title',
1140 'post_content',
1141 'post_excerpt',
1142 'meta' => [
1143 '_sku',
1144 '_variations_skus',
1145 ],
1146 ]
1147 );
1148
1149 $query->set( 'search_fields', $search_fields );
1150 }
1151
1152 // 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()`.
1153 $product_type_query = $query->get( 'product_type', '' );
1154 $product_type_url = ! empty( $_GET['product_type'] ) ? sanitize_text_field( $_GET['product_type'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1155 $allowed_prod_types = [ 'virtual', 'downloadable' ];
1156 if ( empty( $product_type_query ) && ! empty( $product_type_url ) && in_array( $product_type_url, $allowed_prod_types, true ) ) {
1157 $meta_query = $query->get( 'meta_query', [] );
1158 $meta_query[] = [
1159 'key' => "_{$product_type_url}",
1160 'value' => 'yes',
1161 ];
1162 $query->set( 'meta_query', $meta_query );
1163 }
1164
1165 // Sets the meta query for `stock_status` if needed.
1166 $stock_status_query = $query->get( 'stock_status', '' );
1167 $stock_status_url = ! empty( $_GET['stock_status'] ) ? sanitize_text_field( $_GET['stock_status'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1168 $allowed_stock_status = [ 'instock', 'outofstock', 'onbackorder' ];
1169 if ( empty( $stock_status_query ) && ! empty( $stock_status_url ) && in_array( $stock_status_url, $allowed_stock_status, true ) ) {
1170 $meta_query = $query->get( 'meta_query', [] );
1171 $meta_query[] = [
1172 'key' => '_stock_status',
1173 'value' => $stock_status_url,
1174 ];
1175 $query->set( 'meta_query', $meta_query );
1176 }
1177 }
1178
1179 /**
1180 * Determines whether or not ES should be integrating with the provided query
1181 *
1182 * @param \WP_Query $query Query we might integrate with
1183 *
1184 * @return bool
1185 */
1186 protected function should_integrate_with_query( $query ) {
1187 // Lets make sure this doesn't interfere with the CLI
1188 if ( defined( 'WP_CLI' ) && WP_CLI ) {
1189 return false;
1190 }
1191
1192 /**
1193 * Filter to skip WP Query integration
1194 *
1195 * @hook ep_skip_query_integration
1196 * @param {bool} $skip True to skip
1197 * @param {WP_Query} $query WP Query to evaluate
1198 * @return {bool} New skip value
1199 */
1200 if ( apply_filters( 'ep_skip_query_integration', false, $query ) ||
1201 ( isset( $query->query_vars['ep_integrate'] ) && ! filter_var( $query->query_vars['ep_integrate'], FILTER_VALIDATE_BOOLEAN ) ) ) {
1202 return false;
1203 }
1204
1205 if ( ! Utils\is_integrated_request( $this->slug ) ) {
1206 return false;
1207 }
1208
1209 $product_name = $query->get( 'product', false );
1210
1211 $post_parent = $query->get( 'post_parent', false );
1212
1213 /**
1214 * Do nothing for single product queries
1215 */
1216 if ( ! empty( $product_name ) || $query->is_single() ) {
1217 return false;
1218 }
1219
1220 /**
1221 * ElasticPress does not yet support post_parent queries
1222 */
1223 if ( ! empty( $post_parent ) ) {
1224 return false;
1225 }
1226
1227 /**
1228 * If this is just a preview, let's not use Elasticsearch.
1229 */
1230 if ( $query->get( 'preview', false ) ) {
1231 return false;
1232 }
1233
1234 /**
1235 * Cant hook into WC API yet
1236 */
1237 if ( defined( 'WC_API_REQUEST' ) && WC_API_REQUEST ) {
1238 return false;
1239 }
1240
1241 return true;
1242 }
1243
1244 /**
1245 * Depending on the number of products display an admin notice in the custom sort screen for WooCommerce Products
1246 *
1247 * @since 4.4.0
1248 * @param array $notices Current ElasticPress admin notices
1249 * @return array
1250 */
1251 public function maybe_display_notice_about_product_ordering( $notices ) {
1252 global $pagenow, $wp_query;
1253
1254 /**
1255 * Make sure we're on edit.php in admin dashboard.
1256 */
1257 if ( ! is_admin() || 'edit.php' !== $pagenow || empty( $wp_query->query['orderby'] ) || 'menu_order title' !== $wp_query->query['orderby'] ) {
1258 return $notices;
1259 }
1260
1261 /* This filter is documented in /includes/classes/IndexHelper.php */
1262 $documents_per_page_sync = (int) apply_filters( 'ep_index_default_per_page', Utils\get_option( 'ep_bulk_setting', 350 ) );
1263 if ( $documents_per_page_sync >= $wp_query->found_posts ) {
1264 return $notices;
1265 }
1266
1267 $notices['woocommerce_custom_sort'] = [
1268 'html' => sprintf(
1269 /* translators: Sync Page URL */
1270 __( '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' ),
1271 Utils\get_sync_url()
1272 ),
1273 'type' => 'warning',
1274 'dismiss' => true,
1275 ];
1276
1277 return $notices;
1278 }
1279
1280 /**
1281 * Conditionally resync products after applying a custom order.
1282 *
1283 * @since 4.4.0
1284 * @param int $sorting_id ID of post dragged and dropped
1285 * @param array $menu_orders Post IDs and their new menu_order value
1286 */
1287 public function action_sync_on_woocommerce_sort_single( $sorting_id, $menu_orders ) {
1288 /* This filter is documented in /includes/classes/IndexHelper.php */
1289 $documents_per_page_sync = (int) apply_filters( 'ep_index_default_per_page', Utils\get_option( 'ep_bulk_setting', 350 ) );
1290 if ( $documents_per_page_sync < count( $menu_orders ) ) {
1291 return;
1292 }
1293
1294 $sync_manager = Indexables::factory()->get( 'post' )->sync_manager;
1295 foreach ( $menu_orders as $post_id => $order ) {
1296 $sync_manager->add_to_queue( $post_id );
1297 }
1298 }
1299 }
1300