PluginProbe
ElasticPress / 4.4.1
ElasticPress v4.4.1
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Feature / WooCommerce / WooCommerce.php

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

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