Changeset 3663491
- Timestamp:
- 08/24/2026 01:03:28 PM (2 months ago)
- Location:
- wpmelon-advanced-bulk-edit-for-woocommerce
- Files:
-
- 10 added
- 4 deleted
- 36 edited
- 1 copied
-
tags/1.2.0 (copied) (copied from wpmelon-advanced-bulk-edit-for-woocommerce/trunk )
-
tags/1.2.0/assets/dist/.vite/manifest.json (modified) (1 diff)
-
tags/1.2.0/assets/dist/assets/main-CPlqEen8.js (deleted)
-
tags/1.2.0/assets/dist/assets/main-D5Lgb2qa.css (added)
-
tags/1.2.0/assets/dist/assets/main-DuaYQxSj.css (deleted)
-
tags/1.2.0/assets/dist/assets/main-V9rY9XMr.js (added)
-
tags/1.2.0/includes/Admin.php (modified) (3 diffs)
-
tags/1.2.0/includes/Handlers/Attribute_Handler.php (modified) (1 diff)
-
tags/1.2.0/includes/Handlers/Attribute_Save_Handler.php (modified) (2 diffs)
-
tags/1.2.0/includes/Handlers/WP_Save_Hooks_Handler.php (added)
-
tags/1.2.0/includes/Helpers/Page_Size_Helper.php (added)
-
tags/1.2.0/includes/Helpers/Product_Helper.php (modified) (12 diffs)
-
tags/1.2.0/includes/Main.php (modified) (2 diffs)
-
tags/1.2.0/includes/REST/Export_Controller.php (modified) (16 diffs)
-
tags/1.2.0/includes/REST/Products_Controller.php (modified) (21 diffs)
-
tags/1.2.0/includes/REST/Settings_Controller.php (modified) (1 diff)
-
tags/1.2.0/includes/REST/User_Settings_Controller.php (modified) (7 diffs)
-
tags/1.2.0/includes/REST/Views_Controller.php (modified) (6 diffs)
-
tags/1.2.0/includes/Services/Export_Service.php (modified) (10 diffs)
-
tags/1.2.0/includes/Services/Filter_Service.php (modified) (2 diffs)
-
tags/1.2.0/includes/Services/Operations_Service.php (modified) (6 diffs)
-
tags/1.2.0/includes/Services/Product_Service.php (modified) (56 diffs)
-
tags/1.2.0/includes/Services/View_Service.php (modified) (9 diffs)
-
tags/1.2.0/readme.txt (modified) (6 diffs)
-
tags/1.2.0/uninstall.php (added)
-
tags/1.2.0/wpmelon-advanced-bulk-edit-for-woocommerce.php (modified) (3 diffs)
-
trunk/assets/dist/.vite/manifest.json (modified) (1 diff)
-
trunk/assets/dist/assets/main-CPlqEen8.js (deleted)
-
trunk/assets/dist/assets/main-D5Lgb2qa.css (added)
-
trunk/assets/dist/assets/main-DuaYQxSj.css (deleted)
-
trunk/assets/dist/assets/main-V9rY9XMr.js (added)
-
trunk/includes/Admin.php (modified) (3 diffs)
-
trunk/includes/Handlers/Attribute_Handler.php (modified) (1 diff)
-
trunk/includes/Handlers/Attribute_Save_Handler.php (modified) (2 diffs)
-
trunk/includes/Handlers/WP_Save_Hooks_Handler.php (added)
-
trunk/includes/Helpers/Page_Size_Helper.php (added)
-
trunk/includes/Helpers/Product_Helper.php (modified) (12 diffs)
-
trunk/includes/Main.php (modified) (2 diffs)
-
trunk/includes/REST/Export_Controller.php (modified) (16 diffs)
-
trunk/includes/REST/Products_Controller.php (modified) (21 diffs)
-
trunk/includes/REST/Settings_Controller.php (modified) (1 diff)
-
trunk/includes/REST/User_Settings_Controller.php (modified) (7 diffs)
-
trunk/includes/REST/Views_Controller.php (modified) (6 diffs)
-
trunk/includes/Services/Export_Service.php (modified) (10 diffs)
-
trunk/includes/Services/Filter_Service.php (modified) (2 diffs)
-
trunk/includes/Services/Operations_Service.php (modified) (6 diffs)
-
trunk/includes/Services/Product_Service.php (modified) (56 diffs)
-
trunk/includes/Services/View_Service.php (modified) (9 diffs)
-
trunk/readme.txt (modified) (6 diffs)
-
trunk/uninstall.php (added)
-
trunk/wpmelon-advanced-bulk-edit-for-woocommerce.php (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/assets/dist/.vite/manifest.json
r3549389 r3663491 5 5 }, 6 6 "assets/src/main.tsx": { 7 "file": "assets/main- CPlqEen8.js",7 "file": "assets/main-V9rY9XMr.js", 8 8 "name": "main", 9 9 "src": "assets/src/main.tsx", 10 10 "isEntry": true, 11 11 "css": [ 12 "assets/main-D uaYQxSj.css"12 "assets/main-D5Lgb2qa.css" 13 13 ], 14 14 "assets": [ -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Admin.php
r3549389 r3663491 20 20 'wcabe-g2', 21 21 [ $this, 'render_page' ] 22 ); 23 } 24 25 /** 26 * Add a shortcut to the Bulk Edit page in the WordPress admin bar. 27 * 28 * Gated by the per-user `showAdminBarShortcut` setting (default on) and the 29 * `manage_woocommerce` capability. Fires in both admin and front-end contexts 30 * wherever the admin bar renders for a capable user. 31 * 32 * @param \WP_Admin_Bar $admin_bar The admin bar instance. 33 */ 34 public function add_admin_bar_link( \WP_Admin_Bar $admin_bar ): void { 35 if ( ! current_user_can( 'manage_woocommerce' ) ) { 36 return; 37 } 38 39 $settings = REST\User_Settings_Controller::get_all(); 40 if ( empty( $settings['showAdminBarShortcut'] ) ) { 41 return; 42 } 43 44 $admin_bar->add_node( 45 [ 46 'id' => 'wcabe-g2-shortcut', 47 'title' => '<span class="ab-icon dashicons dashicons-edit" aria-hidden="true"></span><span class="ab-label">' 48 . esc_html__( 'Bulk Edit Products', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 49 . '</span>', 50 'href' => admin_url( 'admin.php?page=wcabe-g2' ), 51 'meta' => [ 52 'title' => __( 'WPMelon Advanced Bulk Edit for WooCommerce', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 53 ], 54 ] 22 55 ); 23 56 } … … 51 84 'dimensionUnit' => get_option( 'woocommerce_dimension_unit', 'cm' ), 52 85 'isPro' => defined( 'WCABE_G2_PRO' ) && WCABE_G2_PRO, 86 'cogsEnabled' => Helpers\Product_Helper::is_cogs_enabled(), 87 // URL protocols the server will accept for the Product URL field. 88 // Localized rather than hardcoded client-side because 89 // wp_allowed_protocols() is filterable — a site that adds a scheme must 90 // not have the client reject it before the request reaches the 91 // authoritative server-side check. 92 'allowedProtocols' => array_values( wp_allowed_protocols() ), 53 93 'operationsLimit' => REST\Operations_Controller::OPERATIONS_LIMIT, 94 // Server-authoritative page-size ceiling. The client clamps every page 95 // size (picker, custom input, view apply, dirty baselines) to this, so a 96 // site that LOWERS wcabe_g2_max_per_page never shows a size the products 97 // route would silently reduce. 98 'maxPageSize' => Helpers\Page_Size_Helper::get_max(), 99 // Variable-capable type slugs ('variable' + registered WC_Product_Variable 100 // subclasses, e.g. 'variable-subscription') so the frontend's expand / 101 // "Varies" / variation gates match the backend's (#137). 102 'variableProductTypes' => Services\Product_Service::get_variable_product_types(), 54 103 'attributesSchema' => Services\Attribute_Service::get_attribute_schema(), 104 // Shipping classes as [ slug, name ]. Localized rather than fetched 105 // because the grid cell editor, the bulk-edit field and the paste 106 // resolver all need the vocabulary synchronously, before any request 107 // resolves. The filter panel's copy arrives via GET /filters. 108 'shippingClasses' => Helpers\Product_Helper::get_shipping_class_terms(), 55 109 'pluginSettings' => REST\Settings_Controller::get_all(), 56 110 'userSettings' => REST\User_Settings_Controller::get_all(), 111 // Sync cursor (transport, not a setting) so the client seeds its _rev counter 112 // above the last persisted value — see get_stored_rev()/store/ui.ts (review #4). 113 'userSettingsRev' => REST\User_Settings_Controller::get_stored_rev(), 57 114 ]; 58 115 … … 111 168 'wcabe-g2-admin', 112 169 $dist_url . $entry['file'], 113 [ 'wp-element', 'wp-i18n' ],170 [ 'wp-element', 'wp-i18n', 'wp-api-fetch' ], 114 171 WCABE_G2_VERSION, 115 172 true -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Handlers/Attribute_Handler.php
r3549389 r3663491 46 46 47 47 /** 48 * Ensure term maps are populated. Lazy fallback for contexts 49 * where before_format_products doesn't fire (batch_update, get_variations). 48 * Ensure term maps are populated. Lazy fallback for contexts where 49 * before_format_products doesn't fire (get_variations). 50 * 51 * batch_update() DOES fire it now, from its phase-2 formatting block — do not "clean up" 52 * that do_action on the assumption this fallback covers it. 50 53 */ 51 54 private function ensure_term_maps(): void { -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Handlers/Attribute_Save_Handler.php
r3549389 r3663491 100 100 */ 101 101 public function validate_attribute_fields( array $errors, \WC_Product $product, array $changes ): array { 102 // Parent products only. Variation attribute selections are owned by Pro's 103 // variation handler (#131); guard here so this parent handler never processes 104 // a variation even once Pro adds attr_* to the variation allowlist (the 105 // handler builds WC_Product_Attribute objects, which is wrong for variations). 106 if ( $product instanceof \WC_Product_Variation ) { 107 return $errors; 108 } 109 102 110 $lookup = self::get_field_lookup(); 103 111 … … 164 172 */ 165 173 public function save_attribute_fields( \WC_Product $product, array $changes, array $allowed_fields ): void { 174 // Parent products only — see validate_attribute_fields(). Pro's variation 175 // handler persists variation attribute selections (#131). 176 if ( $product instanceof \WC_Product_Variation ) { 177 return; 178 } 179 166 180 $this->new_terms_created = false; 167 181 $lookup = self::get_field_lookup(); -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Helpers/Product_Helper.php
r3549389 r3663491 13 13 14 14 /** 15 * Request-level cache of the shipping class term list. 16 * 17 * @var array|null 18 */ 19 private static $shipping_classes_cache = null; 20 21 /** 22 * Every shipping class on the site, as [ 'slug' => ..., 'name' => ... ]. 23 * 24 * Sorted by name, empty classes included — this is a picker list, not a 25 * report. No 'count': WooCommerce registers product_shipping_class for 26 * variations as well as products, so a term whose only users are variations 27 * carries count > 0 while the (deliberately parent-level) shipping class 28 * filter returns nothing for it. Shipping a number that contradicts the 29 * filter is worse than shipping no number. 30 * 31 * @return array<int, array{slug: string, name: string}> 32 */ 33 public static function get_shipping_class_terms(): array { 34 self::register_shipping_class_hooks(); 35 36 if ( null !== self::$shipping_classes_cache ) { 37 return self::$shipping_classes_cache; 38 } 39 40 $terms = get_terms( [ 41 'taxonomy' => 'product_shipping_class', 42 'hide_empty' => false, 43 'orderby' => 'name', 44 ] ); 45 46 if ( is_wp_error( $terms ) || empty( $terms ) ) { 47 self::$shipping_classes_cache = []; 48 return []; 49 } 50 51 $result = []; 52 foreach ( $terms as $term ) { 53 $result[] = [ 54 'slug' => $term->slug, 55 'name' => wp_specialchars_decode( $term->name ), 56 ]; 57 } 58 59 self::$shipping_classes_cache = $result; 60 return $result; 61 } 62 63 /** 64 * A product's OWN shipping class slug, or '' when it has none. 65 * 66 * 'edit' context is load-bearing, and get_shipping_class() must never be 67 * substituted here. That getter resolves through get_shipping_class_id() in 68 * default (view) context, and WC_Product_Variation::get_shipping_class_id() 69 * falls back to the PARENT's class in view context. A variation that 70 * inherits would then display the parent's class as though it were its own, 71 * the user would "change" a value that was never set, and the diff engine 72 * would compare against a value the variation does not store. '' here means 73 * exactly "this row stores no class of its own" — rendered as "Same as 74 * parent" on variations and "No shipping class" on parents. 75 * 76 * @param \WC_Product $product 77 * @return string Term slug, or '' when unset (or the term has been deleted). 78 */ 79 public static function get_shipping_class_slug( \WC_Product $product ): string { 80 $term_id = (int) $product->get_shipping_class_id( 'edit' ); 81 if ( $term_id <= 0 ) { 82 return ''; 83 } 84 85 $term = get_term( $term_id, 'product_shipping_class' ); 86 if ( ! $term || is_wp_error( $term ) ) { 87 return ''; 88 } 89 90 return (string) $term->slug; 91 } 92 93 /** 94 * Clear the request-level shipping class cache. 95 * 96 * Also the callback registered on the term CRUD hooks below, so the cache 97 * self-invalidates whenever a shipping class is created, renamed, or 98 * deleted — a request that lists classes, creates one, then lists again 99 * (or a test suite that creates a term as a fixture) sees it immediately, 100 * with no manual clear to remember. Still exposed publicly for tests that 101 * insert terms directly via wp_insert_term() outside any of those hooks' 102 * normal request lifecycle (e.g. across PHPUnit test classes sharing one 103 * process, where fixtures roll back but this static would otherwise not). 104 */ 105 public static function clear_shipping_class_cache(): void { 106 self::$shipping_classes_cache = null; 107 } 108 109 /** 110 * Wire up cache invalidation on shipping-class term CRUD. 111 * 112 * Called unconditionally on every get_shipping_class_terms() rather than 113 * behind an "already registered" flag: WP_UnitTestCase snapshots $wp_filter 114 * before the first test and restores that snapshot after EVERY test's 115 * tearDown, silently stripping any add_action() call made mid-test. A 116 * once-only static flag would go stale the moment that happens — it would 117 * report "registered" while the underlying hook was actually gone. Calling 118 * add_action() every time is cheap and safe: WP_Hook computes the same 119 * identity string for this array callback on every call, so re-adding it 120 * overwrites the same slot rather than creating a duplicate. 121 */ 122 private static function register_shipping_class_hooks(): void { 123 add_action( 'create_product_shipping_class', [ self::class, 'clear_shipping_class_cache' ] ); 124 add_action( 'edited_product_shipping_class', [ self::class, 'clear_shipping_class_cache' ] ); 125 add_action( 'delete_product_shipping_class', [ self::class, 'clear_shipping_class_cache' ] ); 126 } 127 128 /** 129 * Whether WooCommerce's Cost of Goods Sold feature is enabled. 130 * 131 * Intentionally NOT memoized: the underlying option already lives in WP's 132 * alloptions cache, so repeated calls are cheap, and a static cache would 133 * go stale when the option is toggled within a single request (e.g. tests). 134 * Callers must gate WC's get_cogs_value()/set_cogs_value() on this — those 135 * methods emit wc_doing_it_wrong() when the feature is disabled. 136 * 137 * @return bool 138 */ 139 public static function is_cogs_enabled(): bool { 140 return class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) 141 && \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' ); 142 } 143 144 /** 145 * Upload date of an image attachment, or '' when there is no usable image. 146 * 147 * The single source of truth for the "Image uploaded" value: the grid/CSV 148 * cell, the per-variation filter predicate, and the SQL filter must all agree 149 * on which _thumbnail_id values count. A stale ID (deleted attachment) or one 150 * pointing at a non-attachment post yields '' here, mirroring the query's 151 * post_type = 'attachment' join gate. 152 * 153 * @param int $image_id Attachment ID from _thumbnail_id. 154 * @return string 'Y-m-d H:i:s' in site time, or '' when unusable. 155 */ 156 public static function get_image_upload_date( int $image_id ): string { 157 if ( $image_id <= 0 || 'attachment' !== get_post_type( $image_id ) ) { 158 return ''; 159 } 160 161 // 'raw' context — the SQL filter compares the raw post_date column, and 162 // display context would run the post_date filter, breaking that parity. 163 return (string) get_post_field( 'post_date', $image_id, 'raw' ); 164 } 165 166 /** 15 167 * Format a WC_Product into a flat array for the grid. 16 168 * 17 169 * @param \WC_Product $product 170 * @param array|null $ref_map Pre-resolved upsell/cross-sell descriptors from 171 * build_product_ref_map(). Every multi-item caller MUST 172 * pass one — omitting it resolves this product's refs on 173 * its own, which inside a loop is an N+1. Null is for the 174 * genuine singleton (Operations_Service::create_product). 18 175 * @return array 19 176 */ 20 public static function format_product( \WC_Product $product ): array {177 public static function format_product( \WC_Product $product, ?array $ref_map = null ): array { 21 178 $image_id = $product->get_image_id(); 22 179 $thumbnail_url = $image_id … … 24 181 : ''; 25 182 183 // instanceof, not is_type('variable'): third-party variable subclasses 184 // (variable-subscription, …) carry countable variations too (#137). The 185 // count doubles as the frontend's expand/interleave signal. 26 186 $variation_count = 0; 27 if ( $product ->is_type( 'variable' )) {187 if ( $product instanceof \WC_Product_Variable ) { 28 188 $variation_count = count( $product->get_children() ); 29 189 } … … 37 197 $tags = self::resolve_term_names( $product->get_tag_ids(), 'product_tag' ); 38 198 39 // Sale dates — not on variable parents .199 // Sale dates — not on variable parents (incl. variable subclasses). 40 200 $date_on_sale_from = ''; 41 201 $date_on_sale_to = ''; 42 if ( ! $product ->is_type( 'variable' )) {202 if ( ! $product instanceof \WC_Product_Variable ) { 43 203 $from = $product->get_date_on_sale_from(); 44 204 $to = $product->get_date_on_sale_to(); … … 46 206 $date_on_sale_to = $to ? $to->date( 'Y-m-d' ) : ''; 47 207 } 208 209 // Publish date (post_date) and last-modified (post_modified). Present on 210 // every product post regardless of type. Full date-time (Y-m-d H:i:s) in 211 // site timezone, matching WooCommerce's native date_created/date_modified 212 // (a WC_DateTime backed by the post_date DATETIME column). 213 $created = $product->get_date_created(); 214 $modified = $product->get_date_modified(); 48 215 49 216 $gallery_ids = $product->get_gallery_image_ids(); … … 54 221 $gallery_urls[] = $url ?: ''; 55 222 } 223 } 224 225 // Linked products. 'edit' context — the view getters run through 226 // woocommerce_product_get_upsell_ids, and the grid writes this value straight 227 // back, so a third-party projection would overwrite the stored list. 228 $upsell_ids = array_map( 'intval', $product->get_upsell_ids( 'edit' ) ); 229 $cross_sell_ids = array_map( 'intval', $product->get_cross_sell_ids( 'edit' ) ); 230 231 if ( null === $ref_map ) { 232 $ref_map = self::resolve_product_ref_ids( array_merge( $upsell_ids, $cross_sell_ids ) ); 56 233 } 57 234 … … 77 254 'categories' => $categories, 78 255 'tags' => $tags, 256 // Linked products (WooCommerce upsells / cross-sells). Ordered — WC renders 257 // them in stored order — so never rebuild these from a set. Parent-only: 258 // deliberately omitted from format_variation(). 259 'upsell_ids' => self::project_product_refs( $upsell_ids, $ref_map ), 260 'cross_sell_ids' => self::project_product_refs( $cross_sell_ids, $ref_map ), 79 261 'description' => $product->get_description(), 80 262 'short_description' => $product->get_short_description(), … … 89 271 'downloadable' => $product->get_downloadable(), 90 272 'backorders' => $product->get_backorders(), 273 // Cost of Goods (WooCommerce native COGS). Getter only when the 274 // feature is on — it emits wc_doing_it_wrong() otherwise. Key is 275 // always present (null when off) so the TS type stays non-optional. 276 'cogs_value' => self::is_cogs_enabled() ? $product->get_cogs_value() : null, 277 // Publish date (post_date) and last-modified (post_modified), full date-time 278 // (Y-m-d H:i:s, site timezone). Editable "Published on" + read-only 279 // "Modified on". Parent products only — deliberately omitted from 280 // format_variation() (blank on variation rows). 281 'date_created' => $created ? $created->date( 'Y-m-d H:i:s' ) : '', 282 'date_modified' => $modified ? $modified->date( 'Y-m-d H:i:s' ) : '', 283 // Featured image upload date (the attachment's post_date). Read-only 284 // display + export + the "Image uploaded" filter. '' when the product 285 // has no image, or _thumbnail_id is stale / not an attachment. 286 // 'edit' context deliberately, unlike $image_id above: the SQL filter 287 // reads raw _thumbnail_id, so a plugin hooking 288 // woocommerce_product_get_image_id (placeholder images, multilingual 289 // media mapping) must not make this date diverge from what the filter 290 // matches. thumbnail_url stays view-context — it renders whatever WC 291 // would display. 292 'image_date' => self::get_image_upload_date( (int) $product->get_image_id( 'edit' ) ), 293 // Menu order (native wp_posts.menu_order). Signed integer, default 0. 294 'menu_order' => (int) $product->get_menu_order(), 295 // Button text (External/Affiliate products' add-to-cart button label). 296 // The getter only exists on WC_Product_External; every other type 297 // emits '' so the key is always present (VariesRenderer shows a dash). 298 'button_text' => $product instanceof \WC_Product_External ? $product->get_button_text() : '', 299 // Product URL (External/Affiliate products' destination link). Like 300 // button_text, the getter only exists on WC_Product_External; every 301 // other type emits '' so the key is always present. 302 // 'edit' context deliberately: view context runs third-party 303 // woocommerce_product_get_product_url filters, and the SQL filter 304 // matches the raw stored _product_url meta — a filtered display value 305 // would make the editable grid value diverge from what the filter 306 // finds. (WC's getter wraps esc_url_raw() in both contexts.) 307 'product_url' => $product instanceof \WC_Product_External ? $product->get_product_url( 'edit' ) : '', 308 // Shipping class slug ('' = none). Slug rather than term ID because 309 // WooCommerce's own shipping_class query var matches on slug, so the 310 // grid, the filter, the save payload and the CSV all speak one format. 311 'shipping_class' => self::get_shipping_class_slug( $product ), 91 312 ]; 92 313 … … 96 317 /** 97 318 * Resolve term IDs to [ id, name ] arrays via a single get_terms query. 319 * 320 * Public so Pro's history reader can reuse the same resolution (including 321 * the empty-include and hide_empty=false guards) for its term-name display 322 * metadata. 98 323 * 99 324 * @param int[] $term_ids … … 101 326 * @return array Array of [ 'id' => int, 'name' => string ]. 102 327 */ 103 p rivatestatic function resolve_term_names( array $term_ids, string $taxonomy ): array {328 public static function resolve_term_names( array $term_ids, string $taxonomy ): array { 104 329 if ( empty( $term_ids ) ) { 105 330 return []; … … 126 351 127 352 /** 353 * Human-readable display name for a variation ("Parent – Blue, Large"). 354 * 355 * WC stores just the parent title in post_title for variations, so we append 356 * attribute values ourselves. If a custom title has been set on the variation 357 * that differs from the parent, respect it. 358 * 359 * Deliberately NOT WC's get_formatted_name(): that returns HTML (it embeds a 360 * "#<id>" and a <span class="description"> block), which must never reach a 361 * JSON response. Shared by format_variation() and resolve_product_ref_ids() 362 * so a variation reads the same everywhere it is named. 363 * 364 * @param \WC_Product_Variation $variation 365 * @return string Plain text, not HTML. Not yet specialchars-decoded. 366 */ 367 public static function variation_display_name( \WC_Product_Variation $variation ): string { 368 $parent_title = get_the_title( $variation->get_parent_id() ); 369 $variation_title = $variation->get_title(); 370 371 if ( '' !== $variation_title && $variation_title !== $parent_title ) { 372 return $variation_title; 373 } 374 375 $attrs = $variation->get_attributes(); 376 ksort( $attrs ); 377 $term_names = []; 378 foreach ( $attrs as $taxonomy => $slug ) { 379 if ( '' === $slug ) { 380 continue; // Skip "Any" attributes. 381 } 382 $term = get_term_by( 'slug', $slug, $taxonomy ); 383 if ( $term && ! is_wp_error( $term ) ) { 384 $term_names[] = $term->name; 385 } else { 386 $term_names[] = $slug; 387 } 388 } 389 390 $name = $parent_title; 391 if ( ! empty( $term_names ) ) { 392 $name .= ' – ' . implode( ', ', $term_names ); 393 } 394 395 return $name; 396 } 397 398 /** 399 * Normalise a list of product/variation IDs: positive ints only, deduped 400 * (FIRST occurrence wins), caller order preserved, optionally clamped. 401 * 402 * intval + "> 0", NOT absint: absint( -7 ) is 7, which would silently read the 403 * wrong product. The REST layer sanitises through this for the same reason. 404 * 405 * $limit clamps AFTER normalisation, deliberately. Clamping the raw input first 406 * would let duplicates or junk in the leading entries eat the budget — 407 * [5,5,5,7,9] with a limit of 3 must yield [5,7,9], not [5]. 408 * 409 * @param int[] $ids 410 * @param int|null $limit Optional cap, applied to the normalised list. 411 * @return int[] 412 */ 413 public static function normalize_product_ids( array $ids, ?int $limit = null ): array { 414 $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ), static fn( $id ) => $id > 0 ) ) ); 415 416 if ( null !== $limit ) { 417 $ids = array_slice( $ids, 0, max( 0, $limit ) ); 418 } 419 420 return $ids; 421 } 422 423 /** 424 * Drop WordPress's in-process object cache, if the active backend supports it. 425 * 426 * Long export loops prime post/meta/term caches on every page and nothing ever 427 * evicts them, so the runtime cache becomes the dominant memory consumer once 428 * row accumulation is gone. Each page re-primes what it needs, so dropping the 429 * in-process layer between chunks is safe — it costs a few re-priming queries 430 * per chunk and bounds memory in exchange. 431 * 432 * Three-tier guard. wp_cache_flush_runtime() exists as a compat shim from WP 6.0, 433 * but it emits _doing_it_wrong() and returns false when the active object-cache 434 * drop-in doesn't support it — and wp_cache_supports() itself only exists from 435 * WP 6.1. So: ask wp_cache_supports() when it is available; on 6.0 call the 436 * function only for the core default cache, which does support it; otherwise 437 * skip. This is an optimisation, never worth a _doing_it_wrong() notice. 438 */ 439 public static function flush_runtime_cache(): void { 440 if ( function_exists( 'wp_cache_supports' ) ) { 441 if ( wp_cache_supports( 'flush_runtime' ) ) { 442 wp_cache_flush_runtime(); 443 } 444 return; 445 } 446 447 if ( function_exists( 'wp_cache_flush_runtime' ) && ! wp_using_ext_object_cache() ) { 448 wp_cache_flush_runtime(); 449 } 450 } 451 452 /** 453 * Normalise a list of product/variation IDs and prime every cache a read of them needs. 454 * 455 * Two primes, not one. The first covers the ids themselves (posts + meta for _sku + terms 456 * for product_type). The second covers the PARENTS of any variation in the list: a 457 * variation is named from its parent's title, so without this each variation's 458 * get_the_title( parent ) is an individual query. 459 * 460 * Normalisation (positive-int filter, dedupe, clamp-after-dedupe) is delegated to 461 * normalize_product_ids() — see that method for why absint is wrong here and why 462 * $limit is applied last. 463 * 464 * @param int[] $ids 465 * @param int|null $limit Optional cap, applied to the normalised list. 466 * @return int[] The normalised, deduped, positive ids, in first-occurrence order. 467 */ 468 public static function prime_product_caches( array $ids, ?int $limit = null ): array { 469 $ids = self::normalize_product_ids( $ids, $limit ); 470 471 if ( empty( $ids ) ) { 472 return []; 473 } 474 475 _prime_post_caches( $ids, true, true ); 476 477 $parent_ids = []; 478 foreach ( $ids as $id ) { 479 $post = get_post( $id ); 480 if ( $post && 'product_variation' === $post->post_type && $post->post_parent ) { 481 $parent_ids[] = (int) $post->post_parent; 482 } 483 } 484 485 $parent_ids = array_values( array_diff( array_unique( $parent_ids ), $ids ) ); 486 if ( ! empty( $parent_ids ) ) { 487 _prime_post_caches( $parent_ids, true, true ); 488 } 489 490 return $ids; 491 } 492 493 /** 494 * Resolve a flat list of product IDs into upsell/cross-sell ref descriptors. 495 * 496 * Deliberately does NOT construct a WC_Product per ID. wc_get_product() runs the 497 * full data-store read — and for variable parents that means get_children(), a DB 498 * query each. Measured on a 100-row page: wc_get_product()-per-ref costs ~227 499 * queries; reading the primed post cache instead costs 0 for product refs. Only 500 * variation refs fall back to wc_get_product(), because naming them needs their 501 * attribute terms; that cost saturates (WP's term cache amortizes a store's small 502 * attribute vocabulary), so it is bounded by vocabulary, not by ref count. 503 * 504 * post_type is checked explicitly: the write path stores any positive ID without an 505 * existence check (dead refs are preserved by design — see Product_Service:: 506 * normalize_product_refs), so a list can legitimately hold a page or post ID from a 507 * bad import or a paste. Without this gate get_post() would return a valid WP_Post 508 * and we would render that page as a simple product. 509 * 510 * @param int[] $ids 511 * @return array<int, array> Map of id => [ id, name, sku, type, status, missing ]. 512 */ 513 public static function resolve_product_ref_ids( array $ids ): array { 514 $ids = self::prime_product_caches( $ids ); 515 516 if ( empty( $ids ) ) { 517 return []; 518 } 519 520 $map = []; 521 522 foreach ( $ids as $id ) { 523 $post = get_post( $id ); 524 525 if ( ! $post || ! in_array( $post->post_type, [ 'product', 'product_variation' ], true ) ) { 526 $map[ $id ] = self::missing_product_ref( $id ); 527 continue; 528 } 529 530 if ( 'product_variation' === $post->post_type ) { 531 $variation = wc_get_product( $id ); 532 if ( ! $variation instanceof \WC_Product_Variation ) { 533 $map[ $id ] = self::missing_product_ref( $id ); 534 continue; 535 } 536 $map[ $id ] = [ 537 'id' => $id, 538 'name' => wp_specialchars_decode( self::variation_display_name( $variation ) ), 539 // 'edit' context: a SKU-less variation must resolve to its OWN (empty) SKU, 540 // never the parent's. WC_Product_Variation::get_sku() inherits the parent SKU 541 // in view context, so the default read would export the parent's SKU for the 542 // variation — and WC import maps that SKU back to the PARENT, silently 543 // retargeting the link from variation to parent. The inherited SKU also feeds 544 // the picker chips, so a chip search for the shown SKU would find the parent. 545 // Matches the plain-product arm below, which reads raw _sku meta for the same 546 // persistence-safety. 547 'sku' => (string) $variation->get_sku( 'edit' ), 548 'type' => 'variation', 549 'status' => $post->post_status, 550 'missing' => false, 551 ]; 552 continue; 553 } 554 555 // Plain product — read straight off the primed caches. No WC_Product. 556 $terms = get_the_terms( $id, 'product_type' ); 557 $type = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0]->slug : 'simple'; 558 559 $map[ $id ] = [ 560 'id' => $id, 561 'name' => wp_specialchars_decode( $post->post_title ), 562 'sku' => (string) get_post_meta( $id, '_sku', true ), 563 'type' => $type, 564 'status' => $post->post_status, 565 'missing' => false, 566 ]; 567 } 568 569 return $map; 570 } 571 572 /** 573 * Gather the distinct upsell/cross-sell union OWNED BY these products, and resolve it. 574 * 575 * Note the direction: this takes OWNERS and returns descriptors for the products they 576 * point AT. Do not use it to resolve an arbitrary list of IDs (e.g. search results) — 577 * that is resolve_product_ref_ids(), and passing search hits here would hand back each 578 * hit's upsells instead of the hit itself. 579 * 580 * Reads 'edit' context: the default view getters run through 581 * woocommerce_product_get_upsell_ids, which third-party code can filter. The grid 582 * round-trips this value straight back into a write (open picker → add one → Apply), 583 * so a filtered projection would silently overwrite the stored list. 584 * 585 * @param \WC_Product[] $owners 586 * @return array<int, array> Map of id => ref descriptor. 587 */ 588 public static function build_product_ref_map( array $owners ): array { 589 $ref_ids = []; 590 591 foreach ( $owners as $owner ) { 592 if ( ! $owner instanceof \WC_Product || $owner->is_type( 'variation' ) ) { 593 continue; // Variations do not own these fields. 594 } 595 foreach ( $owner->get_upsell_ids( 'edit' ) as $ref_id ) { 596 $ref_ids[] = (int) $ref_id; 597 } 598 foreach ( $owner->get_cross_sell_ids( 'edit' ) as $ref_id ) { 599 $ref_ids[] = (int) $ref_id; 600 } 601 } 602 603 if ( empty( $ref_ids ) ) { 604 return []; // Stores that don't use these fields pay nothing. 605 } 606 607 return self::resolve_product_ref_ids( $ref_ids ); 608 } 609 610 /** 611 * Descriptor for a ref whose target no longer exists (or was never a product). 612 * 613 * @param int $id 614 * @return array 615 */ 616 private static function missing_product_ref( int $id ): array { 617 return [ 618 'id' => $id, 619 'name' => null, 620 'sku' => null, 621 'type' => null, 622 'status' => null, 623 'missing' => true, 624 ]; 625 } 626 627 /** 628 * Project an ordered ID list onto a ref map, preserving order. 629 * 630 * @param int[] $ids 631 * @param array $ref_map 632 * @return array[] 633 */ 634 private static function project_product_refs( array $ids, array $ref_map ): array { 635 return array_values( array_map( 636 static fn( int $id ) => $ref_map[ $id ] ?? self::missing_product_ref( $id ), 637 $ids 638 ) ); 639 } 640 641 /** 128 642 * Format a WC_Product_Variation into a flat array for the grid. 129 643 * … … 141 655 $to = $variation->get_date_on_sale_to(); 142 656 143 // Build variation display name. 144 // WC stores just the parent title in post_title for variations, so we 145 // append attribute values ourselves. If a custom title has been set on 146 // the variation that differs from the parent, respect it. 147 $parent_title = get_the_title( $variation->get_parent_id() ); 148 $variation_title = $variation->get_title(); 149 150 if ( '' !== $variation_title && $variation_title !== $parent_title ) { 151 $name = $variation_title; 152 } else { 153 $attrs = $variation->get_attributes(); 154 ksort( $attrs ); 155 $term_names = []; 156 foreach ( $attrs as $taxonomy => $slug ) { 157 if ( '' === $slug ) { 158 continue; // Skip "Any" attributes. 159 } 160 $term = get_term_by( 'slug', $slug, $taxonomy ); 161 if ( $term && ! is_wp_error( $term ) ) { 162 $term_names[] = $term->name; 163 } else { 164 $term_names[] = $slug; 165 } 166 } 167 $name = $parent_title; 168 if ( ! empty( $term_names ) ) { 169 $name .= ' – ' . implode( ', ', $term_names ); 170 } 171 } 657 $name = self::variation_display_name( $variation ); 172 658 173 659 $data = [ … … 199 685 'downloadable' => $variation->get_downloadable(), 200 686 'backorders' => $variation->get_backorders(), 687 // Cost of Goods — variation's own defined value (null = inherit 688 // parent). Getter only when the feature is on (see format_product). 689 'cogs_value' => self::is_cogs_enabled() ? $variation->get_cogs_value() : null, 690 // Menu order (native wp_posts.menu_order) — the variation's sort 691 // position within its parent. Signed integer, default 0. 692 'menu_order' => (int) $variation->get_menu_order(), 693 // Featured image upload date. DELIBERATELY present on variations, 694 // unlike date_created/date_modified which are parent-only: a variation 695 // owns its own _thumbnail_id, and the "Image uploaded" filter narrows 696 // variation rows by it. Do not "align" this with the other date fields. 697 'image_date' => self::get_image_upload_date( (int) $image_id ), 698 // Shipping class — the variation's OWN class. '' means "Same as 699 // parent", which is why get_shipping_class_slug() reads edit context: 700 // WC's view-context getter would inherit the parent's class here and 701 // make every inheriting variation look like it stores an override. 702 'shipping_class' => self::get_shipping_class_slug( $variation ), 201 703 ]; 202 704 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Main.php
r3549389 r3663491 29 29 $admin = new Admin(); 30 30 add_action( 'admin_menu', [ $admin, 'register_menu' ] ); 31 add_action( 'admin_bar_menu', [ $admin, 'add_admin_bar_link' ], 99 ); 31 32 add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); 32 33 … … 40 41 // Debug tracing — appends server metrics to REST responses when enabled. 41 42 new Handlers\Debug_Trace_Handler(); 43 44 // WordPress save-hook compatibility — Free feature, controlled by plugin setting. 45 new Handlers\WP_Save_Hooks_Handler(); 42 46 } 43 47 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Export_Controller.php
r3549389 r3663491 11 11 use WCABE_G2\Helpers\Product_Helper; 12 12 use WCABE_G2\Services\Export_Service; 13 use WCABE_G2\Services\Filter_Service; 13 14 use WCABE_G2\Services\Product_Service; 15 use WCABE_G2\Services\View_Service; 14 16 15 17 defined( 'ABSPATH' ) || exit; … … 41 43 'default' => [], 42 44 'items' => [ 'type' => 'integer' ], 45 // NOT absint: absint( -7 ) is 7, which would export an unrelated 46 // product. Normalising here keeps the route consistent with the 47 // service contract (positive ints, deduped, first occurrence wins). 43 48 'sanitize_callback' => function ( $value ) { 44 49 if ( ! is_array( $value ) ) { 45 50 return []; 46 51 } 47 return array_filter( array_map( 'absint', $value ));52 return Product_Helper::normalize_product_ids( $value ); 48 53 }, 49 54 ], … … 59 64 'sanitize_callback' => 'sanitize_text_field', 60 65 ], 66 'search_name_mode' => [ 67 'description' => __( 'Title match mode: all (every word, default), any (any word), or phrase (exact phrase).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 68 'type' => 'string', 69 'default' => 'all', 70 'enum' => Product_Service::TITLE_SEARCH_MODES, 71 'sanitize_callback' => 'sanitize_text_field', 72 ], 61 73 'search_sku' => [ 62 'description' => __( 'Search by SKU. Comma-separated for exact match.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 63 'type' => 'string', 64 'sanitize_callback' => 'sanitize_text_field', 74 /* translators: %d: maximum number of comma-separated SKU values. */ 75 'description' => sprintf( __( 'Search by SKU. Comma-separate multiple values to match any of them under the selected mode (capped at %d values).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), Product_Service::SKU_SEARCH_MAX_TOKENS ), 76 'type' => 'string', 77 'sanitize_callback' => 'sanitize_text_field', 78 ], 79 'search_sku_mode' => [ 80 'description' => __( 'SKU match mode: contains, is_exactly, not_contains, starts_with, or ends_with. Comma-separated values match any of them (none of them for not_contains). Omit or send an empty string for the legacy behaviour: comma-separated = exact, single value = partial.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 81 'type' => 'string', 82 'default' => '', 83 'enum' => array_merge( [ '' ], Product_Service::SKU_SEARCH_MODES ), 84 'sanitize_callback' => 'sanitize_text_field', 85 ], 86 'product_url_operator' => [ 87 'description' => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 88 'type' => 'string', 89 'default' => 'contains', 90 'enum' => Product_Service::PRODUCT_URL_OPERATORS, 91 'sanitize_callback' => 'sanitize_text_field', 92 ], 93 'product_url_value' => [ 94 'description' => __( 'Product URL filter value (substring).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 95 'type' => 'string', 96 // NOT sanitize_text_field: it strips percent-encoded octets. 97 'sanitize_callback' => [ View_Service::class, 'sanitize_url_filter_value' ], 65 98 ], 66 99 'category' => [ … … 69 102 'sanitize_callback' => 'sanitize_text_field', 70 103 ], 104 'tag' => [ 105 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 106 'type' => 'string', 107 'sanitize_callback' => 'sanitize_text_field', 108 ], 71 109 'type' => [ 72 110 'description' => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 84 122 'sanitize_callback' => 'sanitize_text_field', 85 123 ], 124 'shipping_class' => [ 125 // No sanitize_callback: sanitize_text_field() strips percent-encoded 126 // octets, which is exactly how WP stores non-ASCII term slugs 127 // (sanitize_title() -> utf8_uri_encode()). That would silently 128 // reduce e.g. a Cyrillic class's slug to '', match-noning every 129 // export for it. Filter_Service::apply_shipping_class_param() is 130 // the real sanitizer — sanitize_title() per comma-separated token. 131 'description' => __( 'Comma-separated shipping class slugs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 132 'type' => 'string', 133 ], 86 134 'price_min' => [ 87 135 'description' => __( 'Minimum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 104 152 'sanitize_callback' => 'sanitize_text_field', 105 153 ], 154 'menu_order_min' => [ 155 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 156 'type' => 'string', 157 'sanitize_callback' => 'sanitize_text_field', 158 'validate_callback' => function ( $value ) { 159 if ( '' === $value ) { 160 return true; 161 } 162 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 163 'rest_invalid_param', 164 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 165 ); 166 }, 167 ], 168 'menu_order_max' => [ 169 'description' => __( 'Maximum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 170 'type' => 'string', 171 'sanitize_callback' => 'sanitize_text_field', 172 'validate_callback' => function ( $value ) { 173 if ( '' === $value ) { 174 return true; 175 } 176 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 177 'rest_invalid_param', 178 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 179 ); 180 }, 181 ], 182 'cogs_min' => [ 183 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 184 'type' => 'string', 185 'sanitize_callback' => 'sanitize_text_field', 186 ], 187 'cogs_max' => [ 188 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 189 'type' => 'string', 190 'sanitize_callback' => 'sanitize_text_field', 191 ], 106 192 'manage_stock' => [ 107 193 'description' => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 134 220 'date_modified_before' => [ 135 221 'description' => __( 'Filter products modified on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 222 'type' => 'string', 223 'sanitize_callback' => 'sanitize_text_field', 224 'validate_callback' => [ $this, 'validate_date_param' ], 225 ], 226 'sale_start_after' => [ 227 'description' => __( 'Filter products whose sale starts on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 228 'type' => 'string', 229 'sanitize_callback' => 'sanitize_text_field', 230 'validate_callback' => [ $this, 'validate_date_param' ], 231 ], 232 'sale_start_before' => [ 233 'description' => __( 'Filter products whose sale starts on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 234 'type' => 'string', 235 'sanitize_callback' => 'sanitize_text_field', 236 'validate_callback' => [ $this, 'validate_date_param' ], 237 ], 238 'sale_end_after' => [ 239 'description' => __( 'Filter products whose sale ends on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 240 'type' => 'string', 241 'sanitize_callback' => 'sanitize_text_field', 242 'validate_callback' => [ $this, 'validate_date_param' ], 243 ], 244 'sale_end_before' => [ 245 'description' => __( 'Filter products whose sale ends on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 246 'type' => 'string', 247 'sanitize_callback' => 'sanitize_text_field', 248 'validate_callback' => [ $this, 'validate_date_param' ], 249 ], 250 'image_date_after' => [ 251 'description' => __( 'Filter products whose featured image was uploaded on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 252 'type' => 'string', 253 'sanitize_callback' => 'sanitize_text_field', 254 'validate_callback' => [ $this, 'validate_date_param' ], 255 ], 256 'image_date_before' => [ 257 'description' => __( 'Filter products whose featured image was uploaded on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 136 258 'type' => 'string', 137 259 'sanitize_callback' => 'sanitize_text_field', … … 183 305 * Export products as CSV. 184 306 * 185 * On success: outputs CSV directly via echo and terminates with wp_die(). 307 * On success: streams the CSV and terminates with exit() — NOT wp_die(). That is why 308 * this method itself has no test coverage: rest_do_request() from PHPUnit would kill 309 * the process. Everything except the header/stream/exit tail lives in 310 * build_csv_stream(), which IS testable; keep new logic there (or in the service) 311 * rather than here. 186 312 * On error: returns WP_REST_Response with JSON error body. 313 * 314 * Note the one thing this method cannot guarantee: once fpassthru() has started, 315 * a failure mid-transfer can no longer be converted into a JSON error. The 316 * fail-loud writes in Export_Service ensure the buffer is COMPLETE before any 317 * header is sent, which is the strongest guarantee available short of buffering 318 * to a real temp file and sending Content-Length. 319 * 320 * Catches \Throwable, not just \RuntimeException: build_csv_stream() also runs 321 * third-party format filters (Pro's ACF reads, for one), and a bug there must 322 * still surface as a JSON error the frontend can render — not an uncaught fatal 323 * that WP's REST fatal handler turns into a bodyless/HTML 500 with no message, 324 * which is the exact failure mode #177 is about. The original error is logged 325 * so the underlying bug stays visible to whoever reads the PHP error log. 187 326 * 188 327 * @param \WP_REST_Request $request … … 190 329 */ 191 330 public function export( \WP_REST_Request $request ) { 192 $columns = $request->get_param( 'columns' ); 193 $product_ids = $request->get_param( 'product_ids' ); 194 $include_variations = (bool) $request->get_param( 'include_variations' ); 195 196 $products = []; 197 198 if ( ! empty( $product_ids ) ) { 199 // Export specific products by ID. 200 foreach ( $product_ids as $id ) { 201 $wc_product = wc_get_product( $id ); 202 if ( ! $wc_product ) { 203 continue; 204 } 205 206 if ( $wc_product->is_type( 'variation' ) ) { 207 $products[] = Product_Helper::format_variation( $wc_product ); 208 } else { 209 $products[] = Product_Helper::format_product( $wc_product ); 210 } 211 212 if ( count( $products ) >= self::MAX_EXPORT_ROWS ) { 213 break; 214 } 215 } 216 } else { 217 // Export all filtered products, paginating through. 218 $filters = $this->extract_filters( $request ); 219 $service = new Product_Service(); 220 $page = 1; 221 222 do { 223 $result = $service->get_products( $page, 100, $include_variations, $filters ); 224 $products = array_merge( $products, $result['products'] ); 225 $has_more = $page < $result['total_pages']; 226 $page++; 227 228 if ( count( $products ) >= self::MAX_EXPORT_ROWS ) { 229 $products = array_slice( $products, 0, self::MAX_EXPORT_ROWS ); 230 break; 231 } 232 } while ( $has_more ); 233 } 234 235 if ( empty( $products ) ) { 331 $this->raise_resource_limits(); 332 333 try { 334 $stream = $this->build_csv_stream( $request ); 335 } catch ( \Throwable $e ) { 336 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- no logging abstraction exists in this codebase (grepped); this is the standard PHP fallback so the underlying cause isn't lost once it's converted to a generic JSON message below. 337 error_log( sprintf( 'WCABE G2 export failed: %s', $e->getMessage() ) ); 338 339 return new \WP_REST_Response( 340 [ 341 'code' => 'export_failed', 342 'message' => __( 'Export failed while generating the CSV file.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 343 ], 344 500 345 ); 346 } 347 348 $handle = $stream['handle']; 349 $count = $stream['count']; 350 351 if ( 0 === $count ) { 352 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream from build_csv_stream(). 353 fclose( $handle ); 354 236 355 return new \WP_REST_Response( 237 356 [ … … 243 362 } 244 363 245 $units = [ 246 'weight_unit' => $request->get_param( 'weight_unit' ), 247 'dimension_unit' => $request->get_param( 'dimension_unit' ), 248 ]; 249 250 $export_service = new Export_Service(); 251 $csv = $export_service->generate_csv( $products, $columns, true, $units ); 252 $count = count( $products ); 364 // Checked BEFORE any header is sent — this is the last point at which a 365 // failure can still become a JSON error rather than a truncated 200. 366 if ( ! rewind( $handle ) ) { 367 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream from build_csv_stream(). 368 fclose( $handle ); 369 370 return new \WP_REST_Response( 371 [ 372 'code' => 'export_failed', 373 'message' => __( 'Export failed while generating the CSV file.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 374 ], 375 500 376 ); 377 } 253 378 254 379 header( 'Content-Type: text/csv; charset=UTF-8' ); … … 260 385 header( 'Cache-Control: no-cache, no-store, must-revalidate' ); 261 386 262 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $csv is a CSV-format response body produced by fputcsv() with proper RFC 4180 quoting; Content-Type is text/csv (set above), so HTML escaping is incorrect and would corrupt the output. 263 echo $csv; 387 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- streams a CSV-format response body produced by fputcsv() with proper RFC 4180 quoting; Content-Type is text/csv (set above), so HTML escaping is incorrect and would corrupt the output. 388 fpassthru( $handle ); 389 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream from build_csv_stream(). 390 fclose( $handle ); 264 391 exit; 265 392 } 266 393 267 394 /** 395 * Raise the request's memory and time limits before building the CSV. 396 * 397 * Belt and braces. Note wp_raise_memory_limit() only raises as far as 398 * WP_MAX_MEMORY_LIMIT (256M by default), so on a host already at 256M it is a 399 * no-op — the memory fix that actually matters is the streaming collection in 400 * Product_Service, not this call. wc_set_time_limit(300) — not 0 — because this 401 * is a synchronous web request and should not run unbounded. 402 * 403 * TEST SEAM: a fatal-failure test proxy overrides this to a no-op, so exercising 404 * the failure path doesn't call set_time_limit()/ini_set() on the shared 405 * PHPUnit process for every run. 406 */ 407 protected function raise_resource_limits(): void { 408 wp_raise_memory_limit( 'admin' ); 409 wc_set_time_limit( 300 ); 410 } 411 412 /** 413 * Build the CSV into a temporary stream. 414 * 415 * TEST SEAM: this is export() minus the headers/stream/exit tail, so PHPUnit can 416 * drive the whole collection → CSV bridge through a proxy subclass. 417 * 418 * The stream is php://temp (spills to disk past 2MB) rather than php://output: 419 * an error must still be able to return JSON, and a mid-loop failure must never 420 * emit a truncated 200 CSV. 421 * 422 * @param \WP_REST_Request $request 423 * @return array{handle: resource, count: int} Caller owns the handle and must fclose() it. 424 * @throws \RuntimeException When the stream cannot be opened or written. 425 */ 426 protected function build_csv_stream( \WP_REST_Request $request ): array { 427 $columns = $request->get_param( 'columns' ); 428 $product_ids = $request->get_param( 'product_ids' ); 429 $include_variations = (bool) $request->get_param( 'include_variations' ); 430 431 $units = [ 432 'weight_unit' => $request->get_param( 'weight_unit' ), 433 'dimension_unit' => $request->get_param( 'dimension_unit' ), 434 ]; 435 436 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory PHP stream (disk-backed past 2MB), not a filesystem operation; WP_Filesystem does not apply. 437 $handle = fopen( 'php://temp', 'r+' ); 438 439 if ( false === $handle ) { 440 throw new \RuntimeException( 'Unable to open a temporary stream for CSV export.' ); 441 } 442 443 try { 444 $export_service = new Export_Service(); 445 446 // begin_csv() once, write_rows() per chunk, all on the SAME instance — that 447 // instance holds the resolved Pro column map every row write reads. 448 $resolved = $export_service->begin_csv( $handle, $columns, true, $units ); 449 450 $sink = static function ( array $rows ) use ( $export_service, $handle, $resolved ): void { 451 $export_service->write_rows( $handle, $rows, $resolved ); 452 }; 453 454 $service = new Product_Service(); 455 456 $count = ! empty( $product_ids ) 457 ? $service->stream_products_by_ids( (array) $product_ids, self::MAX_EXPORT_ROWS, $sink ) 458 : $service->stream_products( 459 $this->extract_filters( $request ), 460 $include_variations, 461 self::MAX_EXPORT_ROWS, 462 $sink 463 ); 464 } catch ( \Throwable $e ) { 465 // Catch solely to release the handle — the caller never receives it, so 466 // nothing else could close it. Rethrown unchanged. 467 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream opened above. 468 fclose( $handle ); 469 throw $e; 470 } 471 472 return [ 473 'handle' => $handle, 474 'count' => $count, 475 ]; 476 } 477 478 /** 268 479 * Extract filter parameters from the request. 480 * 481 * Protected rather than private as a TEST SEAM: export() itself ends in 482 * exit(), so it cannot run under PHPUnit — a test subclass exposes this 483 * method instead, which is what keeps the filter→CSV bridge covered. 269 484 * 270 485 * @param \WP_REST_Request $request 271 486 * @return array 272 487 */ 273 pr ivatefunction extract_filters( \WP_REST_Request $request ): array {488 protected function extract_filters( \WP_REST_Request $request ): array { 274 489 $filters = []; 275 490 … … 278 493 $filters['search_name'] = sanitize_text_field( $search_name ); 279 494 } 280 $search_sku = $request->get_param( 'search_sku' ); 281 if ( ! empty( $search_sku ) ) { 282 $filters['search_sku'] = sanitize_text_field( $search_sku ); 283 } 284 495 $search_name_mode = $request->get_param( 'search_name_mode' ); 496 if ( in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 497 $filters['search_name_mode'] = $search_name_mode; 498 } 499 // Strict '' test, not empty(): "0" is a valid SKU. 500 $search_sku = sanitize_text_field( (string) $request->get_param( 'search_sku' ) ); 501 if ( '' !== $search_sku ) { 502 $filters['search_sku'] = $search_sku; 503 } 504 $search_sku_mode = $request->get_param( 'search_sku_mode' ); 505 if ( in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 506 $filters['search_sku_mode'] = $search_sku_mode; 507 } 508 509 // Product URL filter. Deliberately NOT gated on a non-empty value — the 510 // is_empty / is_not_empty operators are effective with none. 511 // Mirrors Products_Controller::get_items(); keep the two in step. 512 $product_url_operator = $request->get_param( 'product_url_operator' ) ?: 'contains'; 513 $product_url_value = View_Service::sanitize_url_filter_value( $request->get_param( 'product_url_value' ) ?? '' ); 514 if ( Product_Service::product_url_filter_is_effective( $product_url_operator, $product_url_value ) ) { 515 $filters['product_url_operator'] = $product_url_operator; 516 $filters['product_url_value'] = $product_url_value; 517 } 518 519 // Term-ID filters. A param that is PRESENT but parses to zero valid IDs must match 520 // NOTHING (the [0] sentinel — no term has ID 0), not drop the filter: an export 521 // that silently widens back to the whole catalog is the worse failure. The presence 522 // test is deliberately `'' !==` rather than `! empty()` — empty('0') is true, so the 523 // single most likely corrupt value would otherwise slip through as "no filter". 524 // Mirrors Products_Controller::get_items(); keep the two in step. 285 525 $category = $request->get_param( 'category' ); 286 if ( ! empty( $category ) ) { 287 $filters['category'] = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 526 if ( null !== $category && '' !== $category ) { 527 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 528 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 529 } 530 531 $tag = $request->get_param( 'tag' ); 532 if ( null !== $tag && '' !== $tag ) { 533 $tag_ids = array_filter( array_map( 'absint', explode( ',', $tag ) ) ); 534 $filters['tag'] = empty( $tag_ids ) ? [ 0 ] : $tag_ids; 288 535 } 289 536 … … 303 550 } 304 551 552 $filters = Filter_Service::apply_shipping_class_param( 553 $request->get_param( 'shipping_class' ), 554 $filters 555 ); 556 305 557 $price_min = $request->get_param( 'price_min' ); 306 558 if ( null !== $price_min && '' !== $price_min ) { … … 313 565 } 314 566 567 // Cost of Goods range — gated so a stale param can't trigger the COGS query 568 // path when the feature is off (matches Products_Controller). 569 if ( Product_Helper::is_cogs_enabled() ) { 570 $cogs_min = $request->get_param( 'cogs_min' ); 571 if ( null !== $cogs_min && '' !== $cogs_min ) { 572 $filters['cogs_min'] = sanitize_text_field( $cogs_min ); 573 } 574 575 $cogs_max = $request->get_param( 'cogs_max' ); 576 if ( null !== $cogs_max && '' !== $cogs_max ) { 577 $filters['cogs_max'] = sanitize_text_field( $cogs_max ); 578 } 579 } 580 315 581 $stock_qty_min = $request->get_param( 'stock_qty_min' ); 316 582 if ( null !== $stock_qty_min && '' !== $stock_qty_min ) { … … 323 589 } 324 590 591 $menu_order_min = $request->get_param( 'menu_order_min' ); 592 if ( null !== $menu_order_min && '' !== $menu_order_min ) { 593 $filters['menu_order_min'] = sanitize_text_field( $menu_order_min ); 594 } 595 596 $menu_order_max = $request->get_param( 'menu_order_max' ); 597 if ( null !== $menu_order_max && '' !== $menu_order_max ) { 598 $filters['menu_order_max'] = sanitize_text_field( $menu_order_max ); 599 } 600 325 601 $manage_stock = $request->get_param( 'manage_stock' ); 326 602 if ( ! empty( $manage_stock ) ) { … … 333 609 } 334 610 335 $date_params = [ 'date_created_after', 'date_created_before', 'date_modified_after', 'date_modified_before' ]; 611 $date_params = [ 612 'date_created_after', 613 'date_created_before', 614 'date_modified_after', 615 'date_modified_before', 616 'sale_start_after', 617 'sale_start_before', 618 'sale_end_after', 619 'sale_end_before', 620 'image_date_after', 621 'image_date_before', 622 ]; 336 623 foreach ( $date_params as $param ) { 337 624 $val = $request->get_param( $param ); -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Products_Controller.php
r3549389 r3663491 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 10 11 use WCABE_G2\Helpers\Permission_Helper; 12 use WCABE_G2\Helpers\Product_Helper; 13 use WCABE_G2\Services\Filter_Service; 11 14 use WCABE_G2\Services\Product_Service; 15 use WCABE_G2\Services\View_Service; 12 16 13 17 defined( 'ABSPATH' ) || exit; … … 30 34 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 31 35 'args' => $this->get_collection_params(), 36 ], 37 ] ); 38 39 register_rest_route( $this->namespace, '/' . $this->rest_base . '/search', [ 40 [ 41 'methods' => \WP_REST_Server::READABLE, 42 'callback' => [ $this, 'search_items' ], 43 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 44 'args' => [ 45 'term' => [ 46 'description' => __( 'Search term — matches product title, SKU, GTIN, or a bare product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 47 'required' => true, 48 'type' => 'string', 49 'minLength' => 1, 50 'sanitize_callback' => 'sanitize_text_field', 51 'validate_callback' => static function ( $value ) { 52 if ( '' === trim( (string) $value ) ) { 53 return new \WP_Error( 54 'rest_invalid_param', 55 __( 'Search term cannot be empty.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 56 [ 'status' => 400 ] 57 ); 58 } 59 return true; 60 }, 61 ], 62 'exclude' => [ 63 'description' => __( 'Product IDs to omit from results (e.g. the product being edited).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 64 'type' => 'array', 65 'default' => [], 66 'items' => [ 'type' => 'integer' ], 67 'sanitize_callback' => static function ( $value ) { 68 if ( ! is_array( $value ) ) { 69 return []; 70 } 71 // intval, NOT absint: absint( -7 ) is 7, so a negative would 72 // silently exclude the wrong product. 73 return array_values( array_filter( 74 array_map( 'intval', $value ), 75 static fn( $id ) => $id > 0 76 ) ); 77 }, 78 ], 79 'limit' => [ 80 'description' => __( 'Maximum number of results.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 81 'type' => 'integer', 82 'default' => 30, 83 'minimum' => 1, 84 'maximum' => 50, 85 'sanitize_callback' => 'absint', 86 'validate_callback' => 'rest_validate_request_arg', 87 ], 88 ], 32 89 ], 33 90 ] ); … … 61 118 ], 62 119 ], 120 'batch_id' => [ 121 'required' => false, 122 'type' => 'string', 123 'description' => __( 'Optional client-generated identifier correlating all chunks of one save.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 124 'validate_callback' => 'rest_validate_request_arg', 125 'sanitize_callback' => 'sanitize_text_field', 126 ], 63 127 ], 64 128 ], 65 129 ] ); 130 } 131 132 /** 133 * Product lookup for the linked-product picker. 134 * 135 * Route parsing and response shaping only — the search itself lives in 136 * Product_Service::search_products(), where the business logic belongs. 137 * 138 * @param \WP_REST_Request $request 139 * @return \WP_REST_Response 140 */ 141 public function search_items( $request ): \WP_REST_Response { 142 $service = new Product_Service(); 143 144 $results = $service->search_products( 145 (string) $request->get_param( 'term' ), 146 (array) $request->get_param( 'exclude' ), 147 (int) $request->get_param( 'limit' ) 148 ); 149 150 return new \WP_REST_Response( $results, 200 ); 66 151 } 67 152 … … 72 157 public function get_items( $request ): \WP_REST_Response { 73 158 $page = absint( $request->get_param( 'page' ) ) ?: 1; 74 $per_page = absint( $request->get_param( 'per_page' ) ) ?: 100; 159 // Clamped again here (the schema's sanitize_callback already ran) because 160 // get_items() must hold the bound for any caller, including internal 161 // dispatches that build a request without the registered schema. 162 $per_page = Page_Size_Helper::clamp( $request->get_param( 'per_page' ) ); 75 163 $include_variations = (bool) $request->get_param( 'include_variations' ); 76 77 /**78 * Filters the maximum number of products returned per request.79 *80 * Default 100 is a universal performance safety cap to prevent memory81 * exhaustion on shared hosting when variations are expanded. Both Free82 * and Pro honor this default — Pro does not raise it. Site admins on83 * infrastructure that can handle larger page sizes may filter this.84 *85 * @param int $max_per_page Default 100.86 */87 $max_per_page = (int) apply_filters( 'wcabe_g2_max_per_page', 100 );88 $per_page = min( $per_page, $max_per_page );89 164 90 165 // Build filters array from request params. … … 95 170 $filters['search_name'] = sanitize_text_field( $search_name ); 96 171 } 97 $search_sku = $request->get_param( 'search_sku' ); 98 if ( ! empty( $search_sku ) ) { 99 $filters['search_sku'] = sanitize_text_field( $search_sku ); 172 $search_name_mode = $request->get_param( 'search_name_mode' ); 173 if ( in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 174 $filters['search_name_mode'] = $search_name_mode; 175 } 176 // Strict '' test, not empty(): "0" is a valid SKU. 177 $search_sku = sanitize_text_field( (string) $request->get_param( 'search_sku' ) ); 178 if ( '' !== $search_sku ) { 179 $filters['search_sku'] = $search_sku; 180 } 181 $search_sku_mode = $request->get_param( 'search_sku_mode' ); 182 if ( in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 183 $filters['search_sku_mode'] = $search_sku_mode; 100 184 } 101 185 $search_description = $request->get_param( 'search_description' ); … … 117 201 } 118 202 203 // Product URL filter. Deliberately NOT gated on a non-empty value: the 204 // is_empty / is_not_empty operators are effective with none, so the usual 205 // `if ( ! empty( $value ) )` shape would silently drop them here. 206 $product_url_operator = $request->get_param( 'product_url_operator' ) ?: 'contains'; 207 $product_url_value = View_Service::sanitize_url_filter_value( $request->get_param( 'product_url_value' ) ?? '' ); 208 if ( Product_Service::product_url_filter_is_effective( $product_url_operator, $product_url_value ) ) { 209 $filters['product_url_operator'] = $product_url_operator; 210 $filters['product_url_value'] = $product_url_value; 211 } 212 213 // Term-ID filters. A param that is PRESENT but parses to zero valid IDs must match 214 // NOTHING (the [0] sentinel — no term has ID 0), not drop the filter: silently 215 // widening a filtered request back to the whole catalog is the worse failure, and 216 // the same parse feeds CSV export. The presence test is deliberately `'' !==` 217 // rather than `! empty()` — empty('0') is true, so the single most likely corrupt 218 // value would otherwise slip through as "no filter". 119 219 $category = $request->get_param( 'category' ); 120 if ( ! empty( $category ) ) { 121 $filters['category'] = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 220 if ( null !== $category && '' !== $category ) { 221 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 222 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 223 } 224 225 $tag = $request->get_param( 'tag' ); 226 if ( null !== $tag && '' !== $tag ) { 227 $tag_ids = array_filter( array_map( 'absint', explode( ',', $tag ) ) ); 228 $filters['tag'] = empty( $tag_ids ) ? [ 0 ] : $tag_ids; 122 229 } 123 230 … … 137 244 } 138 245 246 $filters = Filter_Service::apply_shipping_class_param( 247 $request->get_param( 'shipping_class' ), 248 $filters 249 ); 250 139 251 $price_min = $request->get_param( 'price_min' ); 140 252 if ( null !== $price_min && '' !== $price_min ) { … … 147 259 } 148 260 261 // Cost of Goods range — only honored when the COGS feature is enabled, so a 262 // stale param can't trigger the COGS query path (or get_cogs_value()). 263 if ( Product_Helper::is_cogs_enabled() ) { 264 $cogs_min = $request->get_param( 'cogs_min' ); 265 if ( null !== $cogs_min && '' !== $cogs_min ) { 266 $filters['cogs_min'] = sanitize_text_field( $cogs_min ); 267 } 268 269 $cogs_max = $request->get_param( 'cogs_max' ); 270 if ( null !== $cogs_max && '' !== $cogs_max ) { 271 $filters['cogs_max'] = sanitize_text_field( $cogs_max ); 272 } 273 } 274 149 275 $stock_qty_min = $request->get_param( 'stock_qty_min' ); 150 276 if ( null !== $stock_qty_min && '' !== $stock_qty_min ) { … … 157 283 } 158 284 285 $menu_order_min = $request->get_param( 'menu_order_min' ); 286 if ( null !== $menu_order_min && '' !== $menu_order_min ) { 287 $filters['menu_order_min'] = sanitize_text_field( $menu_order_min ); 288 } 289 290 $menu_order_max = $request->get_param( 'menu_order_max' ); 291 if ( null !== $menu_order_max && '' !== $menu_order_max ) { 292 $filters['menu_order_max'] = sanitize_text_field( $menu_order_max ); 293 } 294 159 295 $manage_stock = $request->get_param( 'manage_stock' ); 160 296 if ( ! empty( $manage_stock ) ) { … … 167 303 } 168 304 169 $date_params = [ 'date_created_after', 'date_created_before', 'date_modified_after', 'date_modified_before' ]; 305 $date_params = [ 306 'date_created_after', 307 'date_created_before', 308 'date_modified_after', 309 'date_modified_before', 310 'sale_start_after', 311 'sale_start_before', 312 'sale_end_after', 313 'sale_end_before', 314 'image_date_after', 315 'image_date_before', 316 ]; 170 317 foreach ( $date_params as $param ) { 171 318 $val = $request->get_param( $param ); … … 202 349 $filters = apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 203 350 351 // Server-side sort. Whitelisted at the schema level (orderby: id|menu_order); 352 // the service maps these to WC/WP_Query orderby values. 353 $orderby = (string) $request->get_param( 'orderby' ); 354 $order = (string) $request->get_param( 'order' ); 355 204 356 $service = new Product_Service(); 205 $data = $service->get_products( $page, $per_page, $include_variations, $filters );357 $data = $service->get_products( $page, $per_page, $include_variations, $filters, $orderby, $order ); 206 358 207 359 $response = rest_ensure_response( $data ); … … 240 392 } 241 393 394 $batch_id = $request->get_param( 'batch_id' ); 395 242 396 $service = new Product_Service(); 243 $results = $service->batch_update( $products ); 397 $results = $service->batch_update( 398 $products, 399 [ 'batch_id' => is_string( $batch_id ) ? $batch_id : null ] 400 ); 244 401 245 402 return rest_ensure_response( [ 'results' => $results ] ); … … 281 438 'description' => __( 'Maximum number of items per page.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 282 439 'type' => 'integer', 283 'default' => 100, 284 'minimum' => 1, 285 'maximum' => 100, 286 'sanitize_callback' => 'absint', 287 'validate_callback' => 'rest_validate_request_arg', 440 'default' => Page_Size_Helper::DEFAULT_PAGE_SIZE, 441 // Deliberately no 'minimum'/'maximum'/'validate_callback': an 442 // out-of-range or malformed per_page must degrade to a clamped 443 // value, never 400 the whole grid fetch. Page_Size_Helper::clamp 444 // bounds every input (including non-scalars) without erroring, so 445 // schema validation would only add a failure mode. The effective 446 // maximum is filterable per site, which a static schema 'maximum' 447 // could not express anyway. 448 'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ], 288 449 ], 289 450 'include_variations' => [ … … 293 454 'sanitize_callback' => 'rest_sanitize_boolean', 294 455 ], 456 'orderby' => [ 457 'description' => __( 'Server-side sort column.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 458 'type' => 'string', 459 'default' => 'id', 460 'enum' => [ 'id', 'menu_order' ], 461 ], 462 'order' => [ 463 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 464 'type' => 'string', 465 'default' => 'desc', 466 'enum' => [ 'asc', 'desc' ], 467 ], 295 468 'search_name' => [ 296 469 'description' => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 298 471 'sanitize_callback' => 'sanitize_text_field', 299 472 ], 473 'search_name_mode' => [ 474 'description' => __( 'Title match mode: all (every word, default), any (any word), or phrase (exact phrase).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 475 'type' => 'string', 476 'default' => 'all', 477 'enum' => Product_Service::TITLE_SEARCH_MODES, 478 'sanitize_callback' => 'sanitize_text_field', 479 ], 300 480 'search_sku' => [ 301 'description' => __( 'Search by SKU. Comma-separated for exact match.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 302 'type' => 'string', 481 /* translators: %d: maximum number of comma-separated SKU values. */ 482 'description' => sprintf( __( 'Search by SKU. Comma-separate multiple values to match any of them under the selected mode (capped at %d values).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), Product_Service::SKU_SEARCH_MAX_TOKENS ), 483 'type' => 'string', 484 'sanitize_callback' => 'sanitize_text_field', 485 ], 486 'search_sku_mode' => [ 487 'description' => __( 'SKU match mode: contains, is_exactly, not_contains, starts_with, or ends_with. Comma-separated values match any of them (none of them for not_contains). Omit or send an empty string for the legacy behaviour: comma-separated = exact, single value = partial.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 488 'type' => 'string', 489 'default' => '', 490 'enum' => array_merge( [ '' ], Product_Service::SKU_SEARCH_MODES ), 303 491 'sanitize_callback' => 'sanitize_text_field', 304 492 ], … … 330 518 'sanitize_callback' => 'sanitize_text_field', 331 519 ], 520 'product_url_operator' => [ 521 'description' => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 522 'type' => 'string', 523 'default' => 'contains', 524 'enum' => Product_Service::PRODUCT_URL_OPERATORS, 525 'sanitize_callback' => 'sanitize_text_field', 526 ], 527 'product_url_value' => [ 528 'description' => __( 'Product URL filter value (substring).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 529 'type' => 'string', 530 // NOT sanitize_text_field: it strips percent-encoded octets (%20, 531 // %2F), which are legitimate content in a URL substring search. 532 'sanitize_callback' => [ View_Service::class, 'sanitize_url_filter_value' ], 533 ], 332 534 'category' => [ 333 535 'description' => __( 'Comma-separated category term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 335 537 'sanitize_callback' => 'sanitize_text_field', 336 538 ], 539 'tag' => [ 540 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 541 'type' => 'string', 542 'sanitize_callback' => 'sanitize_text_field', 543 ], 337 544 'type' => [ 338 545 'description' => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 349 556 'type' => 'string', 350 557 'sanitize_callback' => 'sanitize_text_field', 558 ], 559 'shipping_class' => [ 560 // No sanitize_callback: sanitize_text_field() strips percent-encoded 561 // octets, which is exactly how WP stores non-ASCII term slugs 562 // (sanitize_title() -> utf8_uri_encode()). That would silently reduce 563 // e.g. a Cyrillic class's slug to '', match-noning every request for 564 // it. Filter_Service::apply_shipping_class_param() is the real 565 // sanitizer — sanitize_title() per comma-separated token. 566 'description' => __( 'Comma-separated shipping class slugs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 567 'type' => 'string', 351 568 ], 352 569 'price_min' => [ … … 378 595 }, 379 596 ], 597 'cogs_min' => [ 598 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 599 'type' => 'string', 600 'sanitize_callback' => 'sanitize_text_field', 601 'validate_callback' => function( $value ) { 602 if ( '' === $value ) { 603 return true; 604 } 605 return is_numeric( $value ) ? true : new \WP_Error( 606 'rest_invalid_param', 607 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 608 ); 609 }, 610 ], 611 'cogs_max' => [ 612 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 613 'type' => 'string', 614 'sanitize_callback' => 'sanitize_text_field', 615 'validate_callback' => function( $value ) { 616 if ( '' === $value ) { 617 return true; 618 } 619 return is_numeric( $value ) ? true : new \WP_Error( 620 'rest_invalid_param', 621 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 622 ); 623 }, 624 ], 380 625 'stock_qty_min' => [ 381 626 'description' => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 406 651 }, 407 652 ], 653 'menu_order_min' => [ 654 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 655 'type' => 'string', 656 'sanitize_callback' => 'sanitize_text_field', 657 'validate_callback' => function( $value ) { 658 // Integer-only (menu order is a whole number; may be negative) — 659 // deliberately stricter than the decimal-allowing stock_qty filter. 660 if ( '' === $value ) { 661 return true; 662 } 663 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 664 'rest_invalid_param', 665 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 666 ); 667 }, 668 ], 669 'menu_order_max' => [ 670 'description' => __( 'Maximum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 671 'type' => 'string', 672 'sanitize_callback' => 'sanitize_text_field', 673 'validate_callback' => function( $value ) { 674 if ( '' === $value ) { 675 return true; 676 } 677 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 678 'rest_invalid_param', 679 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 680 ); 681 }, 682 ], 408 683 'manage_stock' => [ 409 684 'description' => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 436 711 'date_modified_before' => [ 437 712 'description' => __( 'Filter products modified on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 713 'type' => 'string', 714 'sanitize_callback' => 'sanitize_text_field', 715 'validate_callback' => [ $this, 'validate_date_param' ], 716 ], 717 'sale_start_after' => [ 718 'description' => __( 'Filter products whose sale starts on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 719 'type' => 'string', 720 'sanitize_callback' => 'sanitize_text_field', 721 'validate_callback' => [ $this, 'validate_date_param' ], 722 ], 723 'sale_start_before' => [ 724 'description' => __( 'Filter products whose sale starts on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 725 'type' => 'string', 726 'sanitize_callback' => 'sanitize_text_field', 727 'validate_callback' => [ $this, 'validate_date_param' ], 728 ], 729 'sale_end_after' => [ 730 'description' => __( 'Filter products whose sale ends on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 731 'type' => 'string', 732 'sanitize_callback' => 'sanitize_text_field', 733 'validate_callback' => [ $this, 'validate_date_param' ], 734 ], 735 'sale_end_before' => [ 736 'description' => __( 'Filter products whose sale ends on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 737 'type' => 'string', 738 'sanitize_callback' => 'sanitize_text_field', 739 'validate_callback' => [ $this, 'validate_date_param' ], 740 ], 741 'image_date_after' => [ 742 'description' => __( 'Filter products whose featured image was uploaded on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 743 'type' => 'string', 744 'sanitize_callback' => 'sanitize_text_field', 745 'validate_callback' => [ $this, 'validate_date_param' ], 746 ], 747 'image_date_before' => [ 748 'description' => __( 'Filter products whose featured image was uploaded on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 438 749 'type' => 'string', 439 750 'sanitize_callback' => 'sanitize_text_field', -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Settings_Controller.php
r3549389 r3663491 26 26 ], 27 27 'debug_tracing' => [ 28 'type' => 'boolean', 29 'default' => false, 30 ], 31 'trigger_wp_save_hooks' => [ 28 32 'type' => 'boolean', 29 33 'default' => false, -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/User_Settings_Controller.php
r3549389 r3663491 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 10 11 use WCABE_G2\Helpers\Permission_Helper; 11 12 use WCABE_G2\Services\View_Service; … … 27 28 */ 28 29 private const SCHEMA = [ 29 'pageSize' => [ 'type' => 'integer', 'default' => 100],30 'pageSize' => [ 'type' => 'integer', 'default' => Page_Size_Helper::DEFAULT_PAGE_SIZE ], 30 31 'includeVariations' => [ 'type' => 'boolean', 'default' => false ], 31 32 'darkMode' => [ 'type' => 'boolean', 'default' => false ], … … 34 35 'imagePreviewEnabled' => [ 'type' => 'boolean', 'default' => true ], 35 36 'autoLoadProducts' => [ 'type' => 'boolean', 'default' => true ], 37 'showAdminBarShortcut' => [ 'type' => 'boolean', 'default' => true ], 38 'refreshAfterSave' => [ 'type' => 'boolean', 'default' => true ], 39 // Working-layout draft (views working-state redesign). filterDraft is the 40 // filter half (view `filters` shape); activeViewId is the baseline pointer 41 // and the load-precedence "has-draft" marker (nullable — never (string)-cast). 42 'filterDraft' => [ 'type' => 'filters', 'default' => null ], 43 'activeViewId' => [ 'type' => 'nullable_string', 'default' => null ], 44 // Opaque scope stamp recorded alongside filterDraft, identifying the context 45 // the draft's entity ids were resolved in. Tier-neutral: Free never 46 // interprets the value, it only round-trips it so the frontend can compare 47 // the stored stamp against the current one and discard a draft whose entity 48 // ids belong to a different scope (see stripScopedFilters / filterScopeKey 49 // registry slot). Null = no stamp recorded. 50 'filterScopeKey' => [ 'type' => 'nullable_string', 'default' => null ], 36 51 ]; 37 52 … … 58 73 59 74 public function update_settings( \WP_REST_Request $request ): \WP_REST_Response { 75 $body = $request->get_json_params(); 76 77 // Order-safe write gate via a monotonic client _rev (R7.1/R8.1/R8.2). The 78 // settings sync is last-write-wins partial merge, so a stale in-flight request 79 // landing after the unload beacon could clobber individual keys. Stamping every 80 // write with a client _rev and applying-only-if-newer closes that race. 81 // 82 // Read the RAW stored meta for the prior _rev — get_all() is SCHEMA-only and 83 // would drop _rev, so the compare and write-back must use the raw array (R8.1). 84 $raw = get_user_meta( get_current_user_id(), self::META_KEY, true ); 85 if ( ! is_array( $raw ) ) { 86 $raw = []; 87 } 88 // Use the RAW numeric value — do NOT (int)-cast. The client seeds _rev from 89 // Date.now() (~1.75e12), which exceeds PHP_INT_MAX on 32-bit PHP where (int) 90 // saturates to 2147483647, collapsing stored and incoming to the same value so 91 // every write after the first is wrongly rejected as stale (review #8). On 32-bit 92 // JSON decodes such a value to a float; on 64-bit it stays an int. Comparing the 93 // raw numeric values is correct on both (PHP compares numerically) and preserves 94 // the stored int type on 64-bit. is_numeric() already validated them. 95 $stored_rev = ( isset( $raw['_rev'] ) && is_numeric( $raw['_rev'] ) ) ? $raw['_rev'] : null; 96 $incoming_rev = ( isset( $body['_rev'] ) && is_numeric( $body['_rev'] ) ) ? $body['_rev'] : null; 97 98 if ( null !== $incoming_rev ) { 99 // Drop exact-rev replays and older writes. 100 if ( null !== $stored_rev && $incoming_rev <= $stored_rev ) { 101 return rest_ensure_response( self::get_all() ); 102 } 103 } elseif ( null !== $stored_rev ) { 104 // Absent _rev applies ONLY before any _rev is stored (pre-_rev install / 105 // first write). Once a _rev exists, treat a missing _rev as stale — else a 106 // no-rev write would reopen the race (R8.2). 107 return rest_ensure_response( self::get_all() ); 108 } 109 60 110 $current = self::get_all(); 61 $body = $request->get_json_params();62 111 63 112 foreach ( self::SCHEMA as $key => $schema ) { … … 75 124 case 'integer': 76 125 if ( 'pageSize' === $key ) { 77 /** This filter is documented in includes/REST/Products_Controller.php */ 78 $max = (int) apply_filters( 'wcabe_g2_max_per_page', 100 ); 79 $current[ $key ] = max( 1, min( absint( $value ), $max ) ); 126 $current[ $key ] = Page_Size_Helper::clamp( $value ); 80 127 } 81 128 // else: no other integer fields defined. Future integer fields … … 102 149 } 103 150 break; 151 152 case 'filters': 153 if ( null === $value ) { 154 $current[ $key ] = null; 155 } elseif ( is_array( $value ) ) { 156 // Live frontend draft — already the current FilterValues shape, 157 // never a legacy bag shape, so no migration (mirrors columnState). 158 $current[ $key ] = View_Service::sanitize_filter_values( $value ); 159 } 160 break; 161 162 case 'nullable_string': 163 // Preserve null/'' as null (don't (string)-cast) so the load-precedence 164 // marker (activeViewId !== null) stays meaningful. 165 if ( null === $value || '' === $value ) { 166 $current[ $key ] = null; 167 } else { 168 $current[ $key ] = sanitize_text_field( (string) $value ); 169 } 170 break; 104 171 } 105 172 } 106 173 174 // Re-add the rev into the meta array so the $current = get_all() rebuild 175 // (SCHEMA-only) doesn't erase the stored _rev (R8.1). Newest rev wins. 176 if ( null !== $incoming_rev ) { 177 $current['_rev'] = max( $incoming_rev, $stored_rev ?? 0 ); 178 } 179 107 180 update_user_meta( get_current_user_id(), self::META_KEY, $current ); 108 181 109 return rest_ensure_response( $current ); 182 // Return SCHEMA-only settings (get_all strips _rev) so _rev never surfaces 183 // to the client as a setting (R8.1). 184 return rest_ensure_response( self::get_all() ); 185 } 186 187 /** 188 * The stored monotonic sync cursor (_rev), or 0.0 when none has been written yet. 189 * 190 * Localized to the client (window.wcabeG2.userSettingsRev) so it can seed its rev 191 * counter ABOVE the last persisted value. Without this the client seeds from a bare 192 * Date.now(); a backward wall-clock step between sessions (NTP correction, VM resume, 193 * wrong RTC, manual change) then makes every new write's _rev look stale, silently 194 * halting all settings persistence until real time catches up (review #4). This is 195 * transport, deliberately kept out of get_all()/UserSettings so _rev never surfaces 196 * as a setting. Returned as float for 32-bit safety (see update_settings, #8). 197 */ 198 public static function get_stored_rev(): float { 199 $raw = get_user_meta( get_current_user_id(), self::META_KEY, true ); 200 return ( is_array( $raw ) && isset( $raw['_rev'] ) && is_numeric( $raw['_rev'] ) ) ? (float) $raw['_rev'] : 0.0; 110 201 } 111 202 … … 126 217 } 127 218 128 // Defensive clamp on read. The schema and update_settings() already129 // enforce the bound on writes, but values written to user meta outside130 // this controller (legacy installs, future schema changes, direct DB131 // updates) could fall outside it. Same filter and bound as the write path.219 // Defensive clamp on read. update_settings() already enforces the bound on 220 // writes, but values written to user meta outside this controller (legacy 221 // installs, future schema changes, direct DB updates) or a site that has 222 // since LOWERED wcabe_g2_max_per_page could fall outside it. 132 223 if ( isset( $result['pageSize'] ) ) { 133 /** This filter is documented in includes/REST/Products_Controller.php */ 134 $max = (int) apply_filters( 'wcabe_g2_max_per_page', 100 ); 135 $result['pageSize'] = min( max( 1, (int) $result['pageSize'] ), $max ); 224 $result['pageSize'] = Page_Size_Helper::clamp( $result['pageSize'] ); 136 225 } 137 226 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Views_Controller.php
r3549389 r3663491 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 10 11 use WCABE_G2\Helpers\Permission_Helper; 11 12 use WCABE_G2\Services\View_Service; … … 38 39 ); 39 40 } 41 // Validate the SANITIZED value too — WP core runs validate_callback on the 42 // raw param before sanitize_callback, so a raw value like '<b></b>' passes 43 // the non-empty check above but sanitizes to '' and would otherwise persist 44 // a blank-named view. 45 if ( '' === trim( sanitize_text_field( $trimmed ) ) ) { 46 return new \WP_Error( 47 'rest_invalid_param', 48 __( 'View name cannot be empty.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 49 ); 50 } 40 51 return true; 41 52 } … … 67 78 'type' => 'array', 68 79 ], 80 // No minimum/maximum/validate_callback: a malformed page_size 81 // must not fail the whole view save. clamp() bounds any input, 82 // including non-scalars, without erroring. 83 'page_size' => [ 84 'required' => false, 85 'type' => 'integer', 86 'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ], 87 ], 69 88 'is_default' => [ 70 89 'required' => false, … … 100 119 'type' => 'array', 101 120 ], 121 'page_size' => [ 122 'required' => false, 123 'type' => 'integer', 124 'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ], 125 ], 102 126 'is_default' => [ 103 127 'required' => false, … … 144 168 'filters' => $request->get_param( 'filters' ), 145 169 'column_state' => $request->get_param( 'column_state' ), 170 'page_size' => $request->get_param( 'page_size' ), 146 171 'is_default' => $request->get_param( 'is_default' ), 147 172 ]; … … 181 206 $data['column_state'] = $request->get_param( 'column_state' ); 182 207 } 208 if ( null !== $request->get_param( 'page_size' ) ) { 209 $data['page_size'] = $request->get_param( 'page_size' ); 210 } 183 211 if ( null !== $request->get_param( 'is_default' ) ) { 184 212 $data['is_default'] = $request->get_param( 'is_default' ); -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/Export_Service.php
r3549389 r3663491 8 8 namespace WCABE_G2\Services; 9 9 10 use WCABE_G2\Helpers\Product_Helper; 11 10 12 defined( 'ABSPATH' ) || exit; 11 13 … … 18 20 */ 19 21 protected array $pro_columns = []; 22 23 /** 24 * Lazily built slug => name map for the Shipping Class column. Null until the 25 * first shipping_class cell is formatted, so exports without that column 26 * never touch the taxonomy. 27 * 28 * @var array<string, string>|null 29 */ 30 protected ?array $shipping_class_names = null; 20 31 21 32 /** … … 44 55 'regular_price' => 'Regular Price', 45 56 'sale_price' => 'Sale Price', 57 'cogs_value' => 'Cost of Goods', 46 58 'stock_quantity' => 'Stock Qty', 47 59 'stock_status' => 'Stock Status', … … 58 70 'date_on_sale_from' => 'Sale From', 59 71 'date_on_sale_to' => 'Sale To', 72 'date_created' => 'Published on', 73 'date_modified' => 'Modified on', 74 'image_date' => 'Image uploaded', 60 75 'featured' => 'Featured', 61 76 'virtual' => 'Virtual', 62 77 'downloadable' => 'Downloadable', 63 78 'backorders' => 'Backorders', 79 'shipping_class' => 'Shipping Class', 80 'menu_order' => 'Menu Order', 81 'button_text' => 'Button Text', 82 'product_url' => 'Product URL', 83 'upsell_ids' => 'Upsells', 84 'cross_sell_ids' => 'Cross-sells', 64 85 ]; 65 86 66 87 /** 67 88 * Generate a CSV string from an array of formatted product data. 89 * 90 * One-shot entry point, kept for callers that already hold every row in memory. 91 * It is a thin composition over begin_csv() + write_rows() — the incremental 92 * primitives the export controller streams through — so both paths produce 93 * byte-identical output by construction. 68 94 * 69 95 * @param array $products Array of formatted product arrays. … … 72 98 * @param array $units Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ]. 73 99 * @return string CSV content. 100 * @throws \RuntimeException When the temporary stream cannot be opened, written, rewound or read. 74 101 */ 75 102 public function generate_csv( array $products, array $columns = [], bool $include_bom = true, array $units = [] ): string { 103 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory PHP stream, not a filesystem operation. Used as the standard fputcsv() handle pattern for building CSV in memory; WP_Filesystem does not apply to memory streams. 104 $handle = fopen( 'php://temp', 'r+' ); 105 106 if ( false === $handle ) { 107 throw new \RuntimeException( 'Unable to open a temporary stream for CSV generation.' ); 108 } 109 110 try { 111 $resolved = $this->begin_csv( $handle, $columns, $include_bom, $units ); 112 $this->write_rows( $handle, $products, $resolved ); 113 114 if ( ! rewind( $handle ) ) { 115 throw new \RuntimeException( 'Unable to rewind the temporary CSV stream.' ); 116 } 117 118 $csv = stream_get_contents( $handle ); 119 120 if ( false === $csv ) { 121 throw new \RuntimeException( 'Unable to read the temporary CSV stream.' ); 122 } 123 124 return $csv; 125 } finally { 126 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream opened above. In finally so a write failure mid-document never leaks the handle. 127 fclose( $handle ); 128 } 129 } 130 131 /** 132 * Open a CSV document on a stream: resolve the column set, apply the Pro 133 * extra-columns filter, and write the BOM + header row. 134 * 135 * Call EXACTLY ONCE per export, before any write_rows() call, and on the SAME 136 * instance those write_rows() calls use — this is what initializes 137 * $this->pro_columns, which format_cell_value() reads for every 138 * Pro-contributed cell. Calling it per chunk would repeat the BOM and header 139 * row mid-file; calling write_rows() on a different instance would silently 140 * blank every Pro column. 141 * 142 * @param resource $handle Open, writable stream. 143 * @param array $columns Columns to include (keys from EXPORTABLE_COLUMNS). Empty = all. 144 * @param bool $include_bom Prepend UTF-8 BOM for Excel compatibility. 145 * @param array $units Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ]. 146 * @return array Resolved column keys. Pass these to every write_rows() call. 147 * @throws \RuntimeException When a write fails (e.g. the temp stream's backing disk is full). 148 */ 149 public function begin_csv( $handle, array $columns = [], bool $include_bom = true, array $units = [] ): array { 76 150 /** 77 151 * Allow extensions (Pro) to declare additional export columns. … … 81 155 $this->pro_columns = apply_filters( 'wcabe_g2_export_extra_columns', [] ); 82 156 83 // Filter to valid exportable columns only. 157 $columns = $this->resolve_columns( $columns ); 158 159 if ( $include_bom ) { 160 $bom = "\xEF\xBB\xBF"; 161 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- writing to the caller's in-memory php://temp stream. 162 $written = fwrite( $handle, $bom ); 163 164 // Compare the exact byte count, not just !== false: a full stream can 165 // report a SHORT write, which would corrupt the BOM silently. 166 if ( strlen( $bom ) !== $written ) { 167 throw new \RuntimeException( 'Unable to write the CSV byte-order mark.' ); 168 } 169 } 170 171 $this->put_row( $handle, $this->build_headers( $columns, $units ) ); 172 173 return $columns; 174 } 175 176 /** 177 * Append a chunk of formatted product rows to an open CSV document. 178 * 179 * Callable any number of times after begin_csv(), which is what lets the 180 * export stream page-by-page instead of holding every formatted row in memory. 181 * 182 * @param resource $handle Open, writable stream previously passed to begin_csv(). 183 * @param array $products Formatted product arrays for this chunk. 184 * @param array $columns Resolved columns returned by begin_csv(). 185 * @throws \RuntimeException When a write fails. 186 */ 187 public function write_rows( $handle, array $products, array $columns ): void { 188 foreach ( $products as $product ) { 189 $row = []; 190 foreach ( $columns as $col ) { 191 $row[] = $this->format_cell_value( $product, $col ); 192 } 193 $this->put_row( $handle, $row ); 194 } 195 } 196 197 /** 198 * Filter a requested column list down to the ones this export can emit. 199 * 200 * Depends on $this->pro_columns, so it must run after the extra-columns 201 * filter has been applied in begin_csv(). 202 * 203 * @param array $columns Requested columns. Empty = all native exportable columns. 204 * @return array 205 */ 206 private function resolve_columns( array $columns ): array { 84 207 if ( ! empty( $columns ) ) { 85 208 $pro_columns = $this->pro_columns; … … 94 217 } 95 218 96 // Build header labels, adjusting for units. 219 return $columns; 220 } 221 222 /** 223 * Build the header labels for a resolved column list, adjusting for units. 224 * 225 * @param array $columns Resolved column keys. 226 * @param array $units Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ]. 227 * @return array 228 */ 229 private function build_headers( array $columns, array $units ): array { 97 230 $weight_unit = $units['weight_unit'] ?? ''; 98 231 $dimension_unit = $units['dimension_unit'] ?? ''; … … 121 254 } 122 255 123 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory PHP stream, not a filesystem operation. Used as the standard fputcsv() handle pattern for building CSV in memory; WP_Filesystem does not apply to memory streams. 124 $handle = fopen( 'php://temp', 'r+' ); 125 126 if ( $include_bom ) { 127 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- writing to in-memory php://temp stream from line 135. 128 fwrite( $handle, "\xEF\xBB\xBF" ); 129 } 130 131 fputcsv( $handle, $headers ); 132 133 foreach ( $products as $product ) { 134 $row = []; 135 foreach ( $columns as $col ) { 136 $row[] = $this->format_cell_value( $product, $col ); 137 } 138 fputcsv( $handle, $row ); 139 } 140 141 rewind( $handle ); 142 $csv = stream_get_contents( $handle ); 143 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing in-memory php://temp stream from line 135. 144 fclose( $handle ); 145 146 return $csv; 256 return $headers; 257 } 258 259 /** 260 * Write one CSV record, failing loudly. 261 * 262 * A silent truncation here would surface as a valid-looking CSV that is 263 * missing rows, so every write is checked and the controller converts the 264 * failure into a JSON error rather than a partial 200 response. 265 * 266 * The escape argument is explicit because the implicit default is deprecated 267 * from PHP 8.4. '\\' is the historical default and is kept deliberately — 268 * switching to RFC 4180's '' would change how existing exports escape. 269 * 270 * @param resource $handle Open, writable stream. 271 * @param array $row Field values. 272 * @throws \RuntimeException When the write fails. 273 */ 274 private function put_row( $handle, array $row ): void { 275 if ( false === fputcsv( $handle, $row, ',', '"', '\\' ) ) { 276 throw new \RuntimeException( 'Unable to write a CSV row.' ); 277 } 147 278 } 148 279 … … 191 322 return ''; 192 323 324 case 'upsell_ids': 325 case 'cross_sell_ids': 326 // Intentional: export is type-blind. The grid hides `cross_sell_ids` on 327 // grouped/external products (WooCommerce doesn't render Cross-sells there, so it 328 // isn't editable), but the stored meta still exists and CSV must reflect what's 329 // persisted — this is also the only surface that reveals orphan cross-sells on 330 // those types. Do not add a product-type guard here. 331 // Matches WooCommerce's own product CSV exporter: comma-separated, SKU where 332 // the product has one, `id:123` otherwise — so the output stays importable by 333 // WC's native importer. An explicit case is required: the default arm would 334 // (string)-cast the array to "Array" and emit a notice. 335 if ( ! is_array( $value ) ) { 336 return ''; 337 } 338 $refs = []; 339 foreach ( $value as $ref ) { 340 if ( ! is_array( $ref ) || empty( $ref['id'] ) ) { 341 continue; 342 } 343 // Drop DEAD refs (target no longer a product). WC's own exporter does the 344 // same — array_filter( array_map( 'wc_get_product', … ) ) — and for good 345 // reason: `id:N` for a non-existent N makes WC's importer CREATE an "Import 346 // placeholder" product (status "importing") when update_existing is off, so a 347 // G2-export → WC-import round trip would manufacture one junk product per dead 348 // ref. Preserving dead refs in STORAGE stays correct (normalize_product_refs 349 // keeps them, so a chip the user never touches survives); resolving them into 350 // product creation via an interchange format is the bug. Live refs stranded on 351 // a type that hides the field (cross-sells on grouped/external) are NOT missing 352 // and still export — the type-blindness above is unaffected. 353 if ( ! empty( $ref['missing'] ) ) { 354 continue; 355 } 356 $sku = isset( $ref['sku'] ) ? (string) $ref['sku'] : ''; 357 $refs[] = '' !== $sku ? $sku : 'id:' . (int) $ref['id']; 358 } 359 return implode( ', ', $refs ); 360 193 361 case 'manage_stock': 194 362 case 'featured': … … 203 371 return (string) $value; 204 372 373 case 'menu_order': 374 // Always an integer (default 0); 0 is a real position, so export it 375 // rather than blanking like stock_quantity. 376 return (string) (int) $value; 377 205 378 case 'description': 206 379 case 'short_description': 207 380 return wp_strip_all_tags( (string) $value ); 208 381 382 case 'shipping_class': 383 // The stored value is a slug; export the human-readable NAME, which 384 // is what the grid shows and what a spreadsheet user recognises. 385 // A slug whose term has since been deleted falls back to the slug 386 // rather than blanking — losing the value silently would be worse. 387 // '' stays '' (no class / same as parent). 388 $slug = (string) $value; 389 if ( '' === $slug ) { 390 return ''; 391 } 392 if ( null === $this->shipping_class_names ) { 393 $this->shipping_class_names = []; 394 foreach ( Product_Helper::get_shipping_class_terms() as $term ) { 395 $this->shipping_class_names[ $term['slug'] ] = $term['name']; 396 } 397 } 398 return $this->shipping_class_names[ $slug ] ?? $slug; 399 209 400 default: 210 401 return (string) $value; -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/Filter_Service.php
r3549389 r3663491 8 8 namespace WCABE_G2\Services; 9 9 10 use WCABE_G2\Helpers\Product_Helper; 11 10 12 defined( 'ABSPATH' ) || exit; 11 13 12 14 class Filter_Service { 15 16 /** 17 * Fold a raw `shipping_class` REST param into a filters array. 18 * 19 * Shared by Products_Controller and Export_Controller so the two request 20 * paths cannot drift — a filter honoured by the grid but dropped by export 21 * is the classic failure here. 22 * 23 * Junk semantics: sanitize_title() maps '!!!' to '', so a naive array_map 24 * would yield [''] — a filter that looks active and matches nothing in a way 25 * nobody can see. Empties are dropped; if the param was PRESENT but nothing 26 * valid survived, the match-none flag is raised instead of leaving the filter 27 * absent (which would silently return the entire catalog). 28 * 29 * The filter callback checks strict '' equality, not array_filter()'s default 30 * truthiness — sanitize_title() can legally produce the string '0' (WP will 31 * generate that slug for a term named e.g. "0" or "0 items"), and a bare 32 * array_filter() call treats '0' as falsy and drops it same as a real empty. 33 * 34 * @param mixed $raw Raw request param (comma-separated slugs). 35 * @param array $filters Filters array to extend. 36 * @return array The filters array. 37 */ 38 public static function apply_shipping_class_param( $raw, array $filters ): array { 39 if ( null === $raw || '' === $raw ) { 40 return $filters; 41 } 42 43 $slugs = array_values( 44 array_filter( 45 array_map( 'sanitize_title', explode( ',', (string) $raw ) ), 46 static fn( string $slug ): bool => '' !== $slug 47 ) 48 ); 49 50 if ( empty( $slugs ) ) { 51 $filters['shipping_class_match_none'] = true; 52 return $filters; 53 } 54 55 $filters['shipping_class'] = $slugs; 56 return $filters; 57 } 13 58 14 59 /** … … 21 66 'categories' => $this->get_category_tree(), 22 67 'tags' => $this->get_tags(), 23 'types' => [ 'simple', 'variable', 'grouped', 'external' ],68 'types' => $this->get_product_type_options(), 24 69 'statuses' => [ 'publish', 'draft', 'pending', 'private' ], 25 70 'stock_statuses' => [ 'instock', 'outofstock', 'onbackorder' ], 71 // Same list the grid editor gets via window.wcabeG2, from one cached 72 // source, so the filter and the column can never disagree on the 73 // vocabulary. Deliberately no count — see get_shipping_class_terms(). 74 'shipping_classes' => Product_Helper::get_shipping_class_terms(), 26 75 ]; 27 76 28 77 return apply_filters( 'wcabe_g2_filter_options', $options ); 78 } 79 80 /** 81 * Product type filter options — the WooCommerce natives first, then every 82 * third-party registered type (e.g. 'bundle'), as value/label pairs (#137). 83 * Labels come from wc_get_product_types() so third-party types show their 84 * plugin-provided name; the frontend overrides the four native labels with 85 * its own short translations. 86 * 87 * @return array Array of [ 'value' => slug, 'label' => label ]. 88 */ 89 private function get_product_type_options(): array { 90 $registered = wc_get_product_types(); 91 $options = []; 92 93 foreach ( [ 'simple', 'variable', 'grouped', 'external' ] as $slug ) { 94 $options[] = [ 95 'value' => $slug, 96 'label' => (string) ( $registered[ $slug ] ?? ucfirst( $slug ) ), 97 ]; 98 unset( $registered[ $slug ] ); 99 } 100 101 foreach ( $registered as $slug => $label ) { 102 $options[] = [ 103 'value' => (string) $slug, 104 'label' => (string) $label, 105 ]; 106 } 107 108 return $options; 29 109 } 30 110 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/Operations_Service.php
r3549389 r3663491 69 69 * Duplicate one or more products. 70 70 * 71 * `created_ids` is the authoritative record of what was written: every TOP-LEVEL duplicate 72 * that saved, in creation order (never the variations duplicate_variations() creates). 73 * `products` is the subset that could also be formatted for display, and may be SHORTER — 74 * see the phase-2 comment. Count creations off created_ids; render rows off products. 75 * 71 76 * @param int[] $product_ids Source product IDs. 72 77 * @param int $count Number of copies to create per source product. 73 * @return array { success: true, products: [ ... ], failed: [ ...] }78 * @return array { success: true, created_ids: int[], products: [ ... ], failed: [ ... ], warnings?: string[] } 74 79 */ 75 80 public function duplicate_products( array $product_ids, int $count = 1 ): array { … … 79 84 80 85 $products = []; 86 $fresh_products = []; 81 87 $failed = []; 82 88 $total_var_failures = 0; … … 84 90 if ( empty( $product_ids ) ) { 85 91 return [ 86 'success' => true, 87 'products' => [], 88 'failed' => [], 92 'success' => true, 93 'created_ids' => [], 94 'products' => [], 95 'failed' => [], 89 96 ]; 90 97 } … … 126 133 $duplicate->set_tag_ids( $source->get_tag_ids() ); 127 134 $duplicate->set_attributes( $source->get_attributes() ); 128 $duplicate->set_upsell_ids( $source->get_upsell_ids() ); 129 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids() ); 135 // 'edit' context: the view getters run through 136 // woocommerce_product_get_upsell_ids / _cross_sell_ids, which third-party 137 // code can filter. Copying a filtered projection would bake that plugin's 138 // display-time view permanently into the duplicate's stored data. 139 $duplicate->set_upsell_ids( $source->get_upsell_ids( 'edit' ) ); 140 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids( 'edit' ) ); 130 141 131 142 if ( $source->is_type( 'variable' ) ) { … … 144 155 } 145 156 146 // Re-read from DB to get full data (variation_count, etc.). 157 // Re-read from DB to get full data (variation_count, etc.). Formatting is 158 // deferred until every duplicate exists, so linked-product descriptors 159 // are resolved in one batch rather than per copy — a clone carries the 160 // source's upsells/cross-sells, so every duplicate has refs to resolve. 147 161 $fresh = wc_get_product( $duplicate->get_id() ); 148 162 if ( $fresh ) { 149 $ products[] = Product_Helper::format_product( $fresh );163 $fresh_products[] = $fresh; 150 164 } 151 165 } catch ( \Exception $e ) { … … 164 178 } 165 179 180 // Every duplicate above is already IN THE DATABASE. Formatting is presentation, so 181 // nothing below may throw out of this method: an uncaught exception becomes a 500 (no 182 // REST caller wraps us), the client reports "Failed to duplicate products", and the user 183 // retries — creating a SECOND set of copies. \Throwable, not \Exception: third-party 184 // callbacks on wcabe_g2_format_product raise Error/TypeError as readily as Exception. 185 // 186 // created_ids is the truthful record of what was WRITTEN. $products is only what could 187 // also be RENDERED, and may be shorter. Never conflate them: the client counts creations 188 // off created_ids and prepends rows off products. 189 // 190 // TOP-LEVEL ONLY. $fresh_products holds parent duplicates; the variations that 191 // duplicate_variations() saves are deliberately never collected here. Including them 192 // would inflate the "Duplicated N products" count and auto-select variation rows. 193 $created_ids = array_map( static fn( $p ) => $p->get_id(), $fresh_products ); 194 $format_failures = 0; 195 196 if ( ! empty( $fresh_products ) ) { 197 $ref_map = null; 198 199 try { 200 // The hook lets Free's attribute handler and Pro's taxonomy handler warm their 201 // caches before the loop, matching the contract get_products() and batch_update() 202 // follow. 203 do_action( 'wcabe_g2_before_format_products', $created_ids ); 204 205 $ref_map = Product_Helper::build_product_ref_map( $fresh_products ); 206 } catch ( \Throwable $e ) { 207 // null, NOT []. An empty map makes project_product_refs() mark every ref dead; 208 // null makes format_product() self-resolve per product — slower, but correct. 209 $ref_map = null; 210 } 211 212 foreach ( $fresh_products as $fresh ) { 213 try { 214 $products[] = Product_Helper::format_product( $fresh, $ref_map ); 215 } catch ( \Throwable $e ) { 216 // SKIP the row — never push null. useProductOperations' toast reducer does 217 // `'variation_count' in p`, which TypeErrors on a null element. The duplicate 218 // exists; created_ids still counts it, and the warning below tells the user 219 // to reload to see it. 220 $format_failures++; 221 } 222 } 223 } 224 166 225 $result = [ 167 'success' => true, 168 'products' => $products, 169 'failed' => $failed, 226 'success' => true, 227 'created_ids' => $created_ids, 228 'products' => $products, 229 'failed' => $failed, 170 230 ]; 171 231 232 $warnings = []; 233 172 234 if ( $total_var_failures > 0 ) { 173 $result['warnings'] = [ 174 sprintf( 175 /* translators: %d: number of failed variations */ 176 __( '%d variation(s) failed to duplicate.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 177 $total_var_failures 178 ), 179 ]; 235 $warnings[] = sprintf( 236 /* translators: %d: number of failed variations */ 237 __( '%d variation(s) failed to duplicate.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 238 $total_var_failures 239 ); 240 } 241 242 if ( $format_failures > 0 ) { 243 // Appended, never replacing — a run can hit both a variation failure and a format 244 // failure, and dropping either would misreport what happened. 245 $warnings[] = sprintf( 246 /* translators: %d: number of duplicates that were created but could not be displayed */ 247 __( '%d duplicate(s) were created but could not be displayed. Reload to see them.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 248 $format_failures 249 ); 250 } 251 252 if ( ! empty( $warnings ) ) { 253 $result['warnings'] = $warnings; 180 254 } 181 255 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/Product_Service.php
r3549389 r3663491 8 8 namespace WCABE_G2\Services; 9 9 10 use WCABE_G2\Handlers\WP_Save_Hooks_Handler; 10 11 use WCABE_G2\Helpers\Product_Helper; 11 12 … … 13 14 14 15 class Product_Service { 16 17 /** Allowed Title-search match modes. */ 18 public const TITLE_SEARCH_MODES = [ 'phrase', 'all', 'any' ]; 19 20 /** Allowed Product URL filter match modes (mirrors ProductUrlOperator in TS). */ 21 public const PRODUCT_URL_OPERATORS = [ 'contains', 'not_contains', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty' ]; 22 23 /** 24 * Allowed SKU-search match modes (mirrors SkuSearchMode in TS). 25 * 26 * The empty string is deliberately NOT a member: it is the "auto" state, 27 * which routes to the legacy branch (comma-separated => exact IN, single 28 * term => substring LIKE). Keeping it out of the list means every 29 * `in_array( $mode, self::SKU_SEARCH_MODES, true )` gate treats auto and 30 * garbage identically — both fall back to legacy. 31 */ 32 public const SKU_SEARCH_MODES = [ 'contains', 'is_exactly', 'not_contains', 'starts_with', 'ends_with' ]; 33 34 /** 35 * Cap on the number of comma-separated tokens a SKU search term expands to. 36 * 37 * Unlike title search (10 words, a typo-tolerance affordance), pasting a 38 * large SKU list is a genuine bulk-edit workflow, so this is generous. It 39 * still exists because each token becomes its own LIKE/NOT LIKE clause, 40 * doubled again for the variation subquery when include_variations is on — 41 * an unbounded paste is a self-inflicted slow-query / DB-error risk on an 42 * otherwise `manage_woocommerce`-gated endpoint. 43 */ 44 public const SKU_SEARCH_MAX_TOKENS = 200; 45 46 /** 47 * Whether a Product URL operator takes no value. 48 * 49 * These stay effective with an empty value, unlike every other text filter — 50 * callers must not gate them behind a truthiness check on the value. 51 * 52 * @param string $operator 53 * @return bool 54 */ 55 public static function is_product_url_empty_check( string $operator ): bool { 56 return 'is_empty' === $operator || 'is_not_empty' === $operator; 57 } 58 59 /** 60 * Whether the Product URL filter is effective for a given operator/value pair. 61 * 62 * @param string $operator 63 * @param string $value 64 * @return bool 65 */ 66 public static function product_url_filter_is_effective( string $operator, string $value ): bool { 67 return '' !== trim( $value ) || self::is_product_url_empty_check( $operator ); 68 } 69 70 /** 71 * Normalise a raw search_sku_mode filter value to a known mode or '' (auto). 72 * 73 * Deliberately untyped: a direct service caller can put anything in the 74 * filter bag (an array from a malformed request shape, null, an int), and a 75 * `string` type hint would raise a TypeError before this could fall back. 76 * Every consumer — the SQL builder AND the per-variation matching mirrors — 77 * routes through here, so an invalid mode can never make the SQL take the 78 * legacy branch while variation narrowing takes a different one. 79 * 80 * @param mixed $mode Raw filter value. 81 * @return string One of self::SKU_SEARCH_MODES, or '' for auto/legacy. 82 */ 83 public static function normalize_sku_mode( $mode ): string { 84 return is_string( $mode ) && in_array( $mode, self::SKU_SEARCH_MODES, true ) ? $mode : ''; 85 } 86 87 /** 88 * Split a search_sku term into its comma-separated tokens. 89 * 90 * Filters on `'' !== $token` rather than array_filter()'s default 91 * truthiness: "0" is a valid WooCommerce SKU and must survive. 92 * 93 * Truncated to self::SKU_SEARCH_MAX_TOKENS. The cap lives HERE — not in the 94 * SQL builder alone — because this is the one function both the SQL builder 95 * and the PHP variation-narrowing mirror call to get their token list. A cap 96 * applied only on the SQL side would silently reopen the exact SQL/mirror 97 * divergence match modes were built to close: the query would ignore 98 * tokens past the limit while the mirror kept honouring all of them. 99 * 100 * @param string $term Raw (already sanitized) search_sku value. 101 * @return string[] Zero or more non-empty tokens, re-indexed, capped. 102 */ 103 private static function split_sku_term( string $term ): array { 104 $tokens = array_map( 'sanitize_text_field', array_map( 'trim', explode( ',', $term ) ) ); 105 $tokens = array_values( 106 array_filter( 107 $tokens, 108 static fn( string $token ): bool => '' !== $token 109 ) 110 ); 111 return array_slice( $tokens, 0, self::SKU_SEARCH_MAX_TOKENS ); 112 } 113 114 /** Signed 32-bit bounds for wp_posts.menu_order (a MySQL signed INT). */ 115 public const MENU_ORDER_MIN = -2147483648; 116 public const MENU_ORDER_MAX = 2147483647; 117 118 /** 119 * Private WP_Query marker scoping search_products()'s posts_join / posts_where callbacks 120 * to our own query. Without it those filters would apply to every WP_Query alive while 121 * they are registered — see the comment at the callbacks. 122 */ 123 private const REF_SEARCH_QUERY_VAR = 'wcabe_ref_search'; 124 125 /** 126 * Private WP_Query marker scoping get_products()'s Product URL posts_join / posts_where 127 * callbacks to our own query. Same rationale and shape as REF_SEARCH_QUERY_VAR above — 128 * see the comment at those callbacks. 129 */ 130 private const PRODUCT_URL_QUERY_VAR = 'wcabe_product_url_filter'; 15 131 16 132 /** … … 20 136 'simple' => [ 21 137 'name', 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 138 'cogs_value', 139 'date_created', 22 140 'categories', 'tags', 'description', 'short_description', 23 'weight', 'length', 'width', 'height', 141 'weight', 'length', 'width', 'height', 'shipping_class', 24 142 'date_on_sale_from', 'date_on_sale_to', 25 143 'featured', 'virtual', 'downloadable', 26 144 'manage_stock', 'backorders', 27 145 'image_id', 'gallery_image_ids', 146 'upsell_ids', 'cross_sell_ids', 147 'menu_order', 28 148 ], 29 149 'variable' => [ 30 150 'name', 'sku', 'status', 'catalog_visibility', 151 'cogs_value', 152 'date_created', 31 153 'categories', 'tags', 'description', 'short_description', 32 'weight', 'length', 'width', 'height', 154 'weight', 'length', 'width', 'height', 'shipping_class', 33 155 'featured', 34 156 'manage_stock', 'stock_quantity', 'stock_status', 'backorders', 35 157 'image_id', 'gallery_image_ids', 158 'upsell_ids', 'cross_sell_ids', 159 'menu_order', 36 160 ], 37 161 'variation' => [ 38 162 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 163 'cogs_value', 39 164 'description', 40 'weight', 'length', 'width', 'height', 165 'weight', 'length', 'width', 'height', 'shipping_class', 41 166 'date_on_sale_from', 'date_on_sale_to', 42 167 'virtual', 'downloadable', 43 168 'manage_stock', 'backorders', 44 169 'image_id', 170 'menu_order', 45 171 ], 46 172 'grouped' => [ 47 173 'name', 'sku', 'status', 'catalog_visibility', 174 'date_created', 48 175 'categories', 'tags', 'description', 'short_description', 49 176 'featured', 50 177 'image_id', 'gallery_image_ids', 178 // Upsells only. WooCommerce hides Cross-sells on grouped and external products 179 // (hide_if_grouped hide_if_external in its Linked Products metabox): cross-sells are 180 // promoted in the CART, and neither type is ever added to the cart. Writing them 181 // would persist meta that nothing ever renders. Upsells have no such restriction. 182 'upsell_ids', 183 'menu_order', 51 184 ], 52 185 'external' => [ 53 186 'name', 'sku', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility', 187 'cogs_value', 188 'date_created', 54 189 'categories', 'tags', 'description', 'short_description', 55 190 'weight', 'length', 'width', 'height', … … 57 192 'featured', 'virtual', 'downloadable', 58 193 'image_id', 'gallery_image_ids', 194 // Upsells only — see the grouped note above. 195 'upsell_ids', 196 'menu_order', 197 // External-only: the add-to-cart button label (WC_Product_External). 198 'button_text', 199 // External-only: the affiliate/destination link (WC_Product_External). 200 'product_url', 59 201 ], 202 // WooCommerce Product Bundles (third-party type, #137). The 'simple' list 203 // minus virtual/downloadable — Bundles derives those from its own 204 // "assembled/unassembled" settings, so direct writes would fight the plugin. 205 // Prices and stock are valid: static-priced bundles store them natively. 206 'bundle' => [ 207 'name', 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 208 'cogs_value', 209 'date_created', 210 'categories', 'tags', 'description', 'short_description', 211 'weight', 'length', 'width', 'height', 'shipping_class', 212 'date_on_sale_from', 'date_on_sale_to', 213 'featured', 214 'manage_stock', 'backorders', 215 'image_id', 'gallery_image_ids', 216 'upsell_ids', 'cross_sell_ids', 217 'menu_order', 218 ], 219 ]; 220 221 /** 222 * Conservative allowlist for product types with no curated ALLOWED_FIELDS 223 * entry (third-party types such as 'subscription', 'composite', 'booking'). 224 * Type-agnostic post/taxonomy-level fields only — no prices, stock, or 225 * virtual/downloadable, because third-party types commonly derive those 226 * (e.g. Subscriptions syncs price from its own subscription fields). 227 * Cross-sells excluded for the same reason as grouped/external: we cannot 228 * know the type is cart-addable. Mirrored in the TS DEFAULT_EDITABLE_FIELDS 229 * (editable-fields.ts) — keep both in lockstep. 230 */ 231 private const DEFAULT_ALLOWED_FIELDS = [ 232 'name', 'sku', 'status', 'catalog_visibility', 233 'date_created', 234 'categories', 'tags', 'description', 'short_description', 235 'weight', 'length', 'width', 'height', 'shipping_class', 236 'featured', 237 'image_id', 'gallery_image_ids', 238 'upsell_ids', 239 'menu_order', 60 240 ]; 61 241 … … 84 264 85 265 /** 266 * All queryable product types: the WooCommerce natives plus every type a 267 * third-party plugin has registered via the product-type selector (e.g. 268 * 'bundle', 'subscription', 'composite'). The natives are merged in 269 * explicitly so a plugin that filters its own type out of 270 * wc_get_product_types() cannot hide native products from the grid. 271 * 272 * @return string[] Product type slugs. 273 */ 274 public static function get_known_product_types(): array { 275 return array_values( array_unique( array_merge( 276 [ 'simple', 'variable', 'grouped', 'external' ], 277 array_map( 'strval', array_keys( wc_get_product_types() ) ) 278 ) ) ); 279 } 280 281 /** 282 * Variable-capable product types: 'variable' plus every registered type 283 * whose product class extends WC_Product_Variable (e.g. WC Subscriptions' 284 * 'variable-subscription'). These are the types whose rows carry variations 285 * the grid can count, interleave, and expand (#137 follow-up). 286 * 287 * Object-context checks use `instanceof \WC_Product_Variable` directly; 288 * this list serves the query paths (tax_query terms, SQL clauses) that run 289 * before any product object exists. Both derive from the same truth — the 290 * registered product class. 291 * 292 * Deliberately uncached: wc_get_product_types() is a filter call and 293 * get_product_classname() is cheap, and callers outside per-row loops keep 294 * the call count small. 295 * 296 * @return string[] Product type slugs. 297 */ 298 public static function get_variable_product_types(): array { 299 $types = [ 'variable' ]; 300 301 foreach ( array_keys( wc_get_product_types() ) as $slug ) { 302 $classname = \WC_Product_Factory::get_product_classname( 0, (string) $slug ); 303 if ( is_a( $classname, \WC_Product_Variable::class, true ) ) { 304 $types[] = (string) $slug; 305 } 306 } 307 308 return array_values( array_unique( $types ) ); 309 } 310 311 /** 86 312 * Get paginated products. 87 313 * 88 * @param int $page 89 * @param int $per_page 90 * @param bool $include_variations 91 * @param array $filters Optional filter criteria. 314 * @param int $page 315 * @param int $per_page 316 * @param bool $include_variations 317 * @param array $filters Optional filter criteria. 318 * @param string $orderby Sort column: 'id' (default) or 'menu_order'. 319 * @param string $order Sort direction: 'asc' or 'desc' (default). 92 320 * @return array 93 321 */ … … 96 324 int $per_page = 100, 97 325 bool $include_variations = false, 98 array $filters = [] 326 array $filters = [], 327 string $orderby = 'id', 328 string $order = 'desc' 99 329 ): array { 100 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations );330 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations, false, $orderby, $order ); 101 331 102 332 // Allow Pro to prime caches before per-product formatting. … … 104 334 do_action( 'wcabe_g2_before_format_products', $product_ids ); 105 335 106 // Prime the post cache for all gallery attachment IDs in a single query 107 // so that wp_get_attachment_image_url() inside format_product() hits the 108 // object cache instead of making individual DB queries (N+1 prevention). 109 $gallery_ids_to_prime = []; 336 // Prime the post cache for every attachment ID referenced on this page — the 337 // gallery IDs and each product's featured image — in a single query, so that 338 // wp_get_attachment_image_url() and get_image_upload_date() inside 339 // format_product() hit the object cache instead of making individual DB 340 // queries (N+1 prevention). 341 $attachment_ids_to_prime = []; 110 342 foreach ( $query_result['products'] as $product ) { 111 343 $gids = $product->get_gallery_image_ids(); 112 344 if ( ! empty( $gids ) ) { 113 array_push( $gallery_ids_to_prime, ...$gids ); 114 } 115 } 116 if ( ! empty( $gallery_ids_to_prime ) ) { 117 _prime_post_caches( array_unique( $gallery_ids_to_prime ), false, false ); 118 } 119 120 $formatted = array_map( [ Product_Helper::class, 'format_product' ], $query_result['products'] ); 345 array_push( $attachment_ids_to_prime, ...$gids ); 346 } 347 // Both contexts: view feeds wp_get_attachment_image_url() (thumbnail_url), 348 // edit/raw feeds get_image_upload_date() (image_date). They differ only 349 // when a plugin hooks woocommerce_product_get_image_id; array_unique 350 // below collapses them on every other site. 351 $featured_id = (int) $product->get_image_id(); 352 if ( $featured_id > 0 ) { 353 $attachment_ids_to_prime[] = $featured_id; 354 } 355 $raw_featured_id = (int) $product->get_image_id( 'edit' ); 356 if ( $raw_featured_id > 0 && $raw_featured_id !== $featured_id ) { 357 $attachment_ids_to_prime[] = $raw_featured_id; 358 } 359 } 360 if ( ! empty( $attachment_ids_to_prime ) ) { 361 _prime_post_caches( array_unique( $attachment_ids_to_prime ), false, false ); 362 } 363 364 // Batch-resolve every upsell/cross-sell ref on the page in one pass. Returns [] 365 // (and primes nothing) when no product on the page has refs, so stores that don't 366 // use these fields pay nothing. Must be passed into format_product() explicitly — 367 // array_map() would only pass one arg, and the null fallback would resolve each 368 // product's refs individually, i.e. the N+1 this exists to prevent. 369 $ref_map = Product_Helper::build_product_ref_map( $query_result['products'] ); 370 371 $formatted = array_map( 372 static fn( $product ) => Product_Helper::format_product( $product, $ref_map ), 373 $query_result['products'] 374 ); 121 375 122 376 // Interleave variations if requested. … … 128 382 $variation_ids_to_prime = []; 129 383 foreach ( $query_result['products'] as $product ) { 130 if ( $product->is_type( 'variable' ) ) { 384 // instanceof, not is_type('variable'): third-party variable 385 // subclasses (variable-subscription, …) carry variations too (#137). 386 if ( $product instanceof \WC_Product_Variable ) { 131 387 $children = $product->get_children(); 132 388 $children_map[ $product->get_id() ] = $children; … … 198 454 } 199 455 200 if ( ! $product->is_type( 'variable' ) ) { 456 // instanceof, not is_type('variable'): accepts third-party variable 457 // subclasses (variable-subscription, …) whose children are real 458 // variations (#137). 459 if ( ! $product instanceof \WC_Product_Variable ) { 201 460 return new \WP_Error( 202 461 'not_variable', … … 236 495 237 496 /** 497 * Default page size for stream_products() when the caller doesn't specify one. 498 * 499 * The variation-mode value is deliberately smaller. Page size bounds PARENTS, 500 * not emitted rows: get_products() primes and formats every variation of a 501 * page's parents before the stream can slice, so a variation-heavy page is the 502 * export's residual memory spike. 25 parents reduces the typical spike (a single 503 * parent with hundreds of variations can still exceed it). 504 */ 505 private const STREAM_PAGE_SIZE = 100; 506 private const STREAM_PAGE_SIZE_VARIATIONS = 25; 507 508 /** 509 * Page through filtered products, handing each formatted page to $consumer. 510 * 511 * The export path's collection seam. Nothing accumulates here — each page is 512 * released before the next query runs — which is what keeps a 10k-row export 513 * inside a modest memory_limit. Lives in the service (not the controller) 514 * because Export_Controller::export() terminates with exit() and so cannot be 515 * reached from PHPUnit; this method is the covered half. 516 * 517 * $consumer is called once per NON-EMPTY page. Cache releases run for every 518 * page regardless, including pages that yielded nothing and pages whose 519 * collection or consumption threw. 520 * 521 * @param array $filters Sanitized filters. 522 * @param bool $include_variations Interleave variations under their parents. 523 * @param int $max_rows Hard cap on total rows emitted. 524 * @param callable $consumer fn( array $formatted_rows ): void 525 * @param int|null $per_page Page size. Null = STREAM_PAGE_SIZE(_VARIATIONS). 526 * @return int Total rows emitted (0..$max_rows). 527 */ 528 public function stream_products( 529 array $filters, 530 bool $include_variations, 531 int $max_rows, 532 callable $consumer, 533 ?int $per_page = null 534 ): int { 535 if ( $max_rows <= 0 ) { 536 return 0; 537 } 538 539 if ( null === $per_page ) { 540 $per_page = $include_variations ? self::STREAM_PAGE_SIZE_VARIATIONS : self::STREAM_PAGE_SIZE; 541 } 542 543 // The export never reads total_with_variations, but get_products() would run 544 // an extra COUNT for it on EVERY page. Suppress it for the duration of the 545 // loop. A unique closure, not __return_false: removing the shared string 546 // callback could unhook a registration someone else made. 547 $suppress_variation_total = static fn() => false; 548 add_filter( 'wcabe_g2_compute_variation_total', $suppress_variation_total, 999 ); 549 550 $count = 0; 551 $page = 1; 552 553 try { 554 do { 555 // Collection lives INSIDE the try: get_products() primes caches and runs 556 // third-party format filters (Pro's ACF reads, for one), so a throw 557 // during collection must still release what it allocated. 558 try { 559 $result = $this->get_products( $page, $per_page, $include_variations, $filters ); 560 $rows = $result['products']; 561 $total_pages = (int) $result['total_pages']; 562 563 // Detach the page from $result before the next query, or two pages 564 // would be live at once. 565 unset( $result ); 566 567 // Interleaved variations mean a page can overshoot the budget 568 // mid-page. Slicing per page is equivalent to the old 569 // accumulate-then-slice: slicing a concatenation at N is slicing the 570 // last contributing page. 571 $remaining = $max_rows - $count; 572 if ( count( $rows ) > $remaining ) { 573 $rows = array_slice( $rows, 0, $remaining ); 574 } 575 576 $count += count( $rows ); 577 578 if ( ! empty( $rows ) ) { 579 $consumer( $rows ); 580 } 581 } finally { 582 unset( $rows ); 583 Product_Helper::flush_runtime_cache(); 584 585 /** 586 * Fires after each export chunk has been consumed and released. 587 * 588 * Extensions (Pro) use this to drop their own request-local caches — 589 * ACF, for instance, retains every field value it reads for the whole 590 * request, which would otherwise grow with rows × fields. 591 */ 592 do_action( 'wcabe_g2_export_after_chunk' ); 593 } 594 595 $page++; 596 } while ( $count < $max_rows && $page <= $total_pages ); 597 } finally { 598 remove_filter( 'wcabe_g2_compute_variation_total', $suppress_variation_total, 999 ); 599 } 600 601 return $count; 602 } 603 604 /** 605 * Chunked variant of get_products_by_ids() — the "export selected rows" stream. 606 * 607 * Same contract as get_products_by_ids() (normalise, clamp BEFORE any read, drop 608 * non-products, prime once per batch), but only one chunk of WC_Product objects 609 * and formatted arrays is live at a time. 610 * 611 * One intentional difference: the shared ref map and the 612 * wcabe_g2_before_format_products batch span a chunk rather than the whole 613 * selection. Output is identical; selections over $chunk_size pay slightly more 614 * priming queries in exchange for bounded memory. 615 * 616 * @param int[] $ids 617 * @param int $max_rows Hard cap on total rows emitted. 618 * @param callable $consumer fn( array $formatted_rows ): void 619 * @param int $chunk_size Ids per batch. 620 * @return int Total rows emitted (0..$max_rows). 621 */ 622 public function stream_products_by_ids( 623 array $ids, 624 int $max_rows, 625 callable $consumer, 626 int $chunk_size = 100 627 ): int { 628 if ( $max_rows <= 0 ) { 629 return 0; 630 } 631 632 // Clamp the NORMALISED list before any read, so a 50k-id request cannot fan out. 633 $ids = Product_Helper::normalize_product_ids( $ids, $max_rows ); 634 635 if ( empty( $ids ) ) { 636 return 0; 637 } 638 639 $count = 0; 640 641 foreach ( array_chunk( $ids, max( 1, $chunk_size ) ) as $chunk ) { 642 // Collection inside the try for the same reason as stream_products(): 643 // it primes caches and runs format filters before it can return. 644 try { 645 $rows = $this->get_products_by_ids( $chunk, count( $chunk ) ); 646 $count += count( $rows ); 647 648 // A chunk of only dead ids yields nothing — but it still primed post 649 // caches, so the release in finally still has work to do. 650 if ( ! empty( $rows ) ) { 651 $consumer( $rows ); 652 } 653 } finally { 654 unset( $rows ); 655 Product_Helper::flush_runtime_cache(); 656 657 /** This filter is documented in Product_Service::stream_products(). */ 658 do_action( 'wcabe_g2_export_after_chunk' ); 659 } 660 } 661 662 return $count; 663 } 664 665 /** 666 * Format an explicit list of product/variation IDs — the "export selected rows" path. 667 * 668 * Lives here, not in Export_Controller, for two reasons. It is product-collection logic, 669 * which is a service concern (the controller does route parsing and response shaping). 670 * And Export_Controller::export() terminates with exit(), so anything inlined there is 671 * unreachable from PHPUnit — this seam is what makes the selected-rows export testable. 672 * 673 * Contract, in order: 674 * 1. Normalise to positive ints, dedupe (FIRST occurrence wins), preserve caller order. 675 * intval, not absint — absint( -7 ) is 7, which would export an unrelated product. 676 * 2. Clamp the NORMALISED list to $limit, before any read, so a 50k-id request cannot 677 * fan out and so duplicates don't eat the budget. 678 * 3. Drop ids that are not a product or variation (deleted, or never one). 679 * 4. Prime once, then format once against ONE shared ref map — never per row. 680 * 681 * @param int[] $ids 682 * @param int $limit Max rows to return. 683 * @return array[] Formatted product/variation arrays, in the caller's id order. 684 */ 685 public function get_products_by_ids( array $ids, int $limit ): array { 686 if ( $limit <= 0 ) { 687 return []; 688 } 689 690 $source_ids = Product_Helper::prime_product_caches( $ids, $limit ); 691 692 if ( empty( $source_ids ) ) { 693 return []; 694 } 695 696 $wc_products = []; 697 foreach ( $source_ids as $id ) { 698 $wc_product = wc_get_product( $id ); 699 if ( $wc_product ) { 700 $wc_products[] = $wc_product; 701 } 702 } 703 704 if ( empty( $wc_products ) ) { 705 return []; 706 } 707 708 // Same contract as get_products() and batch_update(): let Free's attribute handler and 709 // Pro's taxonomy handler warm their caches before the formatting loop. 710 do_action( 'wcabe_g2_before_format_products', array_map( static fn( $p ) => $p->get_id(), $wc_products ) ); 711 712 // ONE ref map for the whole selection. Formatting per product with no map would resolve 713 // each row's refs individually — the N+1 this exists to prevent, up to $limit rows deep. 714 $ref_map = Product_Helper::build_product_ref_map( $wc_products ); 715 716 $products = []; 717 foreach ( $wc_products as $wc_product ) { 718 $products[] = $wc_product->is_type( 'variation' ) 719 ? Product_Helper::format_variation( $wc_product ) 720 : Product_Helper::format_product( $wc_product, $ref_map ); 721 } 722 723 return $products; 724 } 725 726 /** 727 * Search products and variations for the linked-product picker. 728 * 729 * Deliberately NOT WC_Data_Store::search_products(). Core's query has three properties 730 * that are wrong for a picker: 731 * 732 * 1. It matches post_content and post_excerpt, and applies its LIMIT in SQL (ordered by 733 * post_parent, post_title). A term appearing in a hundred product descriptions 734 * therefore truncates the candidate set BEFORE any post-filter can run — the real 735 * title/SKU match is never returned at all. Recall loss no downstream filter can fix. 736 * 2. It appends a numeric-ID hit AFTER its SQL exclusion and status filtering, with no 737 * exclude check and no status check — so a numeric term defeats $exclude (a product 738 * could offer itself as its own upsell) and resurrects drafts. 739 * 3. It surfaces the PARENTS of matching variations; a picker needs the variation itself. 740 * 741 * This searches exactly the three fields a picker should search — post_title, sku and 742 * global_unique_id (GTIN/EAN, which G1 never had) — so content-only matches cannot enter 743 * the candidate set by construction, and applies exclusion, post type and status 744 * uniformly, including to the numeric pin. 745 * 746 * Variations are included as selectable targets, matching WooCommerce core's own Linked 747 * Products metabox. 748 * 749 * @param string $term Search term (title, SKU, GTIN, or a bare product ID). 750 * @param int[] $exclude Product IDs to omit (e.g. the product being edited). 751 * @param int $limit Max results (clamped to 1..50). 752 * @return array[] Ordered ref descriptors: [ id, name, sku, type, status, missing ]. 753 */ 754 public function search_products( string $term, array $exclude = [], int $limit = 30 ): array { 755 global $wpdb; 756 757 $term = trim( $term ); 758 if ( '' === $term ) { 759 return []; 760 } 761 762 $exclude = array_values( array_filter( array_map( 'intval', $exclude ), static fn( $id ) => $id > 0 ) ); 763 $limit = max( 1, min( $limit, 50 ) ); 764 765 // Same status vocabulary WooCommerce uses for its own product search: private 766 // products are visible only to users who may edit them. 767 $post_statuses = current_user_can( 'edit_private_products' ) 768 ? [ 'private', 'publish' ] 769 : [ 'publish' ]; 770 771 $like = '%' . $wpdb->esc_like( $term ) . '%'; 772 $name_clause = $this->build_name_search_clause( $wpdb, $term, 'phrase' ); 773 774 // Both callbacks take the WP_Query and bail unless it carries our private marker. 775 // posts_join / posts_where fire on EVERY WP_Query for as long as they are registered — 776 // including any a third-party pre_get_posts listener spins up inside ours. Unscoped, 777 // those queries would inherit our WHERE; and because the JOIN is added only once (the 778 // strpos guard), a nested query could even receive the wcabe_ref_lookup WHERE WITHOUT 779 // the JOIN and hard-fail on an unknown column. Same guard shape as 780 // Pro_Revision_Filter_Handler::filter_post_mime_where(). 781 $join_callback = static function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 782 if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) { 783 return $join; 784 } 785 if ( false === strpos( $join, 'wcabe_ref_lookup' ) ) { 786 $join .= " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS wcabe_ref_lookup ON {$wpdb->posts}.ID = wcabe_ref_lookup.product_id "; 787 } 788 return $join; 789 }; 790 791 $where_callback = static function ( string $where, \WP_Query $query ) use ( $wpdb, $name_clause, $like ): string { 792 if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) { 793 return $where; 794 } 795 $sku_clause = $wpdb->prepare( 796 '( wcabe_ref_lookup.sku LIKE %s OR wcabe_ref_lookup.global_unique_id LIKE %s )', 797 $like, 798 $like 799 ); 800 // Title OR SKU OR GTIN. No post_content, no post_excerpt — that is the point. 801 return $where . " AND ( {$name_clause} OR {$sku_clause} ) "; 802 }; 803 804 add_filter( 'posts_join', $join_callback, 10, 2 ); 805 add_filter( 'posts_where', $where_callback, 10, 2 ); 806 807 try { 808 $query = new \WP_Query( [ 809 'post_type' => [ 'product', 'product_variation' ], 810 'post_status' => $post_statuses, 811 'post__not_in' => $exclude, 812 'posts_per_page' => $limit, 813 'fields' => 'ids', 814 'orderby' => 'title', 815 'order' => 'ASC', 816 // No total is consumed, so skip SQL_CALC_FOUND_ROWS. 817 'no_found_rows' => true, 818 'ignore_sticky_posts' => true, 819 // resolve_product_ref_ids() primes exactly the caches it needs. 820 'update_post_meta_cache' => false, 821 'update_post_term_cache' => false, 822 // The marker the two callbacks above key off. Private to us. 823 self::REF_SEARCH_QUERY_VAR => true, 824 ] ); 825 $ids = array_map( 'intval', (array) $query->posts ); 826 } finally { 827 remove_filter( 'posts_join', $join_callback, 10 ); 828 remove_filter( 'posts_where', $where_callback, 10 ); 829 } 830 831 // Numeric ID pin — our ID search; core does not offer one. 832 // 833 // ctype_digit(), NOT is_numeric(). is_numeric() also accepts '1.5', '1e3', '+12' and 834 // '-7', every one of which is a legitimate SKU to search for — and absint('1e3') is 835 // 1000, so searching for a SKU literally named "1e3" would pin an unrelated product 836 // #1000 into the results. Core's search_products() has exactly this bug. 837 // 838 // Exclusion, post type and status are applied HERE, to the pin itself. Core appends 839 // its numeric hit after those filters, which is precisely how a numeric term ends up 840 // defeating $exclude and resurrecting drafts. 841 if ( ctype_digit( $term ) ) { 842 $pin_id = absint( $term ); 843 if ( $pin_id > 0 && ! in_array( $pin_id, $exclude, true ) ) { 844 $pin_post = get_post( $pin_id ); 845 if ( 846 $pin_post 847 && in_array( $pin_post->post_type, [ 'product', 'product_variation' ], true ) 848 && in_array( $pin_post->post_status, $post_statuses, true ) 849 ) { 850 array_unshift( $ids, $pin_id ); 851 // array_unique keeps the FIRST occurrence, so the pin stays on top. 852 $ids = array_values( array_unique( $ids ) ); 853 } 854 } 855 } 856 857 $ids = array_slice( $ids, 0, $limit ); 858 859 if ( empty( $ids ) ) { 860 return []; 861 } 862 863 // Resolve the search HITS themselves. resolve_product_ref_ids(), never 864 // build_product_ref_map() — the latter takes owners and would hand back each hit's 865 // upsells instead of the hit. 866 $map = Product_Helper::resolve_product_ref_ids( $ids ); 867 868 return array_values( array_filter( array_map( 869 static fn( int $id ) => $map[ $id ] ?? null, 870 $ids 871 ) ) ); 872 } 873 874 /** 238 875 * Batch update products. 239 876 * 240 * @param array $items Array of [ 'id' => int, 'changes' => array ]. 877 * @param array $items Array of [ 'id' => int, 'changes' => array ]. 878 * @param array $context Optional per-save context threaded through to the 879 * `wcabe_g2_before_product_save` hook. Shape: [ 'batch_id' => ?string ]. 880 * Free reads nothing from it; extensions may. 241 881 * @return array Array of per-item results. 242 */ 243 public function batch_update( array $items ): array { 882 * 883 * Each item runs one "save cycle", identified by an opaque $save_token threaded 884 * through both the before- and after-save seams, so listeners can correlate the two 885 * halves of ONE cycle even when cycles nest — a save_post listener woken by the 886 * after-save seam can itself call batch_update() on the SAME product id, so post id 887 * alone does not identify a cycle. WP_Save_Hooks_Handler::finish_cycle() in the 888 * finally below is the guaranteed teardown for whatever the before-seam armed. 889 */ 890 public function batch_update( array $items, array $context = [] ): array { 244 891 $results = []; 892 // Successful saves, keyed by their index in $results. Formatting is deferred to 893 // phase 2 (after the loop) so linked-product descriptors are built once, and built 894 // fresh — see the comment there. 895 $saved = []; 896 897 // Batch-prime post, term, and meta caches for every target id in one pass so the 898 // per-item wc_get_product() below — and any listener on wcabe_g2_before_product_save 899 // that re-reads the product (e.g. Operation History's pre/post-image capture, which 900 // formats each product twice) — hit warm caches instead of firing individual queries 901 // per product. Same N+1-prevention rule get_products() follows; a no-op behaviorally. 902 $prime_ids = []; 903 foreach ( $items as $item ) { 904 $prime_id = absint( $item['id'] ?? 0 ); 905 if ( $prime_id ) { 906 $prime_ids[] = $prime_id; 907 } 908 } 909 if ( ! empty( $prime_ids ) && function_exists( '_prime_post_caches' ) ) { 910 _prime_post_caches( array_unique( $prime_ids ), true, true ); 911 } 245 912 246 913 foreach ( $items as $item ) { … … 259 926 } 260 927 928 // Opaque, unique per save cycle. Generated before anything can fail so the 929 // finally below always has a token to tear down with — even on paths that 930 // never armed anything (finish_cycle() no-ops on an unknown token). 931 $save_token = uniqid( 'wcabe_save_', true ); 932 261 933 try { 262 934 $product = wc_get_product( $id ); … … 274 946 275 947 $type = $product->get_type(); 276 $allowed_fields = self::ALLOWED_FIELDS[ $type ] ?? [];277 $allowed_fields = apply_filters( 'wcabe_g2_allowed_fields', $allowed_fields, $product );278 948 279 949 // Validate all changes before applying any. 280 $validation_errors = $this->validate_changes( $product, $changes, $allowed_fields ); 281 $validation_errors = apply_filters( 'wcabe_g2_validate_extra_fields', $validation_errors, $product, $changes ); 950 $validation = $this->validate_item( $product, $changes ); 951 $allowed_fields = $validation['allowed_fields']; 952 $validation_errors = $validation['errors']; 282 953 if ( ! empty( $validation_errors ) ) { 283 954 $results[] = [ … … 305 976 } 306 977 307 do_action( 'wcabe_g2_before_product_save', $product, $changes, $allowed_fields ); 978 // Pre-save snapshot, read before any listener can mutate the post. Passed to 979 // the after-seam so listeners get the same $post_before WordPress hands to 980 // wp_after_insert_post(). Post caches were primed for the whole batch above, 981 // so this is a cache read, not a query. 982 $post_before = get_post( $id ); 983 984 do_action( 'wcabe_g2_before_product_save', $product, $changes, $allowed_fields, $context, $save_token ); 308 985 309 986 $product->save(); 310 987 311 $is_variation = $product->is_type( 'variation' ); 312 $data = $is_variation 313 ? Product_Helper::format_variation( $product ) 314 : Product_Helper::format_product( $product ); 988 // THE WRITE HAS COMMITTED. Everything below is notification, and notification 989 // must never be able to report a committed write as failed — the client would 990 // surface it as an error and re-send rows that were in fact written. 991 // 992 // \Throwable, not \Exception: this seam wakes arbitrary third-party code (its 993 // whole purpose is firing save_post for sync plugins), and the enclosing 994 // catch below only takes \Exception — a listener raising Error/TypeError would 995 // escape batch_update() entirely and 500 the request. Same rule, and the same 996 // reasoning, as the formatting phase below. 997 try { 998 do_action( 'wcabe_g2_after_product_save', $product, $changes, $context, $post_before, $save_token ); 999 } catch ( \Throwable $t ) { 1000 $warnings[] = sprintf( 1001 /* translators: %s: error message from the third-party callback */ 1002 __( 'The product was saved, but another plugin errored while being notified: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1003 $t->getMessage() 1004 ); 1005 } 315 1006 316 1007 $result = [ 317 1008 'id' => $id, 318 1009 'success' => true, 319 'data' => $data,1010 'data' => null, // Filled in phase 2 below. 320 1011 ]; 321 1012 … … 325 1016 326 1017 $results[] = $result; 1018 1019 // Retain the saved object AND its slot in $results, so phase 2 can write 1020 // each formatted row back in place and the response order is preserved. 1021 $saved[ array_key_last( $results ) ] = $product; 327 1022 328 1023 } catch ( \Exception $e ) { … … 334 1029 ], 335 1030 ]; 1031 } finally { 1032 // Unconditional teardown for this cycle — runs on every exit path, including 1033 // the `continue`s above (validation failure, product not found) and any 1034 // exception. Deliberately a direct static call and NOT a do_action: a public 1035 // teardown hook would let a third-party Throwable escape this finally and 1036 // override an already-successful result, re-opening the exact failure mode 1037 // the after-seam's Throwable isolation just closed. Teardown is an internal 1038 // lifecycle detail, not an extension point. 1039 WP_Save_Hooks_Handler::finish_cycle( $save_token ); 1040 } 1041 } 1042 1043 // --- Phase 2: format every successful save against ONE freshly-built ref map. --- 1044 // 1045 // Deliberately after ALL saves, never before. A single batch can (a) give a product 1046 // new upsell IDs, and (b) rename or re-SKU a product that another row in the same 1047 // batch points at. A map built before the loop would miss (a) entirely and carry 1048 // stale descriptors for (b). save() invalidates each product's post cache, so the 1049 // re-prime inside resolve_product_ref_ids() reads post-write values. 1050 // 1051 // EVERY WRITE ABOVE HAS ALREADY COMMITTED. Formatting is presentation, so nothing below 1052 // may throw out of this method: an uncaught exception here becomes a 500 (no REST caller 1053 // wraps us), the client marks the whole chunk failed, and the user re-saves rows that 1054 // were in fact written. \Throwable, not \Exception — wcabe_g2_format_product is a public 1055 // filter and third-party callbacks raise Error/TypeError as readily as Exception. 1056 if ( ! empty( $saved ) ) { 1057 // Re-read every saved product before formatting. NOT an optimisation to be tidied away: 1058 // 1059 // WC_Data::apply_changes() (abstract-wc-data.php:907) merges with array_replace_recursive(), 1060 // and WC_Product does not override it. That merges ARRAY props by INDEX instead of 1061 // replacing them — so an array prop that SHRANK leaves the in-memory object corrupt after 1062 // save(). Upsells [A, B, C] → [B] persists [B] correctly, but the object is left holding 1063 // array_replace_recursive([A,B,C], [B]) === [B, B, C]. Formatting from that object hands 1064 // the grid a row contradicting the write that just succeeded, and the user watches the 1065 // link they removed come straight back. Same trap for gallery_image_ids, categories, tags 1066 // and attributes — every array prop, whenever the new value is shorter than the old. 1067 // 1068 // WooCommerce's own REST controller re-reads after save for exactly this reason 1069 // (WC_REST_CRUD_Controller::save_object). Priming first is what keeps it affordable: post 1070 // and meta for the whole batch come from one prime, so the per-product read stays off the 1071 // query path. 1072 $saved_ids = array_values( array_map( static fn( $p ) => $p->get_id(), $saved ) ); 1073 Product_Helper::prime_product_caches( $saved_ids ); 1074 1075 foreach ( $saved as $index => $product ) { 1076 $reread = wc_get_product( $product->get_id() ); 1077 if ( $reread ) { 1078 $saved[ $index ] = $reread; 1079 } 1080 } 1081 1082 $ref_map = null; 1083 1084 try { 1085 // Same contract as get_products(): let Free's attribute handler and Pro's 1086 // taxonomy handler warm their caches before a batch formatting loop, or their 1087 // per-product term lookups become the N+1 we just removed from ours. 1088 do_action( 'wcabe_g2_before_format_products', $saved_ids ); 1089 1090 $ref_map = Product_Helper::build_product_ref_map( $saved ); 1091 } catch ( \Throwable $e ) { 1092 // null, NOT []. project_product_refs() does `$ref_map[$id] ?? missing_product_ref($id)`, 1093 // so an empty map would mark every ref DEAD and tell the user their upsells are 1094 // all broken. null makes format_product() self-resolve per product — an N+1, but 1095 // correct, on a path that should never fire. 1096 $ref_map = null; 1097 } 1098 1099 foreach ( $saved as $index => $product ) { 1100 try { 1101 $results[ $index ]['data'] = $product->is_type( 'variation' ) 1102 ? Product_Helper::format_variation( $product ) 1103 : Product_Helper::format_product( $product, $ref_map ); 1104 } catch ( \Throwable $e ) { 1105 // The write committed, so `success` stays true — flipping it would strand the 1106 // pending change client-side and invite a duplicate re-send. Drop the key 1107 // rather than nulling it: BatchResult.data is declared OPTIONAL (absent), not 1108 // nullable. The client tolerates a missing data (useSave gates on 1109 // `item.success && item.data`, and clears the pending change either way), and 1110 // the background refetch supplies the true row. 1111 unset( $results[ $index ]['data'] ); 1112 } 336 1113 } 337 1114 } 338 1115 339 1116 return $results; 1117 } 1118 1119 /** 1120 * Resolve the effective field allowlist and validate a change set — WITHOUT 1121 * applying anything. 1122 * 1123 * VALIDATION ONLY — zero side effects: no setters, no save, no hooks beyond 1124 * the two established validation filters. Safe to call as a pure dry-run. 1125 * 1126 * Deliberate shared extension seam (the first since Phase A's $context arg 1127 * on wcabe_g2_before_product_save): batch_update() runs its validation 1128 * through this method, and Pro's Operation History pre-flight calls it 1129 * directly so restorability is judged by the exact allowlist + validation 1130 * pipeline that will perform the write — never by a reimplementation. 1131 * Free functions identically without any caller besides batch_update(). 1132 * 1133 * @param \WC_Product $product The product the changes target. 1134 * @param array $changes Field => value map (input vocabulary). 1135 * @return array{allowed_fields: array, errors: array} allowed_fields is the 1136 * post-filter allowlist for the product's type; errors is an 1137 * array of [ 'field' => string, 'value' => mixed, 'message' => string ]. 1138 */ 1139 public function validate_item( \WC_Product $product, array $changes ): array { 1140 $type = $product->get_type(); 1141 $allowed_fields = self::ALLOWED_FIELDS[ $type ] ?? self::DEFAULT_ALLOWED_FIELDS; 1142 $allowed_fields = apply_filters( 'wcabe_g2_allowed_fields', $allowed_fields, $product ); 1143 1144 $errors = $this->validate_changes( $product, $changes, $allowed_fields ); 1145 $errors = apply_filters( 'wcabe_g2_validate_extra_fields', $errors, $product, $changes ); 1146 1147 return [ 1148 'allowed_fields' => $allowed_fields, 1149 'errors' => $errors, 1150 ]; 340 1151 } 341 1152 … … 360 1171 } 361 1172 362 // Price validation .363 if ( in_array( $field, [ 'regular_price', 'sale_price' ], true ) ) {1173 // Price validation (COGS is a monetary cost — same numeric ≥ 0 rule). 1174 if ( in_array( $field, [ 'regular_price', 'sale_price', 'cogs_value' ], true ) ) { 364 1175 $sanitized = trim( (string) $value ); 365 1176 if ( '' !== $sanitized ) { … … 383 1194 'value' => $value, 384 1195 'message' => __( 'Stock quantity must be a valid number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1196 ]; 1197 } 1198 } elseif ( 'menu_order' === $field ) { 1199 // Menu order is a signed 32-bit integer (default 0). Empty is allowed 1200 // (treated as 0 on save). Decimals/non-numeric are rejected; so are 1201 // values outside the signed-INT range — WordPress runs MySQL non-strict, 1202 // so an out-of-range value would clamp silently on save. 1203 if ( null !== $value && '' !== $value ) { 1204 $str = (string) $value; 1205 if ( ! preg_match( '/^-?\d+$/', $str ) ) { 1206 $errors[] = [ 1207 'field' => $field, 1208 'value' => $value, 1209 'message' => __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1210 ]; 1211 } elseif ( ! self::menu_order_in_int32_range( $str ) ) { 1212 $errors[] = [ 1213 'field' => $field, 1214 'value' => $value, 1215 'message' => __( 'Menu order is out of range.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1216 ]; 1217 } 1218 } 1219 } elseif ( 'product_url' === $field ) { 1220 // External-product destination link. Policy (server authoritative): 1221 // - non-string (array/object) is rejected outright: esc_url_raw() 1222 // would throw or a (string) cast would store the literal "Array"; 1223 // - empty clears the field; 1224 // - otherwise valid iff esc_url_raw() keeps something, i.e. only 1225 // disallowed protocols (javascript:, data:, ...) are rejected. 1226 // Anything wp_allowed_protocols() permits (http, https, ftp, 1227 // mailto, tel, ...) is accepted, matching WooCommerce's own 1228 // product-data panel — being stricter would break bulk 1229 // prepend/append over legitimately stored non-http values. 1230 // Schemeless input ("example.com") is accepted and normalized to 1231 // http:// on save. 1232 // 1233 // Validated against the TRIMMED value, matching what the write path 1234 // in apply_field_change() actually stores. 1235 if ( null !== $value && ! is_string( $value ) ) { 1236 $errors[] = [ 1237 'field' => $field, 1238 'value' => $value, 1239 'message' => __( 'Product URL must be text.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1240 ]; 1241 } elseif ( null !== $value && '' !== trim( $value ) && '' === esc_url_raw( trim( $value ) ) ) { 1242 $errors[] = [ 1243 'field' => $field, 1244 'value' => $value, 1245 'message' => __( 'Product URL is not a valid URL.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 385 1246 ]; 386 1247 } … … 412 1273 } 413 1274 } 1275 } elseif ( 'date_created' === $field ) { 1276 // Publish date (post_date) — REQUIRED. Unlike sale dates it cannot be 1277 // emptied (every post has a post_date; an empty value would reset it to 1278 // "now" on save). Must be a valid Y-m-d or Y-m-d H:i:s (a bare date is 1279 // accepted for back-compat; WC pads it to 00:00:00). Future dates are allowed. 1280 $sanitized = sanitize_text_field( (string) $value ); 1281 if ( '' === $sanitized ) { 1282 $errors[] = [ 1283 'field' => $field, 1284 'value' => $value, 1285 'message' => __( 'Published date cannot be empty.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1286 ]; 1287 } elseif ( ! preg_match( '/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/', $sanitized ) ) { 1288 $errors[] = [ 1289 'field' => $field, 1290 'value' => $value, 1291 'message' => __( 'Invalid date format for Published on. Expected YYYY-MM-DD or YYYY-MM-DD HH:MM:SS.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1292 ]; 1293 } else { 1294 $parts = explode( '-', substr( $sanitized, 0, 10 ) ); 1295 $valid = checkdate( (int) $parts[1], (int) $parts[2], (int) $parts[0] ); 1296 if ( $valid && strlen( $sanitized ) > 10 ) { 1297 // Time part present: the regex guarantees an exact HH:MM:SS shape. 1298 list( $h, $i, $s ) = array_map( 'intval', explode( ':', substr( $sanitized, 11 ) ) ); 1299 $valid = ( $h <= 23 && $i <= 59 && $s <= 59 ); 1300 } 1301 if ( ! $valid ) { 1302 $errors[] = [ 1303 'field' => $field, 1304 'value' => $value, 1305 'message' => __( 'Invalid date for Published on.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1306 ]; 1307 } 1308 } 414 1309 } elseif ( in_array( $field, [ 'date_on_sale_from', 'date_on_sale_to' ], true ) ) { 415 1310 // Date validation — must be empty or Y-m-d format. … … 477 1372 // Empty array is valid — this is the "clear gallery" path. 478 1373 foreach ( $value as $gid ) { 479 if ( ! is_numeric( $gid ) || (int) $gid <= 0) {1374 if ( ! self::is_positive_int_id( $gid ) ) { 480 1375 $errors[] = [ 481 1376 'field' => $field, 482 1377 'value' => $value, 483 1378 'message' => __( 'Invalid gallery image ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1379 ]; 1380 break; 1381 } 1382 } 1383 } 1384 } elseif ( in_array( $field, [ 'upsell_ids', 'cross_sell_ids' ], true ) ) { 1385 // Accepts both wire shapes the normaliser accepts: int[] (Operation History 1386 // restore) and [{ id: int }] (the grid). Existence is deliberately NOT 1387 // checked — dead refs are preserved, see normalize_product_refs(). 1388 if ( ! is_array( $value ) ) { 1389 $errors[] = [ 1390 'field' => $field, 1391 'value' => $value, 1392 'message' => __( 'Linked products must be an array of product IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1393 ]; 1394 } else { 1395 // Empty array is valid — this is the "clear" path. 1396 foreach ( $value as $ref ) { 1397 $ref_id = is_array( $ref ) ? ( $ref['id'] ?? null ) : $ref; 1398 if ( ! self::is_positive_int_id( $ref_id ) ) { 1399 $errors[] = [ 1400 'field' => $field, 1401 'value' => $value, 1402 'message' => __( 'Invalid linked product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 484 1403 ]; 485 1404 break; … … 556 1475 ]; 557 1476 } 1477 } elseif ( 'shipping_class' === $field ) { 1478 // Unlike the enums above the vocabulary is per-site, so validate 1479 // against the taxonomy itself. The wire value must be a STRING, and 1480 // only the exact empty string '' means "clear" (no class on a parent, 1481 // "same as parent" on a variation). A non-string (null, false, an 1482 // array) is invalid input, not an implicit clear — and so is a 1483 // non-empty string that sanitize_title() reduces to '' (junk like 1484 // '!!!'). Conflating either with the real empty-string clear would 1485 // silently wipe an existing shipping class instead of rejecting the 1486 // request. 1487 if ( ! is_string( $value ) ) { 1488 $errors[] = [ 1489 'field' => $field, 1490 'value' => $value, 1491 'message' => __( 'Shipping class must be a string.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1492 ]; 1493 } else { 1494 $sanitized = sanitize_title( $value ); 1495 if ( '' !== $value && '' === $sanitized ) { 1496 $errors[] = [ 1497 'field' => $field, 1498 'value' => $value, 1499 'message' => sprintf( 1500 /* translators: %s: shipping class slug */ 1501 __( 'Unknown shipping class: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1502 $value 1503 ), 1504 ]; 1505 } elseif ( '' !== $sanitized && ! get_term_by( 'slug', $sanitized, 'product_shipping_class' ) ) { 1506 $errors[] = [ 1507 'field' => $field, 1508 'value' => $value, 1509 'message' => sprintf( 1510 /* translators: %s: shipping class slug */ 1511 __( 'Unknown shipping class: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1512 $sanitized 1513 ), 1514 ]; 1515 } 1516 } 558 1517 } 559 1518 } … … 579 1538 case 'sku': 580 1539 $product->set_sku( sanitize_text_field( (string) $value ) ); 1540 break; 1541 1542 case 'button_text': 1543 // External-only (allowlist-gated): set_button_text() exists only on 1544 // WC_Product_External. The instanceof guard turns a misconfigured 1545 // wcabe_g2_allowed_fields filter into a no-op instead of a fatal. 1546 if ( $product instanceof \WC_Product_External ) { 1547 $product->set_button_text( sanitize_text_field( (string) $value ) ); 1548 } 1549 break; 1550 1551 case 'product_url': 1552 // External-only (allowlist-gated), same instanceof rationale as 1553 // button_text above. 1554 // 1555 // esc_url_raw() here is load-bearing: WooCommerce sanitizes in the 1556 // GETTER (WC_Product_External::get_product_url() wraps esc_url_raw), 1557 // not the setter — set_product_url() only runs 1558 // htmlspecialchars_decode(). WC's own admin sanitizes before calling 1559 // the setter for the same reason. Passing the raw value would store 1560 // "example.com" in _product_url while the getter and grid display 1561 // "http://example.com", and the SQL filter matches the raw stored 1562 // meta — so the column users see would silently diverge from what 1563 // the filter finds. Canonicalize here so meta, getter and filter agree. 1564 // 1565 // trim() BEFORE esc_url_raw() is load-bearing, not tidiness: esc_url() 1566 // only LTRIMS, then percent-encodes the spaces it kept, so a 1567 // copy-pasted "https://a.com " would be stored as "https://a.com%20" 1568 // — a silently broken affiliate link. Every other text field escapes 1569 // this via sanitize_text_field(), which trims; esc_url_raw() does not. 1570 if ( $product instanceof \WC_Product_External ) { 1571 $product->set_product_url( esc_url_raw( trim( (string) $value ) ) ); 1572 } 581 1573 break; 582 1574 … … 594 1586 break; 595 1587 1588 case 'cogs_value': 1589 // Native WooCommerce Cost of Goods Sold. Route through the CRUD 1590 // setter (not raw post meta) so $product->save() persists it 1591 // rather than clobbering it from the in-memory cogs_value prop. 1592 // No-op when the feature is off — set_cogs_value() would emit 1593 // wc_doing_it_wrong() otherwise. 1594 if ( Product_Helper::is_cogs_enabled() ) { 1595 $sanitized = trim( (string) $value ); 1596 $product->set_cogs_value( '' === $sanitized ? null : (float) wc_format_decimal( $sanitized ) ); 1597 } 1598 break; 1599 596 1600 case 'stock_quantity': 597 1601 if ( null === $value || '' === $value ) { … … 600 1604 $product->set_stock_quantity( wc_stock_amount( $value ) ); 601 1605 } 1606 break; 1607 1608 case 'menu_order': 1609 // Native wp_posts.menu_order. No "unset" state — an empty value 1610 // resets to 0. set_menu_order() casts via intval() (validated as an 1611 // integer upstream in validate_changes()). 1612 $product->set_menu_order( ( '' === $value || null === $value ) ? 0 : (int) $value ); 602 1613 break; 603 1614 … … 678 1689 break; 679 1690 1691 case 'date_created': 1692 // Publish date (post_date). Validation guarantees a non-empty, valid 1693 // Y-m-d or Y-m-d H:i:s; guard defensively so an empty value never resets 1694 // post_date to "now". set_date_created() interprets the string as 1695 // site-local and persists post_date at full date-time granularity (a bare 1696 // Y-m-d is padded to 00:00:00 site time). 1697 $sanitized = sanitize_text_field( (string) $value ); 1698 if ( '' !== $sanitized ) { 1699 $product->set_date_created( $sanitized ); 1700 } 1701 break; 1702 680 1703 case 'date_on_sale_from': 681 1704 $sanitized = sanitize_text_field( (string) $value ); … … 726 1749 $product->set_gallery_image_ids( $ids ); 727 1750 break; 728 } 1751 1752 case 'upsell_ids': 1753 $product->set_upsell_ids( self::normalize_product_refs( $value, $product->get_id() ) ); 1754 break; 1755 1756 case 'cross_sell_ids': 1757 $product->set_cross_sell_ids( self::normalize_product_refs( $value, $product->get_id() ) ); 1758 break; 1759 1760 case 'shipping_class': 1761 $sanitized = sanitize_title( (string) $value ); 1762 if ( '' === $sanitized ) { 1763 // 0 makes WC write an empty term list: no class on a parent, 1764 // "same as parent" on a variation. Both are the same clear. 1765 $product->set_shipping_class_id( 0 ); 1766 break; 1767 } 1768 $term = get_term_by( 'slug', $sanitized, 'product_shipping_class' ); 1769 if ( ! $term ) { 1770 throw new \InvalidArgumentException( 1771 sprintf( 1772 /* translators: %s: shipping class slug */ 1773 esc_html__( 'Unknown shipping class: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1774 esc_html( $sanitized ) 1775 ) 1776 ); 1777 } 1778 $product->set_shipping_class_id( (int) $term->term_id ); 1779 break; 1780 } 1781 } 1782 1783 /** 1784 * Is $v a positive integer id — strictly? 1785 * 1786 * NOT is_numeric() followed by a cast. is_numeric() accepts '1.9', '1e3', '+12', ' 12' and 1787 * '-7', and the (int)/absint() cast that follows SILENTLY REWRITES them: (int)'1e3' is 1000, 1788 * (int)'1.9' is 1, absint('-7') is 7. A value that passed validation would then be written 1789 * as a DIFFERENT id. This is the same trap search_products() documents at its ctype_digit() 1790 * call, and the same one it notes WooCommerce core falls into. 1791 * 1792 * ctype_digit() alone is not enough either: it passes arbitrarily long digit strings, and 1793 * (int)'999999999999999999999999' saturates to PHP_INT_MAX. The round-trip below pins the 1794 * cast as lossless. It also rejects leading-zero forms like '012'. 1795 * 1796 * Shared by the linked-product and gallery_image_ids validators — both feed a cast. 1797 * 1798 * @param mixed $v 1799 * @return bool 1800 */ 1801 private static function is_positive_int_id( $v ): bool { 1802 if ( is_int( $v ) ) { 1803 return $v > 0; 1804 } 1805 1806 if ( is_string( $v ) ) { 1807 return ctype_digit( $v ) && (string) (int) $v === $v && (int) $v > 0; 1808 } 1809 1810 // Floats, bools, null, arrays, objects. A JSON `12` decodes to a PHP int, and JS 1811 // JSON.stringify(12.0) emits "12", so no legitimate client lands here. 1812 return false; 1813 } 1814 1815 /** 1816 * Normalise a linked-product list to an ordered int[]. 1817 * 1818 * Accepts BOTH wire shapes, deliberately: 1819 * 1820 * [ { id: 12, name: …, sku: … }, … ] — from the grid, which needs names to render 1821 * [ 12, 45, 900 ] — raw ints 1822 * 1823 * The raw-int path is not hypothetical. Operation History captures the canonical CRUD 1824 * value (an int[]) and Pro_History_Input_Adapter::to_input() is an identity passthrough, 1825 * so restore replays int[] straight back through here. Without dual-shape support, 1826 * restore breaks. It also makes a future paste-from-spreadsheet path work for free. 1827 * 1828 * Rules, in order: 1829 * 1. Coerce each entry to a positive int (from 'id' when it's an object/array). 1830 * 2. Dedupe, preserving FIRST-occurrence order. Order is data — WooCommerce renders 1831 * upsells in stored order — so never rebuild this from a set. 1832 * 3. Drop self-reference. (Also dropped client-side, per row, so the bulk preview 1833 * shows what actually lands; this is the server-side backstop.) 1834 * 4. Do NOT check existence. WooCommerce core's set_upsell_ids() stores whatever it 1835 * is given, which is exactly why dead refs accumulate in real stores. We match it 1836 * and surface dead refs in the UI instead, so a user who never touches a dead chip 1837 * keeps it. Silently mutating data the user didn't touch is what erodes trust in a 1838 * tool that writes to a hundred products at once. 1839 * 5. An empty array is valid — that is Clear. 1840 * 1841 * Note this runs AFTER validate_changes(), whose is_positive_int_id() gate has already 1842 * rejected any entry whose (int) cast would be lossy — so the casts below are safe. 1843 * 1844 * @param mixed $value int[] or array of { id: int }. 1845 * @param int $self_id The product being written (excluded from its own list). 1846 * @return int[] 1847 */ 1848 private static function normalize_product_refs( $value, int $self_id ): array { 1849 if ( ! is_array( $value ) ) { 1850 return []; 1851 } 1852 1853 $ids = []; 1854 1855 foreach ( $value as $entry ) { 1856 if ( is_array( $entry ) ) { 1857 $id = isset( $entry['id'] ) ? (int) $entry['id'] : 0; 1858 } elseif ( is_object( $entry ) ) { 1859 $id = isset( $entry->id ) ? (int) $entry->id : 0; 1860 } else { 1861 $id = (int) $entry; 1862 } 1863 1864 if ( $id <= 0 || $id === $self_id ) { 1865 continue; 1866 } 1867 1868 // array_search-free dedupe that preserves first-occurrence order. 1869 if ( ! isset( $ids[ $id ] ) ) { 1870 $ids[ $id ] = true; 1871 } 1872 } 1873 1874 return array_map( 'intval', array_keys( $ids ) ); 729 1875 } 730 1876 … … 788 1934 789 1935 /** 1936 * Whether a menu_order string — already matching /^-?\d+$/ — fits the signed 1937 * 32-bit range (self::MENU_ORDER_MIN..MAX). 1938 * 1939 * Compares as strings (magnitude length, then lexically) to stay 1940 * platform-independent: a lossy (int) cast of an out-of-range value clamps to 1941 * PHP_INT_MAX on 32-bit builds and would wrongly pass. Leading zeros are 1942 * tolerated (mirrors the regex and the frontend): '0005' → 5, '0'/'-0' → 0. 1943 * 1944 * @param string $value Digit string, optionally sign-prefixed. 1945 * @return bool 1946 */ 1947 private static function menu_order_in_int32_range( string $value ): bool { 1948 $negative = ( '' !== $value && '-' === $value[0] ); 1949 $magnitude = ltrim( $negative ? substr( $value, 1 ) : $value, '0' ); 1950 if ( '' === $magnitude ) { 1951 return true; // All zeros → 0, always in range. 1952 } 1953 // Derive the bound magnitudes from the constants (string-safe, no abs()). 1954 $limit = $negative ? ltrim( (string) self::MENU_ORDER_MIN, '-' ) : (string) self::MENU_ORDER_MAX; 1955 if ( strlen( $magnitude ) !== strlen( $limit ) ) { 1956 return strlen( $magnitude ) < strlen( $limit ); 1957 } 1958 return strcmp( $magnitude, $limit ) <= 0; 1959 } 1960 1961 /** 790 1962 * Get products via WooCommerce CRUD API. 791 1963 * … … 794 1966 * @param array $filters Optional filter criteria. 795 1967 * @param bool $include_variations Whether variations will be interleaved (affects meta_query behavior). 796 * @param bool $ids_only_all_variable When true, override pagination/type to fetch ALL matching 797 * variable parent IDs (unbounded, fields=ids). Used by the 798 * variation-total helper; products array in this mode is int[]. 1968 * @param bool $ids_only_all_variable When true, override pagination/type to fetch ALL matching 1969 * variable parent IDs (unbounded, fields=ids). Used by the 1970 * variation-total helper; products array in this mode is int[]. 1971 * @param string $orderby Sort column: 'id' (default) or 'menu_order'. 1972 * @param string $order Sort direction: 'asc' or 'desc' (default). 799 1973 * @return array{ products: \WC_Product[]|int[], total: int, total_pages: int } 800 1974 */ … … 804 1978 array $filters = [], 805 1979 bool $include_variations = false, 806 bool $ids_only_all_variable = false 1980 bool $ids_only_all_variable = false, 1981 string $orderby = 'id', 1982 string $order = 'desc' 807 1983 ): array { 1984 // Map the whitelisted orderby to a WC/WP_Query-supported value. 'menu_order' 1985 // passes through to WP_Query natively; append ID as a stable tiebreaker so 1986 // pagination is deterministic when rows share a menu_order (the default 0) — 1987 // WP_Query parses the space-separated form into "menu_order <dir>, ID <dir>". 1988 // Anything else falls back to ID. 1989 $wc_orderby = ( 'menu_order' === $orderby ) ? 'menu_order ID' : 'ID'; 1990 $wc_order = ( 'asc' === strtolower( $order ) ) ? 'ASC' : 'DESC'; 1991 1992 // A shipping_class param that survived REST sanitisation as nothing at all 1993 // (e.g. '!!!') must match NOTHING, never widen to the whole catalog. There 1994 // is no "impossible slug" to encode this with — sanitize_title() output is 1995 // exactly the legal slug vocabulary — so the REST layer sets this flag. 1996 if ( ! empty( $filters['shipping_class_match_none'] ) ) { 1997 return [ 1998 'products' => [], 1999 'total' => 0, 2000 'total_pages' => 1, 2001 ]; 2002 } 2003 808 2004 $args = [ 809 2005 'limit' => $per_page, 810 2006 'page' => $page, 811 'orderby' => 'ID',812 'order' => 'DESC',2007 'orderby' => $wc_orderby, 2008 'order' => $wc_order, 813 2009 'paginate' => true, 814 2010 ]; … … 824 2020 $args['return'] = 'ids'; 825 2021 2022 $variable_types = self::get_variable_product_types(); 826 2023 if ( ! empty( $filters['type'] ) ) { 827 2024 $intersection = array_values( 828 array_intersect( (array) $filters['type'], [ 'variable' ])2025 array_intersect( (array) $filters['type'], $variable_types ) 829 2026 ); 830 2027 if ( empty( $intersection ) ) { … … 837 2034 $filters['type'] = $intersection; 838 2035 } else { 839 $filters['type'] = [ 'variable' ];2036 $filters['type'] = $variable_types; 840 2037 } 841 2038 } … … 844 2041 $args['status'] = ! empty( $filters['status'] ) 845 2042 ? (array) $filters['status'] 846 : [ 'publish', 'draft', 'private', 'pending' ]; 847 848 // Type filter — use provided values or defaults. 2043 // 'future' (Scheduled) is included so products given a future publish 2044 // date — which WordPress auto-transitions publish -> future — stay 2045 // visible in the grid instead of silently disappearing. 2046 : [ 'publish', 'draft', 'private', 'pending', 'future' ]; 2047 2048 // Type filter — use provided values, or default to every known type so 2049 // third-party types (bundle, subscription, …) appear in the grid (#137). 849 2050 $args['type'] = ! empty( $filters['type'] ) 850 2051 ? (array) $filters['type'] 851 : [ 'simple', 'variable', 'grouped', 'external' ];2052 : self::get_known_product_types(); 852 2053 853 2054 // ID filter — simple comparison or expression (CSV + ranges). … … 892 2093 // Search filters — handled via posts_join/posts_where hooks below. 893 2094 $search_name_term = ! empty( $filters['search_name'] ) ? sanitize_text_field( $filters['search_name'] ) : ''; 894 $search_sku_term = ! empty( $filters['search_sku'] ) ? sanitize_text_field( $filters['search_sku'] ) : ''; 2095 // Title match mode: 'all' (every word, AND, default), 'any' (any word, OR), 'phrase' (whole-term LIKE). 2096 $search_name_mode = isset( $filters['search_name_mode'] ) && in_array( $filters['search_name_mode'], self::TITLE_SEARCH_MODES, true ) 2097 ? $filters['search_name_mode'] 2098 : 'all'; 2099 // Strict '' test, not empty(): "0" is a valid SKU and must not be dropped. 2100 $search_sku_term = isset( $filters['search_sku'] ) ? sanitize_text_field( (string) $filters['search_sku'] ) : ''; 2101 $has_search_sku = '' !== $search_sku_term; 2102 // SKU match mode: '' (auto/legacy) or one of self::SKU_SEARCH_MODES. 2103 $search_sku_mode = self::normalize_sku_mode( $filters['search_sku_mode'] ?? '' ); 895 2104 $search_description_term = ! empty( $filters['search_description'] ) ? sanitize_text_field( $filters['search_description'] ) : ''; 896 2105 $search_short_description_term = ! empty( $filters['search_short_description'] ) ? sanitize_text_field( $filters['search_short_description'] ) : ''; 2106 2107 // Product URL filter (external/affiliate link). "Effective" is NOT a 2108 // non-empty-value test: is_empty / is_not_empty carry meaning with none. 2109 $product_url_operator = isset( $filters['product_url_operator'] ) && in_array( $filters['product_url_operator'], self::PRODUCT_URL_OPERATORS, true ) 2110 ? $filters['product_url_operator'] 2111 : 'contains'; 2112 $product_url_value = isset( $filters['product_url_value'] ) ? trim( (string) $filters['product_url_value'] ) : ''; 2113 $has_product_url_filter = self::product_url_filter_is_effective( $product_url_operator, $product_url_value ); 897 2114 898 2115 // Category filter — exact matching (include_children => false). … … 901 2118 } 902 2119 2120 // Tag filter — WC's data store natively maps 'product_tag_id' (term IDs) to a 2121 // product_tag tax_query. Flat taxonomy, so no include_children handling needed 2122 // (unlike category). Note WC's 'tag' arg expects slugs — do not use it. 2123 if ( ! empty( $filters['tag'] ) ) { 2124 $args['product_tag_id'] = array_map( 'absint', (array) $filters['tag'] ); 2125 } 2126 903 2127 // Stock status filter. 904 2128 if ( ! empty( $filters['stock_status'] ) ) { … … 906 2130 } 907 2131 2132 // Shipping class filter. WooCommerce's own 'shipping_class' query var 2133 // builds the tax_query, matching on SLUG (see WC_Product_Data_Store_CPT) — 2134 // which is why the whole feature speaks slugs rather than term IDs. 2135 // Parent-level by design: variations follow whichever parent matched, and 2136 // a variation that overrides its parent's class is not independently 2137 // surfaced. Narrowing per variation would instead HIDE every variation 2138 // that inherits (its own stored class is empty), which is the wrong 2139 // default for the common case. 2140 if ( ! empty( $filters['shipping_class'] ) ) { 2141 $args['shipping_class'] = array_map( 'sanitize_title', (array) $filters['shipping_class'] ); 2142 } 2143 908 2144 // Price range filters — passed as custom args, handled in handle_custom_query_args. 909 2145 if ( isset( $filters['price_min'] ) && '' !== $filters['price_min'] ) { … … 912 2148 if ( isset( $filters['price_max'] ) && '' !== $filters['price_max'] ) { 913 2149 $args['wcabe_price_max'] = wc_format_decimal( $filters['price_max'] ); 2150 } 2151 2152 // Cost of Goods range filters — gated on the COGS feature so direct service 2153 // calls carrying stale cogs_min/max can't reach the variation matcher or 2154 // get_cogs_value() while the feature is disabled. 2155 if ( Product_Helper::is_cogs_enabled() ) { 2156 if ( isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min'] ) { 2157 $args['wcabe_cogs_min'] = (float) $filters['cogs_min']; 2158 } 2159 if ( isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max'] ) { 2160 $args['wcabe_cogs_max'] = (float) $filters['cogs_max']; 2161 } 914 2162 } 915 2163 … … 943 2191 if ( $modified_after || $modified_before ) { 944 2192 $args['date_modified'] = $this->build_date_range( $modified_after, $modified_before ); 2193 } 2194 2195 // Sale-schedule date range filters. WC's native date_on_sale_from/date_on_sale_to 2196 // query args are deliberately NOT used: for day precision WC compares the range 2197 // end against the START of that day and derives both bounds through gmdate(), so 2198 // sale-to timestamps stored at local 23:59:59 (WC admin's own convention) never 2199 // match and non-UTC sites shift by a day. We compute site-local bounds here and 2200 // expand them in the query callbacks below. 2201 list( $sale_start_min, $sale_start_max ) = $this->sale_date_filter_bounds( 2202 $filters['sale_start_after'] ?? '', 2203 $filters['sale_start_before'] ?? '' 2204 ); 2205 list( $sale_end_min, $sale_end_max ) = $this->sale_date_filter_bounds( 2206 $filters['sale_end_after'] ?? '', 2207 $filters['sale_end_before'] ?? '' 2208 ); 2209 2210 if ( null !== $sale_start_min ) { 2211 $args['wcabe_sale_start_min'] = $sale_start_min; 2212 } 2213 if ( null !== $sale_start_max ) { 2214 $args['wcabe_sale_start_max'] = $sale_start_max; 2215 } 2216 if ( null !== $sale_end_min ) { 2217 $args['wcabe_sale_end_min'] = $sale_end_min; 2218 } 2219 if ( null !== $sale_end_max ) { 2220 $args['wcabe_sale_end_max'] = $sale_end_max; 945 2221 } 946 2222 … … 978 2254 979 2255 $has_stock_qty_filter = isset( $args['wcabe_stock_qty_min'] ) || isset( $args['wcabe_stock_qty_max'] ); 2256 $has_cogs_filter = isset( $args['wcabe_cogs_min'] ) || isset( $args['wcabe_cogs_max'] ); 980 2257 $has_manage_stock_filter = ! empty( $args['wcabe_manage_stock'] ); 981 2258 $has_backorders_filter = ! empty( $args['wcabe_backorders'] ); 982 983 // Hook for exact category matching, multi-value stock_status, price, stock qty, manage stock, backorders, and attribute filtering. 984 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || $has_price_filter || $has_stock_qty_filter || $has_manage_stock_filter || $has_backorders_filter || ! empty( $attribute_tax_queries ); 2259 $has_sale_date_filter = isset( $args['wcabe_sale_start_min'] ) || isset( $args['wcabe_sale_start_max'] ) 2260 || isset( $args['wcabe_sale_end_min'] ) || isset( $args['wcabe_sale_end_max'] ); 2261 2262 // Hook for exact category matching, multi-value stock_status, price, stock qty, COGS, manage stock, backorders, sale dates, and attribute filtering. 2263 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || $has_price_filter || $has_stock_qty_filter || $has_cogs_filter || $has_manage_stock_filter || $has_backorders_filter || $has_sale_date_filter || ! empty( $attribute_tax_queries ); 985 2264 986 2265 $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations ): array { … … 1061 2340 } 1062 2341 2342 // Cost of Goods range filtering via _cogs_total_value meta. 2343 // When include_variations is true, handled by the posts_where hook 2344 // below instead — variable parents carry a non-null default cost, so 2345 // the _stock-style "OR IS NULL" trick doesn't apply. 2346 $has_cogs_min = isset( $query_vars['wcabe_cogs_min'] ); 2347 $has_cogs_max = isset( $query_vars['wcabe_cogs_max'] ); 2348 2349 if ( ( $has_cogs_min || $has_cogs_max ) && ! $include_variations ) { 2350 if ( ! isset( $wp_query_args['meta_query'] ) ) { 2351 $wp_query_args['meta_query'] = []; 2352 } 2353 2354 if ( $has_cogs_min && $has_cogs_max ) { 2355 $cogs_range_clause = [ 2356 'key' => '_cogs_total_value', 2357 'value' => [ (float) $query_vars['wcabe_cogs_min'], (float) $query_vars['wcabe_cogs_max'] ], 2358 'compare' => 'BETWEEN', 2359 'type' => 'DECIMAL(10,2)', 2360 ]; 2361 } elseif ( $has_cogs_min ) { 2362 $cogs_range_clause = [ 2363 'key' => '_cogs_total_value', 2364 'value' => (float) $query_vars['wcabe_cogs_min'], 2365 'compare' => '>=', 2366 'type' => 'DECIMAL(10,2)', 2367 ]; 2368 } else { 2369 $cogs_range_clause = [ 2370 'key' => '_cogs_total_value', 2371 'value' => (float) $query_vars['wcabe_cogs_max'], 2372 'compare' => '<=', 2373 'type' => 'DECIMAL(10,2)', 2374 ]; 2375 } 2376 2377 // Guard _cogs_total_value != '' (grouped AND with the range) so a 2378 // stray empty row can't CAST to 0 and match a 0-inclusive range. 2379 $wp_query_args['meta_query'][] = [ 2380 'relation' => 'AND', 2381 [ 2382 'key' => '_cogs_total_value', 2383 'value' => '', 2384 'compare' => '!=', 2385 ], 2386 $cogs_range_clause, 2387 ]; 2388 } 2389 2390 // Sale-schedule date range filtering via _sale_price_dates_from/_to 2391 // (Unix timestamps). When include_variations is true this is handled by 2392 // the posts_where hook below instead, so a variable parent can surface 2393 // via a matching child variation. 2394 $sale_bounds = [ 2395 '_sale_price_dates_from' => [ 2396 $query_vars['wcabe_sale_start_min'] ?? null, 2397 $query_vars['wcabe_sale_start_max'] ?? null, 2398 ], 2399 '_sale_price_dates_to' => [ 2400 $query_vars['wcabe_sale_end_min'] ?? null, 2401 $query_vars['wcabe_sale_end_max'] ?? null, 2402 ], 2403 ]; 2404 2405 $has_sale_filter = false; 2406 foreach ( $sale_bounds as $bounds ) { 2407 if ( null !== $bounds[0] || null !== $bounds[1] ) { 2408 $has_sale_filter = true; 2409 break; 2410 } 2411 } 2412 2413 if ( $has_sale_filter && ! $include_variations ) { 2414 if ( ! isset( $wp_query_args['meta_query'] ) ) { 2415 $wp_query_args['meta_query'] = []; 2416 } 2417 2418 foreach ( $sale_bounds as $meta_key => $bounds ) { 2419 list( $min, $max ) = $bounds; 2420 if ( null === $min && null === $max ) { 2421 continue; 2422 } 2423 2424 if ( null !== $min && null !== $max ) { 2425 $sale_range_clause = [ 2426 'key' => $meta_key, 2427 'value' => [ $min, $max ], 2428 'compare' => 'BETWEEN', 2429 'type' => 'NUMERIC', 2430 ]; 2431 } elseif ( null !== $min ) { 2432 $sale_range_clause = [ 2433 'key' => $meta_key, 2434 'value' => $min, 2435 'compare' => '>=', 2436 'type' => 'NUMERIC', 2437 ]; 2438 } else { 2439 $sale_range_clause = [ 2440 'key' => $meta_key, 2441 'value' => $max, 2442 'compare' => '<=', 2443 'type' => 'NUMERIC', 2444 ]; 2445 } 2446 2447 // Guard != '' (grouped AND with the range) so an empty row can't 2448 // CAST to 0 and satisfy a <=-bounded range. 2449 $wp_query_args['meta_query'][] = [ 2450 'relation' => 'AND', 2451 [ 2452 'key' => $meta_key, 2453 'value' => '', 2454 'compare' => '!=', 2455 ], 2456 $sale_range_clause, 2457 ]; 2458 } 2459 2460 // A variable parent never legitimately carries a sale schedule (the 2461 // plugin treats sale dates as absent on them), and in this mode a child 2462 // never surfaces its parent — but stale _sale_price_dates_* rows can 2463 // survive a simple->variable type conversion, so exclude them outright. 2464 if ( ! isset( $wp_query_args['tax_query'] ) ) { 2465 $wp_query_args['tax_query'] = []; 2466 } 2467 $wp_query_args['tax_query'][] = [ 2468 'taxonomy' => 'product_type', 2469 'field' => 'slug', 2470 'terms' => self::get_variable_product_types(), 2471 'operator' => 'NOT IN', 2472 ]; 2473 } 2474 1063 2475 // Stock quantity range filtering via _stock meta. 1064 2476 // When include_variations is false, standard meta_query applies. … … 1144 2556 $search_where_callback = null; 1145 2557 1146 if ( $search_name_term || $ search_sku_term) {2558 if ( $search_name_term || $has_search_sku ) { 1147 2559 global $wpdb; 1148 2560 1149 2561 // JOIN is only needed when filtering by SKU. 1150 if ( $ search_sku_term) {2562 if ( $has_search_sku ) { 1151 2563 $search_join_callback = function ( string $join ) use ( $wpdb ): string { 1152 2564 if ( strpos( $join, 'wcabe_sku_lookup' ) === false ) { … … 1160 2572 $name_clause = ''; 1161 2573 if ( $search_name_term ) { 1162 $name_like = '%' . $wpdb->esc_like( $search_name_term ) . '%'; 1163 $name_clause = $wpdb->prepare( "( {$wpdb->posts}.post_title LIKE %s )", $name_like ); 2574 $name_clause = $this->build_name_search_clause( $wpdb, $search_name_term, $search_name_mode ); 1164 2575 } 1165 2576 1166 2577 $sku_clause = ''; 1167 if ( $search_sku_term ) { 1168 if ( strpos( $search_sku_term, ',' ) !== false ) { 1169 // CSV mode: exact match against each SKU. 1170 $skus = array_filter( array_map( 'trim', explode( ',', $search_sku_term ) ) ); 1171 $skus = array_map( 'sanitize_text_field', $skus ); 1172 if ( ! empty( $skus ) ) { 1173 $placeholders = implode( ',', array_fill( 0, count( $skus ), '%s' ) ); 1174 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a comma-joined string of `%s` placeholders, one per element of $skus, all expanded by prepare() via spread. 1175 $sku_clause = $wpdb->prepare( 1176 "( wcabe_sku_lookup.sku IN ($placeholders) )", 1177 ...$skus 1178 ); 1179 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 1180 } 1181 } else { 1182 // Single term: LIKE search. 1183 $sku_like = '%' . $wpdb->esc_like( $search_sku_term ) . '%'; 1184 $sku_clause = $wpdb->prepare( '( wcabe_sku_lookup.sku LIKE %s )', $sku_like ); 1185 } 2578 if ( $has_search_sku ) { 2579 $sku_clause = $this->build_sku_search_clause( $wpdb, $search_sku_term, $search_sku_mode, $include_variations ); 1186 2580 } 1187 2581 … … 1207 2601 $description_where_callback = function ( string $where ) use ( $wpdb, $desc_like, $include_variations ): string { 1208 2602 if ( $include_variations ) { 1209 $where .= $wpdb->prepare( 1210 " AND ( {$wpdb->posts}.post_content LIKE %s OR {$wpdb->posts}.ID IN (" 2603 // The child branch is gated on the parent being variable — a 2604 // stale variation left behind by a variable->simple type 2605 // conversion must not surface its now-simple parent. 2606 $is_variable = $this->sql_is_variable_product(); 2607 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $is_variable is this class's own hardcoded SQL literal (sql_is_variable_product()); the search term is passed through prepare() via %s. 2608 $where .= $wpdb->prepare( 2609 " AND ( {$wpdb->posts}.post_content LIKE %s OR ( {$is_variable} AND {$wpdb->posts}.ID IN (" 1211 2610 . " SELECT p.post_parent FROM {$wpdb->posts} p" 1212 2611 . " INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id" … … 1214 2613 . " AND pm.meta_key = '_variation_description'" 1215 2614 . " AND pm.meta_value LIKE %s" 1216 . ' ) ) ',2615 . ' ) ) )', 1217 2616 $desc_like, 1218 2617 $desc_like 1219 2618 ); 2619 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1220 2620 } else { 1221 2621 $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like ); … … 1326 2726 } 1327 2727 2728 // Product URL filtering (external/affiliate link, _product_url meta). 2729 // Raw SQL rather than meta_query for the same reason as stock qty above: 2730 // WP's meta_query cannot express "meta row missing", which the is_empty 2731 // operator needs. Every column is alias-qualified — an unqualified 2732 // post_id/meta_key/meta_value becomes ambiguous the moment another meta 2733 // join (e.g. stock qty) is active in the same query. 2734 $product_url_join_callback = null; 2735 $product_url_where_callback = null; 2736 $product_url_marker_callback = null; 2737 2738 if ( $has_product_url_filter ) { 2739 global $wpdb; 2740 2741 // posts_join / posts_where fire on EVERY WP_Query for as long as they are 2742 // registered — including any a third-party pre_get_posts listener spins up 2743 // inside ours. Unscoped, those queries would inherit our WHERE; and because 2744 // the JOIN is added only once (the strpos guard), a nested query could even 2745 // receive the WHERE WITHOUT the JOIN and hard-fail on an unknown column. 2746 // So both callbacks below bail unless the query carries our private marker, 2747 // which this callback stamps onto our own query only. Same guard shape as 2748 // search_products() / REF_SEARCH_QUERY_VAR. 2749 $product_url_marker_callback = static function ( array $wp_query_args ): array { 2750 $wp_query_args[ self::PRODUCT_URL_QUERY_VAR ] = true; 2751 return $wp_query_args; 2752 }; 2753 2754 $product_url_join_callback = function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 2755 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 2756 return $join; 2757 } 2758 if ( strpos( $join, 'wcabe_product_url_meta' ) === false ) { 2759 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_product_url_meta ON ( {$wpdb->posts}.ID = wcabe_product_url_meta.post_id AND wcabe_product_url_meta.meta_key = '_product_url' ) "; 2760 } 2761 return $join; 2762 }; 2763 2764 $is_external_sql = $this->sql_is_external_product(); 2765 2766 $product_url_where_callback = function ( string $where, \WP_Query $query ) use ( $wpdb, $product_url_operator, $product_url_value, $is_external_sql ): string { 2767 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 2768 return $where; 2769 } 2770 2771 $col = 'wcabe_product_url_meta.meta_value'; 2772 $has_a_url = "( {$col} IS NOT NULL AND {$col} <> '' )"; 2773 2774 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $has_a_url and $col are this method's own hardcoded column expressions; the user-supplied URL value is passed through prepare() via %s in every branch. 2775 switch ( $product_url_operator ) { 2776 case 'is_empty': 2777 $clause = "( {$col} IS NULL OR {$col} = '' )"; 2778 break; 2779 2780 case 'is_not_empty': 2781 $clause = $has_a_url; 2782 break; 2783 2784 case 'not_contains': 2785 // Scoped to rows that HAVE a URL, so this reads as "URLs that 2786 // don't contain X" rather than "every external product without one". 2787 $clause = $wpdb->prepare( 2788 "( {$has_a_url} AND {$col} NOT LIKE %s )", 2789 '%' . $wpdb->esc_like( $product_url_value ) . '%' 2790 ); 2791 break; 2792 2793 case 'starts_with': 2794 $clause = $wpdb->prepare( 2795 "( {$has_a_url} AND {$col} LIKE %s )", 2796 $wpdb->esc_like( $product_url_value ) . '%' 2797 ); 2798 break; 2799 2800 case 'ends_with': 2801 $clause = $wpdb->prepare( 2802 "( {$has_a_url} AND {$col} LIKE %s )", 2803 '%' . $wpdb->esc_like( $product_url_value ) 2804 ); 2805 break; 2806 2807 case 'contains': 2808 default: 2809 $clause = $wpdb->prepare( 2810 "( {$has_a_url} AND {$col} LIKE %s )", 2811 '%' . $wpdb->esc_like( $product_url_value ) . '%' 2812 ); 2813 break; 2814 } 2815 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 2816 2817 // EVERY operator is scoped to external products, not just is_empty. 2818 // product_url is an external-only field: Product_Helper::format_product() 2819 // and the CSV exporter both emit '' for any other type, and the grid cell 2820 // is blank and read-only there. A non-external row carries _product_url 2821 // only as STALE meta (converted external → simple), so matching it would 2822 // surface a row whose Product URL is blank on every surface the user can 2823 // see, with no way to tell why it matched. Scoping the whole filter keeps 2824 // "what the filter finds" and "what the column shows" the same set. 2825 return $where . " AND ( {$clause} AND {$is_external_sql} ) "; 2826 }; 2827 } 2828 2829 // ── Combined variation-aware filtering: COGS, menu order, sale dates, image date ── 2830 // 2831 // ONE callback pair covers all four deliberately. Each of them can surface a 2832 // variable parent either on its OWN value or through a child variation, and 2833 // emitting them as separate `( own_i OR child_i )` clauses would let *different* 2834 // siblings satisfy different filters — surfacing a parent whose children are 2835 // then all rejected by variation_matches_filters(), i.e. an empty parent row. 2836 // (The sale-date filter already solved this across its own two ranges; this 2837 // generalises the same fix across filters.) The emitted shape is 2838 // 2839 // ( own_A AND own_B AND … ) OR ( is_variable AND EXISTS ( … child_A AND child_B … ) ) 2840 // 2841 // so a variable parent surfaces only when it matches entirely on its own 2842 // values, or when ONE renderable child satisfies every active predicate — 2843 // exactly what variation_matches_filters() accepts when it narrows the 2844 // children. Distributing the child branch this way also emits the correlated 2845 // subquery once instead of once per filter. With a single filter active the 2846 // shape collapses to that filter's previous `( own OR child )` clause, so 2847 // single-filter behaviour is unchanged. 2848 // 2849 // SCOPE: these four filters only. Price, stock status, stock quantity, manage 2850 // stock, backorders, SKU search and description search surface parents through 2851 // other mechanisms (meta_query, the OR-NULL stock posts_where above, IN 2852 // subqueries below) and are NOT folded in — a filter from this group combined 2853 // with one of those can still produce an empty parent row. Known and accepted; 2854 // folding the rest in is tracked separately. 2855 // 2856 // The child status list matches WC get_children() (publish/private only) so a 2857 // parent never surfaces via a variation that won't render. The child branch is 2858 // gated on the parent being variable — a variable->simple type conversion can 2859 // leave published variation posts behind, and a stale child must not surface 2860 // its now-simple parent (interleaving only narrows children of variable 2861 // parents, so such a false positive is uncorrectable downstream). COGS and 2862 // sale dates participate only when include_variations is on; with it off they 2863 // stay on their meta_query paths and never reach this callback, so with 2864 // variations off only menu order and image date are emitted here. 2865 $combined_join_callback = null; 2866 $combined_where_callback = null; 2867 2868 $cogs_min = isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min'] ? (float) $filters['cogs_min'] : null; 2869 $cogs_max = isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max'] ? (float) $filters['cogs_max'] : null; 2870 2871 $menu_order_min = ( isset( $filters['menu_order_min'] ) && '' !== $filters['menu_order_min'] ) ? (int) $filters['menu_order_min'] : null; 2872 $menu_order_max = ( isset( $filters['menu_order_max'] ) && '' !== $filters['menu_order_max'] ) ? (int) $filters['menu_order_max'] : null; 2873 2874 // Image-uploaded bounds. The signal is the featured image attachment's 2875 // post_date — a site-local DATETIME, the same basis the grid renders — so 2876 // day-precision Y-m-d bounds expand to literal day-start / day-end strings and 2877 // compare directly. No timestamp arithmetic, hence no DST edge (contrast the 2878 // sale-date bounds above, which are Unix timestamps stored in meta). 2879 $image_date_after = isset( $filters['image_date_after'] ) ? (string) $filters['image_date_after'] : ''; 2880 $image_date_before = isset( $filters['image_date_before'] ) ? (string) $filters['image_date_before'] : ''; 2881 $image_date_min = '' !== $image_date_after ? $image_date_after . ' 00:00:00' : null; 2882 $image_date_max = '' !== $image_date_before ? $image_date_before . ' 23:59:59' : null; 2883 2884 $use_cogs = $include_variations && $has_cogs_filter; 2885 $use_sale = $include_variations && $has_sale_date_filter; 2886 $use_menu = ( null !== $menu_order_min || null !== $menu_order_max ); 2887 $use_image = ( null !== $image_date_min || null !== $image_date_max ); 2888 2889 if ( $use_cogs || $use_sale || $use_menu || $use_image ) { 2890 global $wpdb; 2891 2892 // Active sale ranges (either or both of from/to may be filtered). 2893 $sale_predicates = ! $use_sale ? [] : array_values( 2894 array_filter( 2895 [ 2896 [ 2897 'meta_key' => '_sale_price_dates_from', 2898 'parent_alias' => 'wcabe_sale_from_meta', 2899 'child_alias' => 'wcabe_sale_from_child', 2900 'min' => $sale_start_min, 2901 'max' => $sale_start_max, 2902 ], 2903 [ 2904 'meta_key' => '_sale_price_dates_to', 2905 'parent_alias' => 'wcabe_sale_to_meta', 2906 'child_alias' => 'wcabe_sale_to_child', 2907 'min' => $sale_end_min, 2908 'max' => $sale_end_max, 2909 ], 2910 ], 2911 static function ( array $range ): bool { 2912 return null !== $range['min'] || null !== $range['max']; 2913 } 2914 ) 2915 ); 2916 2917 // Only COGS, sale dates and image date need parent-side joins; menu_order 2918 // reads a native wp_posts column. 2919 if ( $use_cogs || $use_sale || $use_image ) { 2920 $combined_join_callback = function ( string $join ) use ( $wpdb, $use_cogs, $use_image, $sale_predicates ): string { 2921 if ( $use_cogs && strpos( $join, 'wcabe_cogs_meta' ) === false ) { 2922 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_cogs_meta ON ( {$wpdb->posts}.ID = wcabe_cogs_meta.post_id AND wcabe_cogs_meta.meta_key = '_cogs_total_value' ) "; 2923 } 2924 2925 foreach ( $sale_predicates as $range ) { 2926 if ( strpos( $join, $range['parent_alias'] ) === false ) { 2927 $join .= " LEFT JOIN {$wpdb->postmeta} AS {$range['parent_alias']} ON ( {$wpdb->posts}.ID = {$range['parent_alias']}.post_id AND {$range['parent_alias']}.meta_key = '{$range['meta_key']}' ) "; 2928 } 2929 } 2930 2931 if ( $use_image && strpos( $join, 'wcabe_img_meta' ) === false ) { 2932 // No GROUP BY guard: a duplicate _thumbnail_id meta row would 2933 // duplicate the product row. Same accepted exposure as 2934 // wcabe_stock_meta / wcabe_cogs_meta — _thumbnail_id is written 2935 // through update_post_meta (single-row) on every core/WC path. 2936 // The post_type gate mirrors Product_Helper::get_image_upload_date(), 2937 // so a _thumbnail_id pointing at a non-attachment is excluded here 2938 // and renders blank there. 2939 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_img_meta ON ( {$wpdb->posts}.ID = wcabe_img_meta.post_id AND wcabe_img_meta.meta_key = '_thumbnail_id' ) "; 2940 $join .= " LEFT JOIN {$wpdb->posts} AS wcabe_img ON ( wcabe_img.ID = CAST( wcabe_img_meta.meta_value AS UNSIGNED ) AND wcabe_img.post_type = 'attachment' ) "; 2941 } 2942 2943 return $join; 2944 }; 2945 } 2946 2947 $combined_where_callback = function ( string $where ) use ( 2948 $wpdb, 2949 $include_variations, 2950 $use_cogs, 2951 $use_sale, 2952 $use_menu, 2953 $use_image, 2954 $sale_predicates, 2955 $cogs_min, 2956 $cogs_max, 2957 $menu_order_min, 2958 $menu_order_max, 2959 $image_date_min, 2960 $image_date_max 2961 ): string { 2962 // Bounded comparison in the three shapes prepare() needs. $expr and $ph 2963 // are always this method's own literals, never request data. 2964 $build_bound = function ( string $expr, $min, $max, string $ph ) use ( $wpdb ): string { 2965 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $expr is a hardcoded column/CAST expression and $ph a hardcoded placeholder token (invisible to the sniff but expanded by prepare()); the bound values are passed through prepare(). 2966 if ( null !== $min && null !== $max ) { 2967 return $wpdb->prepare( "( {$expr} BETWEEN {$ph} AND {$ph} )", $min, $max ); 2968 } 2969 if ( null !== $min ) { 2970 return $wpdb->prepare( "( {$expr} >= {$ph} )", $min ); 2971 } 2972 return $wpdb->prepare( "( {$expr} <= {$ph} )", $max ); 2973 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 2974 }; 2975 2976 $is_variable = $this->sql_is_variable_product(); 2977 2978 $own_parts = []; 2979 $child_parts = []; 2980 $child_joins = ''; 2981 2982 if ( $use_cogs ) { 2983 $own_col = 'wcabe_cogs_meta.meta_value'; 2984 $child_col = 'wcabe_child_cogs.meta_value'; 2985 $child_joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_cogs ON ( wcabe_child_cogs.post_id = c.ID AND wcabe_child_cogs.meta_key = '_cogs_total_value' ) "; 2986 $own_parts[] = "( {$own_col} <> '' AND " . $build_bound( "CAST( {$own_col} AS DECIMAL(10,2) )", $cogs_min, $cogs_max, '%f' ) . ' )'; 2987 $child_parts[] = "( {$child_col} <> '' AND " . $build_bound( "CAST( {$child_col} AS DECIMAL(10,2) )", $cogs_min, $cogs_max, '%f' ) . ' )'; 2988 } 2989 2990 if ( $use_menu ) { 2991 $own_parts[] = $build_bound( "{$wpdb->posts}.menu_order", $menu_order_min, $menu_order_max, '%d' ); 2992 $child_parts[] = $build_bound( 'c.menu_order', $menu_order_min, $menu_order_max, '%d' ); 2993 } 2994 2995 if ( $use_image ) { 2996 $child_joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_img_meta ON ( wcabe_child_img_meta.post_id = c.ID AND wcabe_child_img_meta.meta_key = '_thumbnail_id' ) "; 2997 $child_joins .= " INNER JOIN {$wpdb->posts} AS wcabe_child_img ON ( wcabe_child_img.ID = CAST( wcabe_child_img_meta.meta_value AS UNSIGNED ) AND wcabe_child_img.post_type = 'attachment' ) "; 2998 // A product with no image, a stale _thumbnail_id, or one pointing at 2999 // a non-attachment leaves post_date NULL through the LEFT JOIN, and 3000 // NULL fails every comparison — excluded, as intended. 3001 $own_parts[] = $build_bound( 'wcabe_img.post_date', $image_date_min, $image_date_max, '%s' ); 3002 $child_parts[] = $build_bound( 'wcabe_child_img.post_date', $image_date_min, $image_date_max, '%s' ); 3003 } 3004 3005 foreach ( $sale_predicates as $range ) { 3006 $parent_col = "{$range['parent_alias']}.meta_value"; 3007 $child_col = "{$range['child_alias']}.meta_value"; 3008 $child_joins .= " INNER JOIN {$wpdb->postmeta} AS {$range['child_alias']} ON ( {$range['child_alias']}.post_id = c.ID AND {$range['child_alias']}.meta_key = '{$range['meta_key']}' ) "; 3009 $own_parts[] = "( {$parent_col} <> '' AND " . $build_bound( "CAST( {$parent_col} AS UNSIGNED )", $range['min'], $range['max'], '%d' ) . ' )'; 3010 $child_parts[] = "( {$child_col} <> '' AND " . $build_bound( "CAST( {$child_col} AS UNSIGNED )", $range['min'], $range['max'], '%d' ) . ' )'; 3011 } 3012 3013 if ( $use_sale ) { 3014 // A variable parent's own sale meta is necessarily stale (the plugin 3015 // treats sale dates as absent on variable parents and never writes 3016 // them), so while a sale filter is active such a parent may only 3017 // match through a child variation. 3018 $own_parts[] = "NOT {$is_variable}"; 3019 } 3020 3021 $own = empty( $own_parts ) ? '' : '( ' . implode( ' AND ', $own_parts ) . ' )'; 3022 3023 $child = ''; 3024 if ( $include_variations && ! empty( $child_parts ) ) { 3025 $child = "( {$is_variable} AND EXISTS ( SELECT 1 FROM {$wpdb->posts} c{$child_joins}" 3026 . " WHERE c.post_parent = {$wpdb->posts}.ID AND c.post_type = 'product_variation'" 3027 . " AND c.post_status IN ( 'publish', 'private' ) AND " . implode( ' AND ', $child_parts ) . ' ) )'; 3028 } 3029 3030 // Parenthesize so the OR cannot escape the query's other predicates. 3031 if ( '' !== $own && '' !== $child ) { 3032 $where .= " AND ( {$own} OR {$child} ) "; 3033 } elseif ( '' !== $own ) { 3034 $where .= " AND {$own} "; 3035 } elseif ( '' !== $child ) { 3036 $where .= " AND {$child} "; 3037 } 3038 3039 return $where; 3040 }; 3041 } 3042 1328 3043 if ( $needs_custom_query ) { 1329 3044 add_filter( … … 1338 3053 add_filter( 'posts_join', $stock_qty_join_callback, 10, 1 ); 1339 3054 add_filter( 'posts_where', $stock_qty_where_callback, 10, 1 ); 3055 } 3056 3057 if ( $product_url_join_callback ) { 3058 // Marker first — it must be on the query args before WP_Query is built. 3059 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10, 1 ); 3060 add_filter( 'posts_join', $product_url_join_callback, 10, 2 ); 3061 add_filter( 'posts_where', $product_url_where_callback, 10, 2 ); 3062 } 3063 3064 if ( $combined_join_callback ) { 3065 add_filter( 'posts_join', $combined_join_callback, 10, 1 ); 3066 } 3067 if ( $combined_where_callback ) { 3068 add_filter( 'posts_where', $combined_where_callback, 10, 1 ); 1340 3069 } 1341 3070 … … 1371 3100 remove_filter( 'posts_where', $stock_qty_where_callback, 10 ); 1372 3101 } 3102 if ( $product_url_join_callback ) { 3103 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10 ); 3104 remove_filter( 'posts_join', $product_url_join_callback, 10 ); 3105 remove_filter( 'posts_where', $product_url_where_callback, 10 ); 3106 } 3107 if ( $combined_join_callback ) { 3108 remove_filter( 'posts_join', $combined_join_callback, 10 ); 3109 } 3110 if ( $combined_where_callback ) { 3111 remove_filter( 'posts_where', $combined_where_callback, 10 ); 3112 } 1373 3113 if ( $search_join_callback ) { 1374 3114 remove_filter( 'posts_join', $search_join_callback, 10 ); … … 1405 3145 1406 3146 /** 3147 * Build the post_title search WHERE clause for the given match mode. 3148 * 3149 * - 'all' (default): every whitespace-separated word must appear (joined with AND). 3150 * - 'any': at least one word must appear (joined with OR). 3151 * - 'phrase' or a single-word term: the whole term as one LIKE 3152 * (matches the historical behavior — contiguous substring, order-sensitive). 3153 * 3154 * Multi-word matching splits on whitespace and caps at 10 words to bound the 3155 * number of LIKE clauses. The fragment is parenthesized so the caller can 3156 * safely AND it with the SKU clause. 3157 * 3158 * @param \wpdb $wpdb WordPress database handle. 3159 * @param string $term Sanitized search term. 3160 * @param string $mode One of 'phrase', 'all', 'any'. 3161 * @return string Prepared SQL fragment (already wrapped in parentheses). 3162 */ 3163 private function build_name_search_clause( \wpdb $wpdb, string $term, string $mode ): string { 3164 $words = []; 3165 if ( 'phrase' !== $mode ) { 3166 $split = preg_split( '/\s+/', trim( $term ), -1, PREG_SPLIT_NO_EMPTY ); 3167 $words = is_array( $split ) ? $split : []; 3168 } 3169 3170 // Phrase mode, or fewer than two words: original whole-term LIKE. 3171 if ( count( $words ) < 2 ) { 3172 $like = '%' . $wpdb->esc_like( $term ) . '%'; 3173 return $wpdb->prepare( "( {$wpdb->posts}.post_title LIKE %s )", $like ); 3174 } 3175 3176 // Cap the word count to bound the LIKE chain length. 3177 $words = array_slice( $words, 0, 10 ); 3178 $connector = ( 'any' === $mode ) ? 'OR' : 'AND'; 3179 3180 $likes = []; 3181 $values = []; 3182 foreach ( $words as $word ) { 3183 $likes[] = "{$wpdb->posts}.post_title LIKE %s"; 3184 $values[] = '%' . $wpdb->esc_like( $word ) . '%'; 3185 } 3186 3187 $sql = '( ' . implode( " {$connector} ", $likes ) . ' )'; 3188 3189 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $sql is a fixed chain of `%s` placeholders (one per word), each expanded by prepare() via the spread of $values below. 3190 return $wpdb->prepare( $sql, ...$values ); 3191 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 3192 } 3193 3194 /** 3195 * Build the SKU search WHERE clause for the given match mode. 3196 * 3197 * A comma-separated term is a LIST OF VALUES applied under the selected 3198 * mode — the mode is authoritative, the comma only multiplies it. The four 3199 * positive modes OR their values together (match ANY); not_contains ANDs 3200 * them (match NONE). 3201 * 3202 * Mode '' is the legacy "auto" behaviour, preserved for requests and saved 3203 * views written before match modes existed: a comma in the term means 3204 * exact IN, otherwise a whole-term substring LIKE. It maps onto 3205 * is_exactly / contains, which produce that same SQL — with two 3206 * deliberate exceptions, not regressions: "0" is now treated as a real 3207 * token (see split_sku_term()) instead of being dropped by truthiness 3208 * filtering, and the token count is capped at self::SKU_SEARCH_MAX_TOKENS. 3209 * 3210 * not_contains reads the SKU through COALESCE so a product with no SKU (or 3211 * no lookup row at all — the JOIN is a LEFT JOIN) counts as "does not 3212 * contain". The positive modes deliberately keep the bare column: NULL LIKE 3213 * is NULL, which correctly excludes, and the plain column stays 3214 * index-eligible for the anchored LIKE of starts_with. 3215 * 3216 * The fragment is parenthesized so the caller can safely AND it with the 3217 * name clause. 3218 * 3219 * @param \wpdb $wpdb 3220 * @param string $term Raw (already sanitized) search_sku value. 3221 * @param string $mode One of self::SKU_SEARCH_MODES, or '' for auto/legacy. 3222 * @param bool $include_variations Whether to also surface parents via a matching child. 3223 * @return string SQL fragment, or '' when the term holds no usable tokens. 3224 */ 3225 private function build_sku_search_clause( \wpdb $wpdb, string $term, string $mode, bool $include_variations ): string { 3226 $mode = self::normalize_sku_mode( $mode ); 3227 $tokens = self::split_sku_term( $term ); 3228 3229 if ( '' === $mode ) { 3230 // Legacy auto: the comma was the (implicit) mode selector. 3231 $mode = ( strpos( $term, ',' ) !== false ) ? 'is_exactly' : 'contains'; 3232 } 3233 3234 // Comma-only term ( ",,, " ) yields no tokens — no clause, as before. 3235 if ( empty( $tokens ) ) { 3236 return ''; 3237 } 3238 3239 $negated = ( 'not_contains' === $mode ); 3240 3241 /** 3242 * Build a parenthesized predicate over one column for every token. 3243 * 3244 * @param string $column SQL column expression. 3245 * @return array{0: string, 1: string[]} [ sql, prepare values ] 3246 */ 3247 $build_predicate = function ( string $column ) use ( $wpdb, $mode, $tokens, $negated ): array { 3248 if ( 'is_exactly' === $mode ) { 3249 $placeholders = implode( ',', array_fill( 0, count( $tokens ), '%s' ) ); 3250 return [ "( {$column} IN ({$placeholders}) )", $tokens ]; 3251 } 3252 3253 $operator = $negated ? 'NOT LIKE' : 'LIKE'; 3254 $connector = $negated ? ' AND ' : ' OR '; 3255 3256 $parts = []; 3257 $values = []; 3258 foreach ( $tokens as $token ) { 3259 $escaped = $wpdb->esc_like( $token ); 3260 switch ( $mode ) { 3261 case 'starts_with': 3262 $values[] = $escaped . '%'; 3263 break; 3264 case 'ends_with': 3265 $values[] = '%' . $escaped; 3266 break; 3267 default: // contains / not_contains. 3268 $values[] = '%' . $escaped . '%'; 3269 break; 3270 } 3271 $parts[] = "{$column} {$operator} %s"; 3272 } 3273 3274 return [ '( ' . implode( $connector, $parts ) . ' )', $values ]; 3275 }; 3276 3277 $parent_column = $negated ? "COALESCE( wcabe_sku_lookup.sku, '' )" : 'wcabe_sku_lookup.sku'; 3278 [ $parent_sql, $parent_values ] = $build_predicate( $parent_column ); 3279 3280 if ( ! $include_variations ) { 3281 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $parent_sql is a fixed chain of `%s` placeholders (one per token), each expanded by prepare() via the spread below. 3282 return $wpdb->prepare( $parent_sql, ...$parent_values ); 3283 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 3284 } 3285 3286 // Also surface a parent whose child variation matches, so 3287 // interleave_variations() can show that variation. Status predicate 3288 // matches WC get_children(), which returns only publish/private 3289 // variations. The child branch is gated on the parent being variable — 3290 // a stale variation left behind by a variable->simple type conversion 3291 // must not surface its now-simple parent. 3292 // 3293 // not_contains joins LEFT with meta_key in the ON clause so a variation 3294 // with no _sku row still counts as "does not contain"; an INNER JOIN 3295 // would make it invisible. 3296 $child_column = $negated ? "COALESCE( pm.meta_value, '' )" : 'pm.meta_value'; 3297 [ $child_sql, $child_values ] = $build_predicate( $child_column ); 3298 3299 $child_join = $negated 3300 ? "LEFT JOIN {$wpdb->postmeta} pm ON ( p.ID = pm.post_id AND pm.meta_key = '_sku' )" 3301 : "INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id"; 3302 $meta_key_where = $negated ? '' : " AND pm.meta_key = '_sku'"; 3303 3304 $is_variable = $this->sql_is_variable_product(); 3305 3306 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $parent_sql/$child_sql are fixed chains of `%s` placeholders (one per token), all expanded by prepare() via the merged spread below. 3307 return $wpdb->prepare( 3308 "( {$parent_sql}" 3309 . " OR ( {$is_variable} AND {$wpdb->posts}.ID IN (" 3310 . " SELECT p.post_parent FROM {$wpdb->posts} p" 3311 . " {$child_join}" 3312 . " WHERE p.post_type = 'product_variation'" 3313 . " AND p.post_status IN ('publish','private')" 3314 . $meta_key_where 3315 . " AND {$child_sql}" 3316 . ' ) ) )', 3317 ...array_merge( $parent_values, $child_values ) 3318 ); 3319 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 3320 } 3321 3322 /** 1407 3323 * Build a WC date range string for date_created / date_modified args. 1408 3324 * … … 1427 3343 } 1428 3344 return '<=' . $before; 3345 } 3346 3347 /** 3348 * Resolve a sale-date filter range to site-local Unix timestamp bounds. 3349 * 3350 * _sale_price_dates_from / _sale_price_dates_to are stored as Unix timestamps 3351 * derived from site-local dates, and the grid renders them in site time — so 3352 * bounds must be computed in site time too. 3353 * 3354 * The upper bound is derived from an explicit local 23:59:59, NOT from 3355 * "local midnight + DAY_IN_SECONDS - 1": a local day is 23 or 25 hours long 3356 * across a DST transition, so the arithmetic form would spill into the next 3357 * day or drop the final hour. 3358 * 3359 * @param string $after Start date (YYYY-MM-DD) or empty. 3360 * @param string $before End date (YYYY-MM-DD) or empty. 3361 * @return array{0: ?int, 1: ?int} [ min_timestamp, max_timestamp ], null when unbounded. 3362 */ 3363 private function sale_date_filter_bounds( string $after, string $before ): array { 3364 $min = '' !== $after ? wc_string_to_datetime( $after )->getTimestamp() : null; 3365 $max = '' !== $before ? wc_string_to_datetime( $before . ' 23:59:59' )->getTimestamp() : null; 3366 3367 return [ $min, $max ]; 3368 } 3369 3370 /** 3371 * SQL EXISTS expression testing whether the current wp_posts row is a 3372 * variable product. 3373 * 3374 * Used by the posts_where filter callbacks to gate child-variation EXISTS 3375 * branches on the parent actually being variable. A variable->simple type 3376 * conversion can leave published variation posts behind, and without the 3377 * gate a stale child's matching meta would surface its now-simple parent — 3378 * a false-positive row interleave_variations() can neither narrow nor 3379 * remove, since it only processes children of variable parents. 3380 * 3381 * @return string 3382 */ 3383 private function sql_is_variable_product(): string { 3384 global $wpdb; 3385 3386 // Covers third-party variable subclasses (variable-subscription, …), not 3387 // just the native 'variable' slug (#137). Slugs come from registered 3388 // product classes, not user input, but are escaped anyway. 3389 $slugs_in = implode( 3390 ',', 3391 array_map( 3392 static fn( string $slug ): string => "'" . esc_sql( $slug ) . "'", 3393 self::get_variable_product_types() 3394 ) 3395 ); 3396 3397 return "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} tr" 3398 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 3399 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 3400 . " WHERE tr.object_id = {$wpdb->posts}.ID AND tt.taxonomy = 'product_type' AND t.slug IN ( {$slugs_in} ) )"; 3401 } 3402 3403 /** 3404 * SQL predicate matching external (affiliate) products. 3405 * 3406 * Mirrors sql_is_variable_product(). Scopes EVERY Product URL filter operator 3407 * to the only type that surfaces the field — see the note at the filter's 3408 * where-callback. 3409 * 3410 * @return string 3411 */ 3412 private function sql_is_external_product(): string { 3413 global $wpdb; 3414 3415 return "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} tr" 3416 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 3417 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 3418 . " WHERE tr.object_id = {$wpdb->posts}.ID AND tt.taxonomy = 'product_type' AND t.slug = 'external' )"; 1429 3419 } 1430 3420 … … 1518 3508 $result[] = $product; 1519 3509 1520 if ( $product['type'] === 'variable' && $product['variation_count'] > 0 ) { 3510 // variation_count is only non-zero for variable-capable products 3511 // (format_product gates it on instanceof WC_Product_Variable), so it 3512 // doubles as the "has interleavable children" signal — covering 3513 // third-party variable subclasses, not just type === 'variable' (#137). 3514 if ( ! empty( $product['variation_count'] ) ) { 1521 3515 // Use pre-collected children IDs when available, falling back to 1522 3516 // loading the parent product (should only happen if called without … … 1528 3522 } 1529 3523 3524 // If the parent surfaced via its OWN SKU, show all its variations: 3525 // a SKU match on the product itself means "show this product", not 3526 // "filter its variants". Other meta filters still narrow children. 3527 // 3528 // NOT for not_contains: under a negative mode nearly every parent 3529 // matches on its own SKU, so honouring the rule there would switch 3530 // variation narrowing off wholesale and show children whose SKUs DO 3531 // contain the term. Accepted consequence: a parent that passes 3532 // not_contains while all of its children fail renders as an empty 3533 // parent row — the same class of edge as the limitation below. 3534 // 3535 // Inverse edge, positive modes: build_sku_search_clause() ORs the 3536 // parent's own SKU with "any child matches" — a parent whose OWN 3537 // SKU does NOT match still surfaces (and its non-matching own SKU 3538 // is what the grid displays) as long as at least one child does. 3539 // That parent row's visible SKU therefore will not itself contain 3540 // the searched term. Intentional: the parent row is a container for 3541 // the matching child, not a claim that the parent's own SKU matches. 3542 // 3543 // Known limitation: when search_sku and search_description match 3544 // DIFFERENT siblings, the SQL ORs surface the parent but the 3545 // per-child AND below can hide both → empty parent. Pre-existing 3546 // with search_description; not resolved here. 3547 $child_filters = $filters; 3548 $child_has_meta_filter = $has_meta_filter; 3549 $sku_mode = self::normalize_sku_mode( $filters['search_sku_mode'] ?? '' ); 3550 if ( 3551 $has_meta_filter 3552 && 'not_contains' !== $sku_mode 3553 && true === $this->sku_term_matches( (string) ( $product['sku'] ?? '' ), (string) ( $filters['search_sku'] ?? '' ), $sku_mode ) 3554 ) { 3555 unset( $child_filters['search_sku'], $child_filters['search_sku_mode'] ); 3556 $child_has_meta_filter = $this->has_variation_meta_filters( $child_filters ); 3557 } 3558 1530 3559 foreach ( $children as $child_id ) { 1531 3560 $variation = wc_get_product( $child_id ); 1532 3561 if ( $variation && $variation->is_type( 'variation' ) ) { 1533 if ( $ has_meta_filter && ! $this->variation_matches_filters( $variation, $filters ) ) {3562 if ( $child_has_meta_filter && ! $this->variation_matches_filters( $variation, $child_filters ) ) { 1534 3563 continue; 1535 3564 } … … 1556 3585 * Returns an approximate flag because the raw COUNT(*) does not replicate 1557 3586 * variation_matches_filters() — variation-level filters (price, stock_qty, 1558 * m anage_stock, backorders, search_description) cause an over-count at1559 * render time. Accepted as a safe upper bound.3587 * menu_order, manage_stock, backorders, search_description, search_sku) cause 3588 * an over-count at render time. Accepted as a safe upper bound. 1560 3589 * 1561 3590 * @param int $total_parents Total parents matching filters (from main query). … … 1581 3610 foreach ( array_chunk( $variable_parent_ids, 1000 ) as $chunk ) { 1582 3611 $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); 1583 // post_status NOT IN ('trash', 'auto-draft') matches what the main1584 // rendering path surfaces: get_children() returns all non-trashed1585 // child IDs, and wc_get_product() returns any non-auto-draft1586 // variation. Earlier version used IN ('publish', 'private'), which1587 // under-counted drafts. See Product_Service::get_variations() for1588 // th e matching path. Parent status filtering is already applied1589 // viathe parent-ID query above; this clause is the independent3612 // post_status IN ('publish', 'private') mirrors the render path: 3613 // WC_Product_Variable::get_children() queries variation children with 3614 // post_status => array( publish, private ) (verified in WC 10.8.1, 3615 // class-wc-product-variable-data-store-cpt.php), so the grid only ever 3616 // renders those. Counting draft/pending here would over-report rows 3617 // that never display. Parent status filtering is already applied via 3618 // the parent-ID query above; this clause is the independent 1590 3619 // VARIATION-status axis in WC's data model. 1591 3620 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a comma-joined string of `%d` placeholders, one per element of $chunk, all expanded by prepare() via spread. … … 1593 3622 "SELECT COUNT(*) FROM {$wpdb->posts} 1594 3623 WHERE post_type = 'product_variation' 1595 AND post_status NOT IN ('trash', 'auto-draft')3624 AND post_status IN ('publish', 'private') 1596 3625 AND post_parent IN ($placeholders)", 1597 3626 ...$chunk … … 1648 3677 return true; 1649 3678 } 3679 if ( isset( $filters['menu_order_min'] ) && '' !== $filters['menu_order_min'] ) { 3680 return true; 3681 } 3682 if ( isset( $filters['menu_order_max'] ) && '' !== $filters['menu_order_max'] ) { 3683 return true; 3684 } 3685 if ( 3686 ( ( isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min'] ) || ( isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max'] ) ) 3687 && Product_Helper::is_cogs_enabled() 3688 ) { 3689 return true; 3690 } 1650 3691 if ( ! empty( $filters['manage_stock'] ) ) { 1651 3692 return true; … … 1654 3695 return true; 1655 3696 } 3697 foreach ( [ 'sale_start_after', 'sale_start_before', 'sale_end_after', 'sale_end_before' ] as $sale_param ) { 3698 if ( isset( $filters[ $sale_param ] ) && '' !== $filters[ $sale_param ] ) { 3699 return true; 3700 } 3701 } 3702 foreach ( [ 'image_date_after', 'image_date_before' ] as $image_param ) { 3703 if ( isset( $filters[ $image_param ] ) && '' !== $filters[ $image_param ] ) { 3704 return true; 3705 } 3706 } 1656 3707 if ( ! empty( $filters['search_description'] ) ) { 1657 3708 return true; 1658 3709 } 3710 // Strict '' test, not empty(): "0" is a valid SKU. 3711 if ( $this->sku_term_is_effective( (string) ( $filters['search_sku'] ?? '' ) ) ) { 3712 return true; 3713 } 1659 3714 // search_short_description is not checked — variations don't have short 1660 3715 // descriptions; the parent-level post_excerpt filter is sufficient. 3716 // product_url is not checked either — only external products carry one, 3717 // and an external product is never variable, so the filter can never 3718 // surface a parent through a child variation. 1661 3719 return false; 3720 } 3721 3722 /** 3723 * Whether a search_sku term would produce an actual SQL clause. False for an 3724 * empty term or a comma-only term with no real tokens — the query builds no 3725 * clause in those cases. Shared so per-variation narrowing and the 3726 * approximate-count flag agree on what counts as an active SKU filter. 3727 * 3728 * Deliberately takes no mode: effectiveness is mode-invariant, because every 3729 * mode (and the legacy auto branch) builds its clause from the same token 3730 * list — see build_sku_search_clause(), which returns '' on an empty list 3731 * regardless of mode. 3732 * 3733 * @param string $term Raw search_sku filter value. 3734 * @return bool 3735 */ 3736 private function sku_term_is_effective( string $term ): bool { 3737 return [] !== self::split_sku_term( sanitize_text_field( $term ) ); 3738 } 3739 3740 /** 3741 * Match a SKU against a search_sku term + mode, mirroring the SQL built by 3742 * build_sku_search_clause() and its sanitization. Returns null when the term 3743 * is not an effective filter, so callers neither surface nor narrow on it — 3744 * callers compare against true/false strictly and rely on that third state. 3745 * mb_* keeps case-folding parity with MySQL's default utf8mb4_*_ci collation 3746 * for multibyte SKUs. 3747 * 3748 * The positive modes match ANY token; not_contains must miss EVERY token, 3749 * and an empty SKU therefore matches it — mirroring the COALESCE in the SQL. 3750 * 3751 * @param string $sku The SKU to test. 3752 * @param string $term Raw search_sku filter value. 3753 * @param mixed $mode Raw search_sku_mode filter value (normalised here). 3754 * @return bool|null 3755 */ 3756 private function sku_term_matches( string $sku, string $term, $mode = '' ): ?bool { 3757 if ( ! $this->sku_term_is_effective( $term ) ) { 3758 return null; 3759 } 3760 3761 $term = sanitize_text_field( $term ); 3762 $mode = self::normalize_sku_mode( $mode ); 3763 $tokens = self::split_sku_term( $term ); 3764 3765 if ( '' === $mode ) { 3766 // Legacy auto: the comma was the (implicit) mode selector. 3767 $mode = ( strpos( $term, ',' ) !== false ) ? 'is_exactly' : 'contains'; 3768 } 3769 3770 $sku_folded = mb_strtolower( $sku ); 3771 3772 if ( 'is_exactly' === $mode ) { 3773 $folded = array_map( 3774 static fn( string $token ): string => mb_strtolower( $token ), 3775 $tokens 3776 ); 3777 return in_array( $sku_folded, $folded, true ); 3778 } 3779 3780 foreach ( $tokens as $token ) { 3781 $token_folded = mb_strtolower( $token ); 3782 3783 switch ( $mode ) { 3784 case 'starts_with': 3785 $hit = 0 === mb_strpos( $sku_folded, $token_folded ); 3786 break; 3787 case 'ends_with': 3788 $token_length = mb_strlen( $token_folded ); 3789 $hit = $token_length <= mb_strlen( $sku_folded ) 3790 && mb_substr( $sku_folded, -$token_length ) === $token_folded; 3791 break; 3792 default: // contains / not_contains. 3793 $hit = false !== mb_strpos( $sku_folded, $token_folded ); 3794 break; 3795 } 3796 3797 if ( 'not_contains' === $mode ) { 3798 // Must miss every token — one hit disqualifies. 3799 if ( $hit ) { 3800 return false; 3801 } 3802 } elseif ( $hit ) { 3803 // Positive modes match any token — one hit is enough. 3804 return true; 3805 } 3806 } 3807 3808 return 'not_contains' === $mode; 1662 3809 } 1663 3810 … … 1708 3855 } 1709 3856 3857 // Cost of Goods range — match on the variation's OWN defined value (null = 3858 // inherits parent, treated as no-match, like stock qty). Gated on 3859 // is_cogs_enabled() so get_cogs_value() never fires when the feature is off. 3860 $has_cogs_min = isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min']; 3861 $has_cogs_max = isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max']; 3862 if ( ( $has_cogs_min || $has_cogs_max ) && Product_Helper::is_cogs_enabled() ) { 3863 $cogs = $variation->get_cogs_value(); 3864 if ( null === $cogs ) { 3865 return false; 3866 } 3867 if ( $has_cogs_min && $cogs < (float) $filters['cogs_min'] ) { 3868 return false; 3869 } 3870 if ( $has_cogs_max && $cogs > (float) $filters['cogs_max'] ) { 3871 return false; 3872 } 3873 } 3874 3875 // Menu order range — the variation's own value (get_menu_order() is always 3876 // an int, default 0; no null-guard needed like stock qty / COGS). 3877 $has_menu_min = isset( $filters['menu_order_min'] ) && '' !== $filters['menu_order_min']; 3878 $has_menu_max = isset( $filters['menu_order_max'] ) && '' !== $filters['menu_order_max']; 3879 if ( $has_menu_min || $has_menu_max ) { 3880 $order = $variation->get_menu_order(); 3881 if ( $has_menu_min && $order < (int) $filters['menu_order_min'] ) { 3882 return false; 3883 } 3884 if ( $has_menu_max && $order > (int) $filters['menu_order_max'] ) { 3885 return false; 3886 } 3887 } 3888 3889 // Sale-schedule date ranges — the variation's OWN schedule (null = no sale 3890 // scheduled, treated as no-match, like stock qty / COGS). Y-m-d strings are 3891 // zero-padded so lexical comparison is chronological, and WC_DateTime::date() 3892 // renders in site time — the same basis as the SQL bounds. 3893 $sale_date_ranges = [ 3894 [ 'get_date_on_sale_from', 'sale_start_after', 'sale_start_before' ], 3895 [ 'get_date_on_sale_to', 'sale_end_after', 'sale_end_before' ], 3896 ]; 3897 foreach ( $sale_date_ranges as list( $getter, $after_key, $before_key ) ) { 3898 $has_after = isset( $filters[ $after_key ] ) && '' !== $filters[ $after_key ]; 3899 $has_before = isset( $filters[ $before_key ] ) && '' !== $filters[ $before_key ]; 3900 if ( ! $has_after && ! $has_before ) { 3901 continue; 3902 } 3903 3904 $sale_date = $variation->{$getter}(); 3905 if ( null === $sale_date ) { 3906 return false; 3907 } 3908 3909 $value = $sale_date->date( 'Y-m-d' ); 3910 if ( $has_after && $value < $filters[ $after_key ] ) { 3911 return false; 3912 } 3913 if ( $has_before && $value > $filters[ $before_key ] ) { 3914 return false; 3915 } 3916 } 3917 3918 // Image-uploaded date range — the variation's OWN featured image ('edit' 3919 // context bypasses the filter that inherits the parent image for imageless 3920 // variations, matching format_variation()). No own image, a stale 3921 // _thumbnail_id, or one pointing at a non-attachment = no match, like stock 3922 // qty / COGS / sale dates. Y-m-d strings are zero-padded so lexical 3923 // comparison is chronological, and the attachment post_date is site-local — 3924 // the same basis as the SQL bounds. 3925 $has_image_after = isset( $filters['image_date_after'] ) && '' !== $filters['image_date_after']; 3926 $has_image_before = isset( $filters['image_date_before'] ) && '' !== $filters['image_date_before']; 3927 if ( $has_image_after || $has_image_before ) { 3928 $image_date = Product_Helper::get_image_upload_date( (int) $variation->get_image_id( 'edit' ) ); 3929 if ( '' === $image_date ) { 3930 return false; 3931 } 3932 3933 $value = substr( $image_date, 0, 10 ); 3934 if ( $has_image_after && $value < $filters['image_date_after'] ) { 3935 return false; 3936 } 3937 if ( $has_image_before && $value > $filters['image_date_before'] ) { 3938 return false; 3939 } 3940 } 3941 1710 3942 // Manage stock. 1711 3943 if ( ! empty( $filters['manage_stock'] ) ) { … … 1737 3969 } 1738 3970 3971 // SKU (_sku meta). A null result means the term is not an effective 3972 // filter (empty / comma-only) — the SQL builds no clause then, so don't 3973 // narrow here either. See sku_term_matches() for per-mode parity. 3974 // 3975 // 'edit' context, deliberately: WC_Product_Variation::get_sku() falls back 3976 // to the PARENT's SKU whenever the variation's own value is empty() — and 3977 // PHP counts the perfectly valid SKU "0" as empty. The SQL matches on the 3978 // variation's own _sku meta, so the mirror must read the own value or it 3979 // narrows on a SKU the query never looked at. 3980 if ( 3981 false === $this->sku_term_matches( 3982 (string) $variation->get_sku( 'edit' ), 3983 (string) ( $filters['search_sku'] ?? '' ), 3984 $filters['search_sku_mode'] ?? '' 3985 ) 3986 ) { 3987 return false; 3988 } 3989 1739 3990 // Short description — variations don't have one (no postmeta equivalent), 1740 3991 // so skip this check; the parent-level post_excerpt filter already ran. -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/View_Service.php
r3549389 r3663491 7 7 8 8 namespace WCABE_G2\Services; 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 9 11 10 12 defined( 'ABSPATH' ) || exit; … … 95 97 $view['filters'] = $this->migrate_filters_shape( $view['filters'] ); 96 98 } 99 // Bound a stored page size on read: a site that has since LOWERED 100 // wcabe_g2_max_per_page must not hand the client a size the products 101 // route would silently reduce. Views saved before page_size existed 102 // are deliberately NOT backfilled — the frontend falls back to its 103 // effective default, which keeps "absent" distinguishable from "100". 104 if ( isset( $view['page_size'] ) ) { 105 $view['page_size'] = Page_Size_Helper::clamp( $view['page_size'] ); 106 } 97 107 } 98 108 unset( $view ); … … 118 128 'filters' => $this->sanitize_filters( $data['filters'] ?? [] ), 119 129 'column_state' => self::sanitize_column_state( $data['column_state'] ?? [] ), 130 'page_size' => Page_Size_Helper::clamp( $data['page_size'] ?? null ), 120 131 'is_default' => ! empty( $data['is_default'] ), 121 132 'created_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), … … 173 184 } 174 185 186 if ( isset( $data['page_size'] ) ) { 187 $views[ $index ]['page_size'] = Page_Size_Helper::clamp( $data['page_size'] ); 188 } 189 175 190 if ( isset( $data['is_default'] ) ) { 176 191 $views[ $index ]['is_default'] = (bool) $data['is_default']; … … 283 298 * Sanitize filters to match the expected FilterValues shape. 284 299 * 300 * Thin instance wrapper: migrate any legacy saved-view bag shape, then delegate 301 * to the public static sanitizer. Stays private/instance-bound only because the 302 * migration call (migrate_filters_shape) is instance-bound. 303 * 285 304 * @param array $filters 286 305 * @return array 287 306 */ 288 307 private function sanitize_filters( array $filters ): array { 308 return self::sanitize_filter_values( $this->migrate_filters_shape( $filters ) ); 309 } 310 311 /** 312 * Sanitize a URL-substring filter value. 313 * 314 * Deliberately NOT sanitize_text_field(): that strips percent-encoded octets 315 * (see wp-includes/formatting.php), so searching for "%20" or "%2F" — both 316 * common in real product URLs — would silently match nothing. Strips control 317 * characters and markup, preserves %XX. SQL safety is handled downstream by 318 * $wpdb->prepare() + esc_like(); this is only about control chars and tags. 319 * 320 * @param mixed $value Raw filter value. 321 * @return string 322 */ 323 public static function sanitize_url_filter_value( $value ): string { 324 if ( ! is_scalar( $value ) ) { 325 return ''; 326 } 327 328 $value = wp_check_invalid_utf8( (string) $value ); 329 $value = wp_strip_all_tags( $value ); 330 // Null bytes and C0/C1 control characters (incl. newlines and tabs). 331 $value = preg_replace( '/[\x00-\x1F\x7F-\x9F]/u', '', $value ); 332 333 return trim( (string) $value ); 334 } 335 336 /** 337 * Sanitize a FilterValues blob (scalar fields + dynamic bags) into the canonical 338 * shape. Public + static so reusable outside view CRUD — the User_Settings 339 * controller calls it for the filterDraft field (mirrors sanitize_column_state). 340 * 341 * No migration here: callers that may receive a legacy bag shape (saved views) 342 * migrate first via the wrapper; live frontend draft data is already current. 343 * The migrate-then-sanitize order is behavior-preserving — migration only renames 344 * the dynamic bags (which the body re-reads), and never touches the scalar fields. 345 * 346 * @param array $filters 347 * @return array 348 */ 349 public static function sanitize_filter_values( array $filters ): array { 289 350 $allowed_operators = [ '=', '>', '<', '>=', '<=', 'expression' ]; 290 351 $id_operator = $filters['id_operator'] ?? '='; … … 293 354 } 294 355 356 $search_name_mode = $filters['search_name_mode'] ?? 'all'; 357 if ( ! in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 358 $search_name_mode = 'all'; 359 } 360 361 // SKU match mode. '' is the legacy "auto" state (comma = exact, single 362 // value = partial), so an unknown mode falls back to it rather than to a 363 // concrete mode. Sanitize the term FIRST — sanitize_text_field trims, so 364 // a whitespace-only term canonicalizes to '' here — and drop the mode 365 // with it: a mode left behind an empty term would make a freshly loaded 366 // view report a phantom "Modified" (same rationale as product URL below). 367 $search_sku = sanitize_text_field( $filters['search_sku'] ?? '' ); 368 $search_sku_mode = $filters['search_sku_mode'] ?? ''; 369 if ( ! in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 370 $search_sku_mode = ''; 371 } 372 if ( '' === $search_sku ) { 373 $search_sku_mode = ''; 374 } 375 376 // Product URL filter. Canonicalized here as well as in the client store, 377 // because a view can also be created/imported straight through the REST 378 // API: a stale value hidden behind an empty-check operator would persist 379 // and — since the dirty-state baseline compares against the STORED view — 380 // make a freshly loaded view report a phantom "Modified". 381 $product_url_operator = $filters['product_url_operator'] ?? 'contains'; 382 if ( ! in_array( $product_url_operator, Product_Service::PRODUCT_URL_OPERATORS, true ) ) { 383 $product_url_operator = 'contains'; 384 } 385 $product_url_value = self::sanitize_url_filter_value( $filters['product_url_value'] ?? '' ); 386 if ( Product_Service::is_product_url_empty_check( $product_url_operator ) ) { 387 $product_url_value = ''; 388 } elseif ( '' === $product_url_value ) { 389 $product_url_operator = 'contains'; 390 } 391 295 392 $sanitized = [ 296 393 // Original fields. 297 394 'search_name' => sanitize_text_field( $filters['search_name'] ?? '' ), 298 'search_sku' => sanitize_text_field( $filters['search_sku'] ?? '' ), 395 'search_name_mode' => $search_name_mode, 396 'search_sku' => $search_sku, 397 'search_sku_mode' => $search_sku_mode, 299 398 'category' => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ), 399 'tag' => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ), 300 400 'type' => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ), 301 401 'status' => array_map( 'sanitize_text_field', (array) ( $filters['status'] ?? [] ) ), 302 402 'stock_status' => array_map( 'sanitize_text_field', (array) ( $filters['stock_status'] ?? [] ) ), 403 // Slugs. The filter callback drops values that sanitize_title reduced to 404 // '' (junk like '!!!'), and array_values re-indexes so the result is a 405 // JSON array rather than an object — a stored [''] would otherwise read 406 // back as an active filter that matches nothing visible. Strict '' 407 // equality, not array_filter()'s default truthiness: sanitize_title() 408 // can legally produce the string '0', which truthiness would drop too. 409 'shipping_class' => array_values( 410 array_filter( 411 array_map( 'sanitize_title', (array) ( $filters['shipping_class'] ?? [] ) ), 412 static fn( string $slug ): bool => '' !== $slug 413 ) 414 ), 303 415 'price_min' => sanitize_text_field( $filters['price_min'] ?? '' ), 304 416 'price_max' => sanitize_text_field( $filters['price_max'] ?? '' ), 417 'cogs_min' => sanitize_text_field( $filters['cogs_min'] ?? '' ), 418 'cogs_max' => sanitize_text_field( $filters['cogs_max'] ?? '' ), 305 419 'stock_qty_min' => sanitize_text_field( $filters['stock_qty_min'] ?? '' ), 306 420 'stock_qty_max' => sanitize_text_field( $filters['stock_qty_max'] ?? '' ), 421 'menu_order_min' => sanitize_text_field( $filters['menu_order_min'] ?? '' ), 422 'menu_order_max' => sanitize_text_field( $filters['menu_order_max'] ?? '' ), 307 423 'manage_stock' => array_map( 'sanitize_text_field', (array) ( $filters['manage_stock'] ?? [] ) ), 308 424 'backorders' => array_map( 'sanitize_text_field', (array) ( $filters['backorders'] ?? [] ) ), … … 315 431 'id_operator' => $id_operator, 316 432 'id_value' => sanitize_text_field( $filters['id_value'] ?? '' ), 433 434 // Product URL filter. NOTE: the value uses sanitize_url_filter_value(), 435 // not sanitize_text_field() — the latter strips percent-encoded octets 436 // (%20, %2F), silently corrupting legitimate URL substring searches. 437 'product_url_operator' => $product_url_operator, 438 'product_url_value' => $product_url_value, 317 439 318 440 // Date filters. … … 321 443 'date_modified_from' => sanitize_text_field( $filters['date_modified_from'] ?? '' ), 322 444 'date_modified_to' => sanitize_text_field( $filters['date_modified_to'] ?? '' ), 445 'sale_start_from' => sanitize_text_field( $filters['sale_start_from'] ?? '' ), 446 'sale_start_to' => sanitize_text_field( $filters['sale_start_to'] ?? '' ), 447 'sale_end_from' => sanitize_text_field( $filters['sale_end_from'] ?? '' ), 448 'sale_end_to' => sanitize_text_field( $filters['sale_end_to'] ?? '' ), 449 'image_date_from' => sanitize_text_field( $filters['image_date_from'] ?? '' ), 450 'image_date_to' => sanitize_text_field( $filters['image_date_to'] ?? '' ), 323 451 324 452 // Dynamic filter bags — sanitized below. … … 326 454 'extension_filters' => [], 327 455 ]; 328 329 // Normalize any pre-Phase-B saved-view shape (old acf_filters /330 // custom_meta_filters / taxonomy_filters keys) into extension_filters331 // before the dynamic-bag sanitization below reads it.332 $filters = $this->migrate_filters_shape( $filters );333 456 334 457 // Attribute filters: Record<string, number[]> — Free feature. -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/readme.txt
r3549389 r3663491 3 3 Tags: bulk edit, product editor, product bulk edit, woocommerce products, inventory 4 4 Requires at least: 6.0 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1. 0.07 Stable tag: 1.2.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 31 31 * **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style 32 32 * **Bulk operations** — apply percentage price adjustments, find-and-replace, text append and prepend, and bulk category and tag assignment across many products at once 33 * **Saved views** — save filter sets and column layouts as named views for one-click recall 34 * **Filters** — filter by category, product type, stock status, name, SKU (CSV SKU lists supported), description, date created or modified, and more 33 * **Wide field coverage** — prices, stock, SKUs, statuses, categories, tags, shipping class, menu order, upsells and cross-sells, product URL and button text for external products, and more 34 * **Saved views** — save filter sets and column layouts as named views for one-click recall, and share them between sites with view import/export 35 * **Filters** — filter by category, tags, product type, stock status, name and SKU (with exact/contains match modes and CSV SKU lists), description, dates created, modified, on sale, or image uploaded, and more 35 36 * **Column management** — show, hide, reorder, resize, and pin columns; each user's layout is saved separately 36 37 * **Keyboard shortcuts** — navigate and edit without touching the mouse 37 38 * **Product operations** — duplicate and change status right from the grid 38 39 * **Image and gallery management** — set featured images and manage product galleries directly from the grid 39 * **CSV export** — export the current filtered view to CSV in one click 40 * **CSV export** — export the current filtered view to CSV in one click, including large catalogs 40 41 * **Dark mode** — switch between light and dark themes 41 * **Fast loading** — virtualized rendering handles thousands of products without the browser choking 42 * **Fast loading** — virtualized rendering handles thousands of products without the browser choking, with a configurable page size 43 * **Plays well with others** — products from third-party type plugins (such as bundles or subscriptions) appear in the grid with their common fields editable, and an optional setting re-fires WordPress save hooks so sync and cache plugins stay in step 42 44 43 45 = Built on proper WooCommerce APIs = … … 51 53 = Pro version = 52 54 53 If you need more — regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/).55 If you need more — paste from Excel/Google Sheets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/). 54 56 55 57 The free version is genuinely useful on its own. Pro is for stores that need more advanced bulk-editing workflows or fields the free version doesn’t touch. … … 95 97 = Can I undo changes? = 96 98 97 Edits are staged as pending changes in the grid before you save, so you can undo them or discard them entirely while they're unsaved. Once saved, changes go to the database and cannot be undone by the plugin itself — keep a backup, as you would with any bulk operation. (A proper edit history with rollback is planned for the Pro version.)99 Edits are staged as pending changes in the grid before you save, so you can undo them or discard them entirely while they're unsaved. Once saved, the free version can't undo them — keep a backup, as you would with any bulk operation. The Pro version adds Operation History, which records each save so you can review and restore previous field values (see below). 98 100 99 101 = Is there a Pro version? = 100 102 101 Yes. Pro adds regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, fine-grained selection tools for the current grid page, and more. It's available at [wpmelon.com](https://wpmelon.com/bulk-edit/). The free version is a complete, standalone tool — Pro extends it, it doesn't gate it. 103 Yes. Pro adds paste from Excel/Google Sheets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more. It's available at [wpmelon.com](https://wpmelon.com/bulk-edit/). The free version is a complete, standalone tool — Pro extends it, it doesn't gate it. 104 105 = What does Operation History store? (Pro) = 106 107 Operation History is a Pro feature, currently in beta and off by default. When you turn it on, each bulk save is recorded locally so you can review it later and restore previous field values. Each record stores the before and after values of the fields that save changed, the product and variation IDs involved, the user who made the change, and timestamps. The data lives in custom database tables on your own site and is never sent off-site. Records are kept for 30 days by default (configurable) and pruned automatically. You can turn recording on or off, clear the history at any time, and optionally have all history data removed when you uninstall Pro. The free version records nothing and creates no history tables. 102 108 103 109 = Does it support custom post types? = … … 128 134 == Changelog == 129 135 136 = 1.2.0 = 137 * Added Cost of Goods (COGS) as an editable product column when WooCommerce's Cost of Goods Sold feature is enabled. Edit it inline or with the bulk-edit panel, filter products by a Cost of Goods range, and include it in CSV exports. 138 * Added a Published on column — show it in the grid, edit its full date and time inline, and bulk-edit it — plus a read-only Modified on column. Both are available in CSV export. 139 * Products with a future Published on date now appear in the grid as "Scheduled" (and can be filtered by that status) instead of being hidden from the default view. 140 * The Title filter now matches all words by default, with a choice of how multi-word searches behave. When you type two or more words, a mode dropdown appears: "All words" (the default) finds titles containing every word in any order, "Any word" matches titles containing at least one word, and "Exact phrase" matches the words exactly as typed. 141 * You can now include product attribute columns (such as Color or Size) in the CSV export. Open the Export dialog and tick the attribute columns you want — each cell lists the product's terms separated by " | ". 142 * Your column layout and filters are now remembered automatically across page reloads and navigation — no need to save a view first. If you have a default view, it's still applied on load; otherwise your last working layout is restored. 143 * Added a Default view at the top of the Views menu that resets your columns and filters to their defaults in one click. The old per-view "Set as default" star is retired — your working layout now persists on its own, and any existing default view is still applied on load. 144 * Switching views (or to Default) with unsaved column or filter changes now asks whether to Save, Disregard, or Cancel, so a modified layout isn't lost by accident. On the Default view the prompt lets you name and save the changes as a new view before switching. 145 * The Views button now shows "· Modified" when your columns or filters differ from the selected view (or the Default). Use Update to save the changes back to the view, or Revert to discard them; on the Default view, "Save as new view" lets you keep them as a brand-new view. 146 * Added Menu order as a product column. View it in the grid, edit it inline or with the bulk-edit panel, filter products by a menu order range, sort the whole catalog by menu order, and include it in CSV exports. 147 * Added Upsells and Cross-sells as editable product columns. Pick linked products from a searchable modal (by title, SKU, GTIN, or product ID), reorder them — WooCommerce shows them in the order you set — and bulk-edit them across a selection with Replace all, Add, or Remove. Both columns are available in CSV export. Links pointing at deleted products are shown as a warning rather than silently discarded, so you decide when to remove them, and a link to a draft or disabled product is flagged so you know it won't appear on your storefront. Cross-sells are editable on simple and variable products only, matching WooCommerce, which hides the field on grouped and external products because cross-sells are promoted in the cart. 148 * More products-per-page choices (250, 500 and 1000), plus a "Custom…" option for any page size up to 10,000. Your page size is now saved as part of your views, so it comes back with a saved view and survives a reload. 149 * Added Sale From and Sale To date-range filters. Variable products are included when one of their variations matches the sale schedule you filtered for, and only the matching variations are shown. 150 * Added a Tags filter. Pick one or more product tags to narrow the grid to products carrying any of them; it combines with all other filters and applies to CSV exports too. 151 * Added the Button text field for External/Affiliate products. View and edit the add-to-cart button label inline, change it with the bulk-edit panel (set, prepend, append, find & replace, clear), and include it in CSV exports. 152 * Added an "Image uploaded" filter and grid/CSV column showing when each product's featured image was uploaded. Filter by a date range to find products whose images changed since a given date — useful when syncing images between sites. Variations are matched on their own images, and the column is available in the column picker and CSV exports. 153 * Export any saved grid view as a JSON file or copyable text, and import a view exported from another account or site. 154 * Added support for third-party product types. WooCommerce Product Bundles products now appear in the grid with full inline and bulk editing (prices, stock, categories, images, and more), and other plugin-registered types (subscriptions, composites, etc.) show up with a safe set of editable fields. The Type filter and the grid's type badges show every registered product type under its own plugin-provided name. Variable-style third-party products (such as variable subscriptions) expand to show their variations, just like native variable products. 155 * Added a "Refresh list after saving" setting. Turn it off to keep edited products visible in the grid after saving — even when they no longer match the active filters — until you reload the list. 156 * Added a Product URL column for External/Affiliate products — show it in the grid, edit it inline and in bulk (including find & replace for affiliate domain migrations), and export it to CSV. A new Product URL filter finds products by their link, with contains, does not contain, starts with, ends with, is empty and is not empty modes. 157 * The SKU filter now has a match-mode dropdown: Contains, Is exactly, Does not contain, Starts with, and Ends with. Comma-separate several SKUs to match any of them under the chosen mode (or none of them, for "Does not contain"), up to 200 values. The dropdown starts on Auto, which keeps the original behavior — a single SKU is a partial match, comma-separated SKUs are exact matches — so existing saved views and searches are unchanged. Also fixed: searching for the SKU "0" now filters products correctly instead of being ignored. 158 * Added a compatibility setting that triggers the standard WordPress save hooks after each save, so sync and integration plugins detect changes made in the editor. Find it under "Notify other plugins of changes" in Settings — it is off by default, and the info button beside it lists exactly which hooks are fired. 159 * Added Shipping class as a product column. View it in the grid, edit it inline or with the bulk-edit panel — including a "Same as parent" option on variations — filter products by shipping class, and include it in CSV exports. 160 * Renamed the Created product filter to Published on to align with WordPress/WooCommerce publishing terminology, and renamed Modified to Modified on for consistency. 161 * The Published on, Modified on, Sale from, and Sale to filters now appear in the filter panel only when their columns are shown in the grid, like the other filters. A date filter that has a value set stays visible even when its column is hidden. 162 * Updated the Pro upgrade dialog with the latest Pro features (undo & rollback, paste from Excel, WPML support) and current early-bird pricing for annual and lifetime licenses. 163 * Fixed searching by a variation's SKU returning no results. When variations are shown in the grid, a SKU search now matches variation SKUs and displays the matching variation under its parent. Searching a product's own SKU keeps all of its variations visible. 164 * Fixed bulk-edit searches and saves failing after the page was left open for a long time. The page now refreshes its security token automatically, and shows a clear "session expired or permissions changed — reload" message in the rare case it can't recover. 165 * Fixed several Default-view and layout issues: selecting Default now restores columns you had hidden back to their factory visibility; deleting the view you're currently on returns you to Default and flags the layout as modified when it differs, instead of leaving a stale layout mislabeled as clean; and switching views no longer shows columns from your previous layout when "auto-load products" is turned off. 166 * Fixed the Views "· Modified" indicator disappearing when you chose "Update with current state" on a view other than the one currently selected. 167 * Fixed the Views "· Modified" indicator misbehaving: reverting a column change now clears it (previously it could stay stuck on), and re-selecting the same categories or filters in a different order no longer flags the layout as modified. 168 * Fixed the selected view being lost after a page reload — the active view is now correctly remembered instead of quietly reverting to Default on the next load. 169 * Fixed saving a new view before the product grid has loaded (with auto-load off) storing an empty column layout — a new view now captures your current columns in that case. 170 * Fixed column layout changes being silently discarded when updating a view while product auto-loading is turned off — the view now saves your columns, and the layout no longer keeps showing as modified afterward. 171 * Cancelling a bulk save (or restore) while a batch was mid-request no longer reports that in-progress batch as a failed save. For saves, the items in it are now shown as not attempted. 172 * Deleting the plugin now removes its settings and per-user interface preferences from the database, instead of leaving them behind. 173 * Clicking Stop during a save now cancels the in-progress request immediately, instead of only skipping the batches that hadn't started yet. Rows that still finished saving on the server are reconciled afterward, so they no longer linger as unsaved. 174 * Pressing Stop during a save no longer hides a real login or permission failure. If your session expired or your permissions changed mid-save, you now get the reload prompt instead of a misleading "save stopped" message, so you know the remaining products weren't saved and why. 175 * Save-stopped notifications are now translatable. The messages shown when you stop a save part-way through — including the counts of what was saved, what's still pending, and any per-product errors — now appear in your site's language. 176 * When a save (or a Pro Operation History restore) is rejected because you are no longer authenticated and the site replies with an HTTP 401 error (e.g. Application Passwords or an authentication plugin), the session-expired warning now appears — including when Stop was pressed mid-operation — instead of the failure looking like a cancelled run. Rejected save/restore requests also now show the actual server error message instead of the generic "Unknown server error" text, which is now translatable. 177 * Turning off "Manage stock" now discards any staged Stock quantity / Backorders edits for that product no matter how the change is made — inline edit, fill handle, bulk edit, or paste — so stale stock values can no longer be saved once stock management is switched off. 178 * Fixed an error thrown when clicking "Clear all filters" on the "No products match your filters" screen. 179 * Switching to another app or browser tab while editing a cell no longer closes the editor. When you come back, the cell is still in edit mode with the cursor where you left it — you can keep typing, or paste a copied value straight into the cell. 180 * Enter-to-edit-the-next-row and the variation Select/Deselect buttons no longer stop working after a load error, an empty filter result, or when auto-loading is disabled. 181 * Fixed a Cost of Goods filter issue where a product that had been converted from variable to simple could wrongly appear in the filtered results when a leftover hidden variation matched the filter. 182 * Fixed the Menu order filter, SKU search, and Description search wrongly including a product that had been converted from variable to simple when a leftover hidden variation matched the filter. 183 * Fixed date picker calendar popups following the operating system's light/dark theme instead of the plugin's selected theme. 184 * Fixed toggling "Include Variations" reverting the grid's column layout (hidden columns, widths, pinning) to an older state and incorrectly marking the loaded view as "Modified". Also fixed Settings → "Reset layout" not restoring the default column visibility (previously hidden columns could stay hidden after a reset). 185 * Fixed CSV exports failing on large product sets with many columns selected. The file is now built page by page and caches are released as it goes, so the export uses far less server memory. When an export does fail, the error now names the cause and status (for example "Export failed (500): …") instead of a generic "Export failed". 186 187 = 1.0.1 = 188 * Added a Bulk Edit shortcut to the WordPress admin bar for quick access to the editor from anywhere in wp-admin. It can be turned off per user from the Settings menu and is on by default. 189 * Fixed tall popovers overflowing past the bottom of the screen on short browser windows; they now fit within the visible area and scroll internally. 190 * Fixed the bulk edit page not scrolling on short browser windows, which left the toolbar, pagination, and lower admin menu items clipped and unreachable. 191 * Fixed form fields rendering with light backgrounds in dark mode inside popover panels (filters, Selection Manager, dropdowns), and dark-mode styling for date and time inputs — native calendar/clock icons and picker popups now follow the dark theme too. 192 * Fixed the horizontal scrollbar floating in the middle of the grid instead of staying at the bottom after collapsing or expanding the Filters or Bulk Edit panel. 193 130 194 = 1.0.0 = 131 195 * Initial release. … … 133 197 == Upgrade Notice == 134 198 199 = 1.2.0 = 200 Major feature release: upsells/cross-sells, shipping class, product URL and button text editing, tags and sale-date filters, view import/export, SKU match modes, third-party product types, and many fixes. 201 135 202 = 1.0.0 = 136 203 Initial release. -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/wpmelon-advanced-bulk-edit-for-woocommerce.php
r3549389 r3663491 4 4 * Plugin URI: https://wpmelon.com/bulk-edit/ 5 5 * Description: Excel-like bulk editing interface for WooCommerce products 6 * Version: 1. 0.06 * Version: 1.2.0 7 7 * Author: WPMelon 8 8 * Author URI: https://wpmelon.com/ … … 10 10 * Domain Path: /languages 11 11 * Requires at least: 6.0 12 * Tested up to: 7. 012 * Tested up to: 7.1 13 13 * Requires PHP: 8.0 14 14 * Requires Plugins: woocommerce 15 15 * WC requires at least: 10.0 16 * WC tested up to: 1 0.716 * WC tested up to: 11.0.1 17 17 * License: GPLv2 or later 18 18 * License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 126 126 define( 'WCABE_G2_LOADED', true ); 127 127 128 define( 'WCABE_G2_VERSION', '1. 0.0' );128 define( 'WCABE_G2_VERSION', '1.2.0' ); 129 129 define( 'WCABE_G2_PLUGIN_FILE', __FILE__ ); 130 130 define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/assets/dist/.vite/manifest.json
r3549389 r3663491 5 5 }, 6 6 "assets/src/main.tsx": { 7 "file": "assets/main- CPlqEen8.js",7 "file": "assets/main-V9rY9XMr.js", 8 8 "name": "main", 9 9 "src": "assets/src/main.tsx", 10 10 "isEntry": true, 11 11 "css": [ 12 "assets/main-D uaYQxSj.css"12 "assets/main-D5Lgb2qa.css" 13 13 ], 14 14 "assets": [ -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Admin.php
r3549389 r3663491 20 20 'wcabe-g2', 21 21 [ $this, 'render_page' ] 22 ); 23 } 24 25 /** 26 * Add a shortcut to the Bulk Edit page in the WordPress admin bar. 27 * 28 * Gated by the per-user `showAdminBarShortcut` setting (default on) and the 29 * `manage_woocommerce` capability. Fires in both admin and front-end contexts 30 * wherever the admin bar renders for a capable user. 31 * 32 * @param \WP_Admin_Bar $admin_bar The admin bar instance. 33 */ 34 public function add_admin_bar_link( \WP_Admin_Bar $admin_bar ): void { 35 if ( ! current_user_can( 'manage_woocommerce' ) ) { 36 return; 37 } 38 39 $settings = REST\User_Settings_Controller::get_all(); 40 if ( empty( $settings['showAdminBarShortcut'] ) ) { 41 return; 42 } 43 44 $admin_bar->add_node( 45 [ 46 'id' => 'wcabe-g2-shortcut', 47 'title' => '<span class="ab-icon dashicons dashicons-edit" aria-hidden="true"></span><span class="ab-label">' 48 . esc_html__( 'Bulk Edit Products', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 49 . '</span>', 50 'href' => admin_url( 'admin.php?page=wcabe-g2' ), 51 'meta' => [ 52 'title' => __( 'WPMelon Advanced Bulk Edit for WooCommerce', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 53 ], 54 ] 22 55 ); 23 56 } … … 51 84 'dimensionUnit' => get_option( 'woocommerce_dimension_unit', 'cm' ), 52 85 'isPro' => defined( 'WCABE_G2_PRO' ) && WCABE_G2_PRO, 86 'cogsEnabled' => Helpers\Product_Helper::is_cogs_enabled(), 87 // URL protocols the server will accept for the Product URL field. 88 // Localized rather than hardcoded client-side because 89 // wp_allowed_protocols() is filterable — a site that adds a scheme must 90 // not have the client reject it before the request reaches the 91 // authoritative server-side check. 92 'allowedProtocols' => array_values( wp_allowed_protocols() ), 53 93 'operationsLimit' => REST\Operations_Controller::OPERATIONS_LIMIT, 94 // Server-authoritative page-size ceiling. The client clamps every page 95 // size (picker, custom input, view apply, dirty baselines) to this, so a 96 // site that LOWERS wcabe_g2_max_per_page never shows a size the products 97 // route would silently reduce. 98 'maxPageSize' => Helpers\Page_Size_Helper::get_max(), 99 // Variable-capable type slugs ('variable' + registered WC_Product_Variable 100 // subclasses, e.g. 'variable-subscription') so the frontend's expand / 101 // "Varies" / variation gates match the backend's (#137). 102 'variableProductTypes' => Services\Product_Service::get_variable_product_types(), 54 103 'attributesSchema' => Services\Attribute_Service::get_attribute_schema(), 104 // Shipping classes as [ slug, name ]. Localized rather than fetched 105 // because the grid cell editor, the bulk-edit field and the paste 106 // resolver all need the vocabulary synchronously, before any request 107 // resolves. The filter panel's copy arrives via GET /filters. 108 'shippingClasses' => Helpers\Product_Helper::get_shipping_class_terms(), 55 109 'pluginSettings' => REST\Settings_Controller::get_all(), 56 110 'userSettings' => REST\User_Settings_Controller::get_all(), 111 // Sync cursor (transport, not a setting) so the client seeds its _rev counter 112 // above the last persisted value — see get_stored_rev()/store/ui.ts (review #4). 113 'userSettingsRev' => REST\User_Settings_Controller::get_stored_rev(), 57 114 ]; 58 115 … … 111 168 'wcabe-g2-admin', 112 169 $dist_url . $entry['file'], 113 [ 'wp-element', 'wp-i18n' ],170 [ 'wp-element', 'wp-i18n', 'wp-api-fetch' ], 114 171 WCABE_G2_VERSION, 115 172 true -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Handler.php
r3549389 r3663491 46 46 47 47 /** 48 * Ensure term maps are populated. Lazy fallback for contexts 49 * where before_format_products doesn't fire (batch_update, get_variations). 48 * Ensure term maps are populated. Lazy fallback for contexts where 49 * before_format_products doesn't fire (get_variations). 50 * 51 * batch_update() DOES fire it now, from its phase-2 formatting block — do not "clean up" 52 * that do_action on the assumption this fallback covers it. 50 53 */ 51 54 private function ensure_term_maps(): void { -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Save_Handler.php
r3549389 r3663491 100 100 */ 101 101 public function validate_attribute_fields( array $errors, \WC_Product $product, array $changes ): array { 102 // Parent products only. Variation attribute selections are owned by Pro's 103 // variation handler (#131); guard here so this parent handler never processes 104 // a variation even once Pro adds attr_* to the variation allowlist (the 105 // handler builds WC_Product_Attribute objects, which is wrong for variations). 106 if ( $product instanceof \WC_Product_Variation ) { 107 return $errors; 108 } 109 102 110 $lookup = self::get_field_lookup(); 103 111 … … 164 172 */ 165 173 public function save_attribute_fields( \WC_Product $product, array $changes, array $allowed_fields ): void { 174 // Parent products only — see validate_attribute_fields(). Pro's variation 175 // handler persists variation attribute selections (#131). 176 if ( $product instanceof \WC_Product_Variation ) { 177 return; 178 } 179 166 180 $this->new_terms_created = false; 167 181 $lookup = self::get_field_lookup(); -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Helpers/Product_Helper.php
r3549389 r3663491 13 13 14 14 /** 15 * Request-level cache of the shipping class term list. 16 * 17 * @var array|null 18 */ 19 private static $shipping_classes_cache = null; 20 21 /** 22 * Every shipping class on the site, as [ 'slug' => ..., 'name' => ... ]. 23 * 24 * Sorted by name, empty classes included — this is a picker list, not a 25 * report. No 'count': WooCommerce registers product_shipping_class for 26 * variations as well as products, so a term whose only users are variations 27 * carries count > 0 while the (deliberately parent-level) shipping class 28 * filter returns nothing for it. Shipping a number that contradicts the 29 * filter is worse than shipping no number. 30 * 31 * @return array<int, array{slug: string, name: string}> 32 */ 33 public static function get_shipping_class_terms(): array { 34 self::register_shipping_class_hooks(); 35 36 if ( null !== self::$shipping_classes_cache ) { 37 return self::$shipping_classes_cache; 38 } 39 40 $terms = get_terms( [ 41 'taxonomy' => 'product_shipping_class', 42 'hide_empty' => false, 43 'orderby' => 'name', 44 ] ); 45 46 if ( is_wp_error( $terms ) || empty( $terms ) ) { 47 self::$shipping_classes_cache = []; 48 return []; 49 } 50 51 $result = []; 52 foreach ( $terms as $term ) { 53 $result[] = [ 54 'slug' => $term->slug, 55 'name' => wp_specialchars_decode( $term->name ), 56 ]; 57 } 58 59 self::$shipping_classes_cache = $result; 60 return $result; 61 } 62 63 /** 64 * A product's OWN shipping class slug, or '' when it has none. 65 * 66 * 'edit' context is load-bearing, and get_shipping_class() must never be 67 * substituted here. That getter resolves through get_shipping_class_id() in 68 * default (view) context, and WC_Product_Variation::get_shipping_class_id() 69 * falls back to the PARENT's class in view context. A variation that 70 * inherits would then display the parent's class as though it were its own, 71 * the user would "change" a value that was never set, and the diff engine 72 * would compare against a value the variation does not store. '' here means 73 * exactly "this row stores no class of its own" — rendered as "Same as 74 * parent" on variations and "No shipping class" on parents. 75 * 76 * @param \WC_Product $product 77 * @return string Term slug, or '' when unset (or the term has been deleted). 78 */ 79 public static function get_shipping_class_slug( \WC_Product $product ): string { 80 $term_id = (int) $product->get_shipping_class_id( 'edit' ); 81 if ( $term_id <= 0 ) { 82 return ''; 83 } 84 85 $term = get_term( $term_id, 'product_shipping_class' ); 86 if ( ! $term || is_wp_error( $term ) ) { 87 return ''; 88 } 89 90 return (string) $term->slug; 91 } 92 93 /** 94 * Clear the request-level shipping class cache. 95 * 96 * Also the callback registered on the term CRUD hooks below, so the cache 97 * self-invalidates whenever a shipping class is created, renamed, or 98 * deleted — a request that lists classes, creates one, then lists again 99 * (or a test suite that creates a term as a fixture) sees it immediately, 100 * with no manual clear to remember. Still exposed publicly for tests that 101 * insert terms directly via wp_insert_term() outside any of those hooks' 102 * normal request lifecycle (e.g. across PHPUnit test classes sharing one 103 * process, where fixtures roll back but this static would otherwise not). 104 */ 105 public static function clear_shipping_class_cache(): void { 106 self::$shipping_classes_cache = null; 107 } 108 109 /** 110 * Wire up cache invalidation on shipping-class term CRUD. 111 * 112 * Called unconditionally on every get_shipping_class_terms() rather than 113 * behind an "already registered" flag: WP_UnitTestCase snapshots $wp_filter 114 * before the first test and restores that snapshot after EVERY test's 115 * tearDown, silently stripping any add_action() call made mid-test. A 116 * once-only static flag would go stale the moment that happens — it would 117 * report "registered" while the underlying hook was actually gone. Calling 118 * add_action() every time is cheap and safe: WP_Hook computes the same 119 * identity string for this array callback on every call, so re-adding it 120 * overwrites the same slot rather than creating a duplicate. 121 */ 122 private static function register_shipping_class_hooks(): void { 123 add_action( 'create_product_shipping_class', [ self::class, 'clear_shipping_class_cache' ] ); 124 add_action( 'edited_product_shipping_class', [ self::class, 'clear_shipping_class_cache' ] ); 125 add_action( 'delete_product_shipping_class', [ self::class, 'clear_shipping_class_cache' ] ); 126 } 127 128 /** 129 * Whether WooCommerce's Cost of Goods Sold feature is enabled. 130 * 131 * Intentionally NOT memoized: the underlying option already lives in WP's 132 * alloptions cache, so repeated calls are cheap, and a static cache would 133 * go stale when the option is toggled within a single request (e.g. tests). 134 * Callers must gate WC's get_cogs_value()/set_cogs_value() on this — those 135 * methods emit wc_doing_it_wrong() when the feature is disabled. 136 * 137 * @return bool 138 */ 139 public static function is_cogs_enabled(): bool { 140 return class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) 141 && \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' ); 142 } 143 144 /** 145 * Upload date of an image attachment, or '' when there is no usable image. 146 * 147 * The single source of truth for the "Image uploaded" value: the grid/CSV 148 * cell, the per-variation filter predicate, and the SQL filter must all agree 149 * on which _thumbnail_id values count. A stale ID (deleted attachment) or one 150 * pointing at a non-attachment post yields '' here, mirroring the query's 151 * post_type = 'attachment' join gate. 152 * 153 * @param int $image_id Attachment ID from _thumbnail_id. 154 * @return string 'Y-m-d H:i:s' in site time, or '' when unusable. 155 */ 156 public static function get_image_upload_date( int $image_id ): string { 157 if ( $image_id <= 0 || 'attachment' !== get_post_type( $image_id ) ) { 158 return ''; 159 } 160 161 // 'raw' context — the SQL filter compares the raw post_date column, and 162 // display context would run the post_date filter, breaking that parity. 163 return (string) get_post_field( 'post_date', $image_id, 'raw' ); 164 } 165 166 /** 15 167 * Format a WC_Product into a flat array for the grid. 16 168 * 17 169 * @param \WC_Product $product 170 * @param array|null $ref_map Pre-resolved upsell/cross-sell descriptors from 171 * build_product_ref_map(). Every multi-item caller MUST 172 * pass one — omitting it resolves this product's refs on 173 * its own, which inside a loop is an N+1. Null is for the 174 * genuine singleton (Operations_Service::create_product). 18 175 * @return array 19 176 */ 20 public static function format_product( \WC_Product $product ): array {177 public static function format_product( \WC_Product $product, ?array $ref_map = null ): array { 21 178 $image_id = $product->get_image_id(); 22 179 $thumbnail_url = $image_id … … 24 181 : ''; 25 182 183 // instanceof, not is_type('variable'): third-party variable subclasses 184 // (variable-subscription, …) carry countable variations too (#137). The 185 // count doubles as the frontend's expand/interleave signal. 26 186 $variation_count = 0; 27 if ( $product ->is_type( 'variable' )) {187 if ( $product instanceof \WC_Product_Variable ) { 28 188 $variation_count = count( $product->get_children() ); 29 189 } … … 37 197 $tags = self::resolve_term_names( $product->get_tag_ids(), 'product_tag' ); 38 198 39 // Sale dates — not on variable parents .199 // Sale dates — not on variable parents (incl. variable subclasses). 40 200 $date_on_sale_from = ''; 41 201 $date_on_sale_to = ''; 42 if ( ! $product ->is_type( 'variable' )) {202 if ( ! $product instanceof \WC_Product_Variable ) { 43 203 $from = $product->get_date_on_sale_from(); 44 204 $to = $product->get_date_on_sale_to(); … … 46 206 $date_on_sale_to = $to ? $to->date( 'Y-m-d' ) : ''; 47 207 } 208 209 // Publish date (post_date) and last-modified (post_modified). Present on 210 // every product post regardless of type. Full date-time (Y-m-d H:i:s) in 211 // site timezone, matching WooCommerce's native date_created/date_modified 212 // (a WC_DateTime backed by the post_date DATETIME column). 213 $created = $product->get_date_created(); 214 $modified = $product->get_date_modified(); 48 215 49 216 $gallery_ids = $product->get_gallery_image_ids(); … … 54 221 $gallery_urls[] = $url ?: ''; 55 222 } 223 } 224 225 // Linked products. 'edit' context — the view getters run through 226 // woocommerce_product_get_upsell_ids, and the grid writes this value straight 227 // back, so a third-party projection would overwrite the stored list. 228 $upsell_ids = array_map( 'intval', $product->get_upsell_ids( 'edit' ) ); 229 $cross_sell_ids = array_map( 'intval', $product->get_cross_sell_ids( 'edit' ) ); 230 231 if ( null === $ref_map ) { 232 $ref_map = self::resolve_product_ref_ids( array_merge( $upsell_ids, $cross_sell_ids ) ); 56 233 } 57 234 … … 77 254 'categories' => $categories, 78 255 'tags' => $tags, 256 // Linked products (WooCommerce upsells / cross-sells). Ordered — WC renders 257 // them in stored order — so never rebuild these from a set. Parent-only: 258 // deliberately omitted from format_variation(). 259 'upsell_ids' => self::project_product_refs( $upsell_ids, $ref_map ), 260 'cross_sell_ids' => self::project_product_refs( $cross_sell_ids, $ref_map ), 79 261 'description' => $product->get_description(), 80 262 'short_description' => $product->get_short_description(), … … 89 271 'downloadable' => $product->get_downloadable(), 90 272 'backorders' => $product->get_backorders(), 273 // Cost of Goods (WooCommerce native COGS). Getter only when the 274 // feature is on — it emits wc_doing_it_wrong() otherwise. Key is 275 // always present (null when off) so the TS type stays non-optional. 276 'cogs_value' => self::is_cogs_enabled() ? $product->get_cogs_value() : null, 277 // Publish date (post_date) and last-modified (post_modified), full date-time 278 // (Y-m-d H:i:s, site timezone). Editable "Published on" + read-only 279 // "Modified on". Parent products only — deliberately omitted from 280 // format_variation() (blank on variation rows). 281 'date_created' => $created ? $created->date( 'Y-m-d H:i:s' ) : '', 282 'date_modified' => $modified ? $modified->date( 'Y-m-d H:i:s' ) : '', 283 // Featured image upload date (the attachment's post_date). Read-only 284 // display + export + the "Image uploaded" filter. '' when the product 285 // has no image, or _thumbnail_id is stale / not an attachment. 286 // 'edit' context deliberately, unlike $image_id above: the SQL filter 287 // reads raw _thumbnail_id, so a plugin hooking 288 // woocommerce_product_get_image_id (placeholder images, multilingual 289 // media mapping) must not make this date diverge from what the filter 290 // matches. thumbnail_url stays view-context — it renders whatever WC 291 // would display. 292 'image_date' => self::get_image_upload_date( (int) $product->get_image_id( 'edit' ) ), 293 // Menu order (native wp_posts.menu_order). Signed integer, default 0. 294 'menu_order' => (int) $product->get_menu_order(), 295 // Button text (External/Affiliate products' add-to-cart button label). 296 // The getter only exists on WC_Product_External; every other type 297 // emits '' so the key is always present (VariesRenderer shows a dash). 298 'button_text' => $product instanceof \WC_Product_External ? $product->get_button_text() : '', 299 // Product URL (External/Affiliate products' destination link). Like 300 // button_text, the getter only exists on WC_Product_External; every 301 // other type emits '' so the key is always present. 302 // 'edit' context deliberately: view context runs third-party 303 // woocommerce_product_get_product_url filters, and the SQL filter 304 // matches the raw stored _product_url meta — a filtered display value 305 // would make the editable grid value diverge from what the filter 306 // finds. (WC's getter wraps esc_url_raw() in both contexts.) 307 'product_url' => $product instanceof \WC_Product_External ? $product->get_product_url( 'edit' ) : '', 308 // Shipping class slug ('' = none). Slug rather than term ID because 309 // WooCommerce's own shipping_class query var matches on slug, so the 310 // grid, the filter, the save payload and the CSV all speak one format. 311 'shipping_class' => self::get_shipping_class_slug( $product ), 91 312 ]; 92 313 … … 96 317 /** 97 318 * Resolve term IDs to [ id, name ] arrays via a single get_terms query. 319 * 320 * Public so Pro's history reader can reuse the same resolution (including 321 * the empty-include and hide_empty=false guards) for its term-name display 322 * metadata. 98 323 * 99 324 * @param int[] $term_ids … … 101 326 * @return array Array of [ 'id' => int, 'name' => string ]. 102 327 */ 103 p rivatestatic function resolve_term_names( array $term_ids, string $taxonomy ): array {328 public static function resolve_term_names( array $term_ids, string $taxonomy ): array { 104 329 if ( empty( $term_ids ) ) { 105 330 return []; … … 126 351 127 352 /** 353 * Human-readable display name for a variation ("Parent – Blue, Large"). 354 * 355 * WC stores just the parent title in post_title for variations, so we append 356 * attribute values ourselves. If a custom title has been set on the variation 357 * that differs from the parent, respect it. 358 * 359 * Deliberately NOT WC's get_formatted_name(): that returns HTML (it embeds a 360 * "#<id>" and a <span class="description"> block), which must never reach a 361 * JSON response. Shared by format_variation() and resolve_product_ref_ids() 362 * so a variation reads the same everywhere it is named. 363 * 364 * @param \WC_Product_Variation $variation 365 * @return string Plain text, not HTML. Not yet specialchars-decoded. 366 */ 367 public static function variation_display_name( \WC_Product_Variation $variation ): string { 368 $parent_title = get_the_title( $variation->get_parent_id() ); 369 $variation_title = $variation->get_title(); 370 371 if ( '' !== $variation_title && $variation_title !== $parent_title ) { 372 return $variation_title; 373 } 374 375 $attrs = $variation->get_attributes(); 376 ksort( $attrs ); 377 $term_names = []; 378 foreach ( $attrs as $taxonomy => $slug ) { 379 if ( '' === $slug ) { 380 continue; // Skip "Any" attributes. 381 } 382 $term = get_term_by( 'slug', $slug, $taxonomy ); 383 if ( $term && ! is_wp_error( $term ) ) { 384 $term_names[] = $term->name; 385 } else { 386 $term_names[] = $slug; 387 } 388 } 389 390 $name = $parent_title; 391 if ( ! empty( $term_names ) ) { 392 $name .= ' – ' . implode( ', ', $term_names ); 393 } 394 395 return $name; 396 } 397 398 /** 399 * Normalise a list of product/variation IDs: positive ints only, deduped 400 * (FIRST occurrence wins), caller order preserved, optionally clamped. 401 * 402 * intval + "> 0", NOT absint: absint( -7 ) is 7, which would silently read the 403 * wrong product. The REST layer sanitises through this for the same reason. 404 * 405 * $limit clamps AFTER normalisation, deliberately. Clamping the raw input first 406 * would let duplicates or junk in the leading entries eat the budget — 407 * [5,5,5,7,9] with a limit of 3 must yield [5,7,9], not [5]. 408 * 409 * @param int[] $ids 410 * @param int|null $limit Optional cap, applied to the normalised list. 411 * @return int[] 412 */ 413 public static function normalize_product_ids( array $ids, ?int $limit = null ): array { 414 $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ), static fn( $id ) => $id > 0 ) ) ); 415 416 if ( null !== $limit ) { 417 $ids = array_slice( $ids, 0, max( 0, $limit ) ); 418 } 419 420 return $ids; 421 } 422 423 /** 424 * Drop WordPress's in-process object cache, if the active backend supports it. 425 * 426 * Long export loops prime post/meta/term caches on every page and nothing ever 427 * evicts them, so the runtime cache becomes the dominant memory consumer once 428 * row accumulation is gone. Each page re-primes what it needs, so dropping the 429 * in-process layer between chunks is safe — it costs a few re-priming queries 430 * per chunk and bounds memory in exchange. 431 * 432 * Three-tier guard. wp_cache_flush_runtime() exists as a compat shim from WP 6.0, 433 * but it emits _doing_it_wrong() and returns false when the active object-cache 434 * drop-in doesn't support it — and wp_cache_supports() itself only exists from 435 * WP 6.1. So: ask wp_cache_supports() when it is available; on 6.0 call the 436 * function only for the core default cache, which does support it; otherwise 437 * skip. This is an optimisation, never worth a _doing_it_wrong() notice. 438 */ 439 public static function flush_runtime_cache(): void { 440 if ( function_exists( 'wp_cache_supports' ) ) { 441 if ( wp_cache_supports( 'flush_runtime' ) ) { 442 wp_cache_flush_runtime(); 443 } 444 return; 445 } 446 447 if ( function_exists( 'wp_cache_flush_runtime' ) && ! wp_using_ext_object_cache() ) { 448 wp_cache_flush_runtime(); 449 } 450 } 451 452 /** 453 * Normalise a list of product/variation IDs and prime every cache a read of them needs. 454 * 455 * Two primes, not one. The first covers the ids themselves (posts + meta for _sku + terms 456 * for product_type). The second covers the PARENTS of any variation in the list: a 457 * variation is named from its parent's title, so without this each variation's 458 * get_the_title( parent ) is an individual query. 459 * 460 * Normalisation (positive-int filter, dedupe, clamp-after-dedupe) is delegated to 461 * normalize_product_ids() — see that method for why absint is wrong here and why 462 * $limit is applied last. 463 * 464 * @param int[] $ids 465 * @param int|null $limit Optional cap, applied to the normalised list. 466 * @return int[] The normalised, deduped, positive ids, in first-occurrence order. 467 */ 468 public static function prime_product_caches( array $ids, ?int $limit = null ): array { 469 $ids = self::normalize_product_ids( $ids, $limit ); 470 471 if ( empty( $ids ) ) { 472 return []; 473 } 474 475 _prime_post_caches( $ids, true, true ); 476 477 $parent_ids = []; 478 foreach ( $ids as $id ) { 479 $post = get_post( $id ); 480 if ( $post && 'product_variation' === $post->post_type && $post->post_parent ) { 481 $parent_ids[] = (int) $post->post_parent; 482 } 483 } 484 485 $parent_ids = array_values( array_diff( array_unique( $parent_ids ), $ids ) ); 486 if ( ! empty( $parent_ids ) ) { 487 _prime_post_caches( $parent_ids, true, true ); 488 } 489 490 return $ids; 491 } 492 493 /** 494 * Resolve a flat list of product IDs into upsell/cross-sell ref descriptors. 495 * 496 * Deliberately does NOT construct a WC_Product per ID. wc_get_product() runs the 497 * full data-store read — and for variable parents that means get_children(), a DB 498 * query each. Measured on a 100-row page: wc_get_product()-per-ref costs ~227 499 * queries; reading the primed post cache instead costs 0 for product refs. Only 500 * variation refs fall back to wc_get_product(), because naming them needs their 501 * attribute terms; that cost saturates (WP's term cache amortizes a store's small 502 * attribute vocabulary), so it is bounded by vocabulary, not by ref count. 503 * 504 * post_type is checked explicitly: the write path stores any positive ID without an 505 * existence check (dead refs are preserved by design — see Product_Service:: 506 * normalize_product_refs), so a list can legitimately hold a page or post ID from a 507 * bad import or a paste. Without this gate get_post() would return a valid WP_Post 508 * and we would render that page as a simple product. 509 * 510 * @param int[] $ids 511 * @return array<int, array> Map of id => [ id, name, sku, type, status, missing ]. 512 */ 513 public static function resolve_product_ref_ids( array $ids ): array { 514 $ids = self::prime_product_caches( $ids ); 515 516 if ( empty( $ids ) ) { 517 return []; 518 } 519 520 $map = []; 521 522 foreach ( $ids as $id ) { 523 $post = get_post( $id ); 524 525 if ( ! $post || ! in_array( $post->post_type, [ 'product', 'product_variation' ], true ) ) { 526 $map[ $id ] = self::missing_product_ref( $id ); 527 continue; 528 } 529 530 if ( 'product_variation' === $post->post_type ) { 531 $variation = wc_get_product( $id ); 532 if ( ! $variation instanceof \WC_Product_Variation ) { 533 $map[ $id ] = self::missing_product_ref( $id ); 534 continue; 535 } 536 $map[ $id ] = [ 537 'id' => $id, 538 'name' => wp_specialchars_decode( self::variation_display_name( $variation ) ), 539 // 'edit' context: a SKU-less variation must resolve to its OWN (empty) SKU, 540 // never the parent's. WC_Product_Variation::get_sku() inherits the parent SKU 541 // in view context, so the default read would export the parent's SKU for the 542 // variation — and WC import maps that SKU back to the PARENT, silently 543 // retargeting the link from variation to parent. The inherited SKU also feeds 544 // the picker chips, so a chip search for the shown SKU would find the parent. 545 // Matches the plain-product arm below, which reads raw _sku meta for the same 546 // persistence-safety. 547 'sku' => (string) $variation->get_sku( 'edit' ), 548 'type' => 'variation', 549 'status' => $post->post_status, 550 'missing' => false, 551 ]; 552 continue; 553 } 554 555 // Plain product — read straight off the primed caches. No WC_Product. 556 $terms = get_the_terms( $id, 'product_type' ); 557 $type = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0]->slug : 'simple'; 558 559 $map[ $id ] = [ 560 'id' => $id, 561 'name' => wp_specialchars_decode( $post->post_title ), 562 'sku' => (string) get_post_meta( $id, '_sku', true ), 563 'type' => $type, 564 'status' => $post->post_status, 565 'missing' => false, 566 ]; 567 } 568 569 return $map; 570 } 571 572 /** 573 * Gather the distinct upsell/cross-sell union OWNED BY these products, and resolve it. 574 * 575 * Note the direction: this takes OWNERS and returns descriptors for the products they 576 * point AT. Do not use it to resolve an arbitrary list of IDs (e.g. search results) — 577 * that is resolve_product_ref_ids(), and passing search hits here would hand back each 578 * hit's upsells instead of the hit itself. 579 * 580 * Reads 'edit' context: the default view getters run through 581 * woocommerce_product_get_upsell_ids, which third-party code can filter. The grid 582 * round-trips this value straight back into a write (open picker → add one → Apply), 583 * so a filtered projection would silently overwrite the stored list. 584 * 585 * @param \WC_Product[] $owners 586 * @return array<int, array> Map of id => ref descriptor. 587 */ 588 public static function build_product_ref_map( array $owners ): array { 589 $ref_ids = []; 590 591 foreach ( $owners as $owner ) { 592 if ( ! $owner instanceof \WC_Product || $owner->is_type( 'variation' ) ) { 593 continue; // Variations do not own these fields. 594 } 595 foreach ( $owner->get_upsell_ids( 'edit' ) as $ref_id ) { 596 $ref_ids[] = (int) $ref_id; 597 } 598 foreach ( $owner->get_cross_sell_ids( 'edit' ) as $ref_id ) { 599 $ref_ids[] = (int) $ref_id; 600 } 601 } 602 603 if ( empty( $ref_ids ) ) { 604 return []; // Stores that don't use these fields pay nothing. 605 } 606 607 return self::resolve_product_ref_ids( $ref_ids ); 608 } 609 610 /** 611 * Descriptor for a ref whose target no longer exists (or was never a product). 612 * 613 * @param int $id 614 * @return array 615 */ 616 private static function missing_product_ref( int $id ): array { 617 return [ 618 'id' => $id, 619 'name' => null, 620 'sku' => null, 621 'type' => null, 622 'status' => null, 623 'missing' => true, 624 ]; 625 } 626 627 /** 628 * Project an ordered ID list onto a ref map, preserving order. 629 * 630 * @param int[] $ids 631 * @param array $ref_map 632 * @return array[] 633 */ 634 private static function project_product_refs( array $ids, array $ref_map ): array { 635 return array_values( array_map( 636 static fn( int $id ) => $ref_map[ $id ] ?? self::missing_product_ref( $id ), 637 $ids 638 ) ); 639 } 640 641 /** 128 642 * Format a WC_Product_Variation into a flat array for the grid. 129 643 * … … 141 655 $to = $variation->get_date_on_sale_to(); 142 656 143 // Build variation display name. 144 // WC stores just the parent title in post_title for variations, so we 145 // append attribute values ourselves. If a custom title has been set on 146 // the variation that differs from the parent, respect it. 147 $parent_title = get_the_title( $variation->get_parent_id() ); 148 $variation_title = $variation->get_title(); 149 150 if ( '' !== $variation_title && $variation_title !== $parent_title ) { 151 $name = $variation_title; 152 } else { 153 $attrs = $variation->get_attributes(); 154 ksort( $attrs ); 155 $term_names = []; 156 foreach ( $attrs as $taxonomy => $slug ) { 157 if ( '' === $slug ) { 158 continue; // Skip "Any" attributes. 159 } 160 $term = get_term_by( 'slug', $slug, $taxonomy ); 161 if ( $term && ! is_wp_error( $term ) ) { 162 $term_names[] = $term->name; 163 } else { 164 $term_names[] = $slug; 165 } 166 } 167 $name = $parent_title; 168 if ( ! empty( $term_names ) ) { 169 $name .= ' – ' . implode( ', ', $term_names ); 170 } 171 } 657 $name = self::variation_display_name( $variation ); 172 658 173 659 $data = [ … … 199 685 'downloadable' => $variation->get_downloadable(), 200 686 'backorders' => $variation->get_backorders(), 687 // Cost of Goods — variation's own defined value (null = inherit 688 // parent). Getter only when the feature is on (see format_product). 689 'cogs_value' => self::is_cogs_enabled() ? $variation->get_cogs_value() : null, 690 // Menu order (native wp_posts.menu_order) — the variation's sort 691 // position within its parent. Signed integer, default 0. 692 'menu_order' => (int) $variation->get_menu_order(), 693 // Featured image upload date. DELIBERATELY present on variations, 694 // unlike date_created/date_modified which are parent-only: a variation 695 // owns its own _thumbnail_id, and the "Image uploaded" filter narrows 696 // variation rows by it. Do not "align" this with the other date fields. 697 'image_date' => self::get_image_upload_date( (int) $image_id ), 698 // Shipping class — the variation's OWN class. '' means "Same as 699 // parent", which is why get_shipping_class_slug() reads edit context: 700 // WC's view-context getter would inherit the parent's class here and 701 // make every inheriting variation look like it stores an override. 702 'shipping_class' => self::get_shipping_class_slug( $variation ), 201 703 ]; 202 704 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Main.php
r3549389 r3663491 29 29 $admin = new Admin(); 30 30 add_action( 'admin_menu', [ $admin, 'register_menu' ] ); 31 add_action( 'admin_bar_menu', [ $admin, 'add_admin_bar_link' ], 99 ); 31 32 add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); 32 33 … … 40 41 // Debug tracing — appends server metrics to REST responses when enabled. 41 42 new Handlers\Debug_Trace_Handler(); 43 44 // WordPress save-hook compatibility — Free feature, controlled by plugin setting. 45 new Handlers\WP_Save_Hooks_Handler(); 42 46 } 43 47 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Export_Controller.php
r3549389 r3663491 11 11 use WCABE_G2\Helpers\Product_Helper; 12 12 use WCABE_G2\Services\Export_Service; 13 use WCABE_G2\Services\Filter_Service; 13 14 use WCABE_G2\Services\Product_Service; 15 use WCABE_G2\Services\View_Service; 14 16 15 17 defined( 'ABSPATH' ) || exit; … … 41 43 'default' => [], 42 44 'items' => [ 'type' => 'integer' ], 45 // NOT absint: absint( -7 ) is 7, which would export an unrelated 46 // product. Normalising here keeps the route consistent with the 47 // service contract (positive ints, deduped, first occurrence wins). 43 48 'sanitize_callback' => function ( $value ) { 44 49 if ( ! is_array( $value ) ) { 45 50 return []; 46 51 } 47 return array_filter( array_map( 'absint', $value ));52 return Product_Helper::normalize_product_ids( $value ); 48 53 }, 49 54 ], … … 59 64 'sanitize_callback' => 'sanitize_text_field', 60 65 ], 66 'search_name_mode' => [ 67 'description' => __( 'Title match mode: all (every word, default), any (any word), or phrase (exact phrase).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 68 'type' => 'string', 69 'default' => 'all', 70 'enum' => Product_Service::TITLE_SEARCH_MODES, 71 'sanitize_callback' => 'sanitize_text_field', 72 ], 61 73 'search_sku' => [ 62 'description' => __( 'Search by SKU. Comma-separated for exact match.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 63 'type' => 'string', 64 'sanitize_callback' => 'sanitize_text_field', 74 /* translators: %d: maximum number of comma-separated SKU values. */ 75 'description' => sprintf( __( 'Search by SKU. Comma-separate multiple values to match any of them under the selected mode (capped at %d values).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), Product_Service::SKU_SEARCH_MAX_TOKENS ), 76 'type' => 'string', 77 'sanitize_callback' => 'sanitize_text_field', 78 ], 79 'search_sku_mode' => [ 80 'description' => __( 'SKU match mode: contains, is_exactly, not_contains, starts_with, or ends_with. Comma-separated values match any of them (none of them for not_contains). Omit or send an empty string for the legacy behaviour: comma-separated = exact, single value = partial.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 81 'type' => 'string', 82 'default' => '', 83 'enum' => array_merge( [ '' ], Product_Service::SKU_SEARCH_MODES ), 84 'sanitize_callback' => 'sanitize_text_field', 85 ], 86 'product_url_operator' => [ 87 'description' => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 88 'type' => 'string', 89 'default' => 'contains', 90 'enum' => Product_Service::PRODUCT_URL_OPERATORS, 91 'sanitize_callback' => 'sanitize_text_field', 92 ], 93 'product_url_value' => [ 94 'description' => __( 'Product URL filter value (substring).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 95 'type' => 'string', 96 // NOT sanitize_text_field: it strips percent-encoded octets. 97 'sanitize_callback' => [ View_Service::class, 'sanitize_url_filter_value' ], 65 98 ], 66 99 'category' => [ … … 69 102 'sanitize_callback' => 'sanitize_text_field', 70 103 ], 104 'tag' => [ 105 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 106 'type' => 'string', 107 'sanitize_callback' => 'sanitize_text_field', 108 ], 71 109 'type' => [ 72 110 'description' => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 84 122 'sanitize_callback' => 'sanitize_text_field', 85 123 ], 124 'shipping_class' => [ 125 // No sanitize_callback: sanitize_text_field() strips percent-encoded 126 // octets, which is exactly how WP stores non-ASCII term slugs 127 // (sanitize_title() -> utf8_uri_encode()). That would silently 128 // reduce e.g. a Cyrillic class's slug to '', match-noning every 129 // export for it. Filter_Service::apply_shipping_class_param() is 130 // the real sanitizer — sanitize_title() per comma-separated token. 131 'description' => __( 'Comma-separated shipping class slugs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 132 'type' => 'string', 133 ], 86 134 'price_min' => [ 87 135 'description' => __( 'Minimum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 104 152 'sanitize_callback' => 'sanitize_text_field', 105 153 ], 154 'menu_order_min' => [ 155 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 156 'type' => 'string', 157 'sanitize_callback' => 'sanitize_text_field', 158 'validate_callback' => function ( $value ) { 159 if ( '' === $value ) { 160 return true; 161 } 162 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 163 'rest_invalid_param', 164 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 165 ); 166 }, 167 ], 168 'menu_order_max' => [ 169 'description' => __( 'Maximum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 170 'type' => 'string', 171 'sanitize_callback' => 'sanitize_text_field', 172 'validate_callback' => function ( $value ) { 173 if ( '' === $value ) { 174 return true; 175 } 176 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 177 'rest_invalid_param', 178 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 179 ); 180 }, 181 ], 182 'cogs_min' => [ 183 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 184 'type' => 'string', 185 'sanitize_callback' => 'sanitize_text_field', 186 ], 187 'cogs_max' => [ 188 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 189 'type' => 'string', 190 'sanitize_callback' => 'sanitize_text_field', 191 ], 106 192 'manage_stock' => [ 107 193 'description' => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 134 220 'date_modified_before' => [ 135 221 'description' => __( 'Filter products modified on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 222 'type' => 'string', 223 'sanitize_callback' => 'sanitize_text_field', 224 'validate_callback' => [ $this, 'validate_date_param' ], 225 ], 226 'sale_start_after' => [ 227 'description' => __( 'Filter products whose sale starts on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 228 'type' => 'string', 229 'sanitize_callback' => 'sanitize_text_field', 230 'validate_callback' => [ $this, 'validate_date_param' ], 231 ], 232 'sale_start_before' => [ 233 'description' => __( 'Filter products whose sale starts on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 234 'type' => 'string', 235 'sanitize_callback' => 'sanitize_text_field', 236 'validate_callback' => [ $this, 'validate_date_param' ], 237 ], 238 'sale_end_after' => [ 239 'description' => __( 'Filter products whose sale ends on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 240 'type' => 'string', 241 'sanitize_callback' => 'sanitize_text_field', 242 'validate_callback' => [ $this, 'validate_date_param' ], 243 ], 244 'sale_end_before' => [ 245 'description' => __( 'Filter products whose sale ends on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 246 'type' => 'string', 247 'sanitize_callback' => 'sanitize_text_field', 248 'validate_callback' => [ $this, 'validate_date_param' ], 249 ], 250 'image_date_after' => [ 251 'description' => __( 'Filter products whose featured image was uploaded on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 252 'type' => 'string', 253 'sanitize_callback' => 'sanitize_text_field', 254 'validate_callback' => [ $this, 'validate_date_param' ], 255 ], 256 'image_date_before' => [ 257 'description' => __( 'Filter products whose featured image was uploaded on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 136 258 'type' => 'string', 137 259 'sanitize_callback' => 'sanitize_text_field', … … 183 305 * Export products as CSV. 184 306 * 185 * On success: outputs CSV directly via echo and terminates with wp_die(). 307 * On success: streams the CSV and terminates with exit() — NOT wp_die(). That is why 308 * this method itself has no test coverage: rest_do_request() from PHPUnit would kill 309 * the process. Everything except the header/stream/exit tail lives in 310 * build_csv_stream(), which IS testable; keep new logic there (or in the service) 311 * rather than here. 186 312 * On error: returns WP_REST_Response with JSON error body. 313 * 314 * Note the one thing this method cannot guarantee: once fpassthru() has started, 315 * a failure mid-transfer can no longer be converted into a JSON error. The 316 * fail-loud writes in Export_Service ensure the buffer is COMPLETE before any 317 * header is sent, which is the strongest guarantee available short of buffering 318 * to a real temp file and sending Content-Length. 319 * 320 * Catches \Throwable, not just \RuntimeException: build_csv_stream() also runs 321 * third-party format filters (Pro's ACF reads, for one), and a bug there must 322 * still surface as a JSON error the frontend can render — not an uncaught fatal 323 * that WP's REST fatal handler turns into a bodyless/HTML 500 with no message, 324 * which is the exact failure mode #177 is about. The original error is logged 325 * so the underlying bug stays visible to whoever reads the PHP error log. 187 326 * 188 327 * @param \WP_REST_Request $request … … 190 329 */ 191 330 public function export( \WP_REST_Request $request ) { 192 $columns = $request->get_param( 'columns' ); 193 $product_ids = $request->get_param( 'product_ids' ); 194 $include_variations = (bool) $request->get_param( 'include_variations' ); 195 196 $products = []; 197 198 if ( ! empty( $product_ids ) ) { 199 // Export specific products by ID. 200 foreach ( $product_ids as $id ) { 201 $wc_product = wc_get_product( $id ); 202 if ( ! $wc_product ) { 203 continue; 204 } 205 206 if ( $wc_product->is_type( 'variation' ) ) { 207 $products[] = Product_Helper::format_variation( $wc_product ); 208 } else { 209 $products[] = Product_Helper::format_product( $wc_product ); 210 } 211 212 if ( count( $products ) >= self::MAX_EXPORT_ROWS ) { 213 break; 214 } 215 } 216 } else { 217 // Export all filtered products, paginating through. 218 $filters = $this->extract_filters( $request ); 219 $service = new Product_Service(); 220 $page = 1; 221 222 do { 223 $result = $service->get_products( $page, 100, $include_variations, $filters ); 224 $products = array_merge( $products, $result['products'] ); 225 $has_more = $page < $result['total_pages']; 226 $page++; 227 228 if ( count( $products ) >= self::MAX_EXPORT_ROWS ) { 229 $products = array_slice( $products, 0, self::MAX_EXPORT_ROWS ); 230 break; 231 } 232 } while ( $has_more ); 233 } 234 235 if ( empty( $products ) ) { 331 $this->raise_resource_limits(); 332 333 try { 334 $stream = $this->build_csv_stream( $request ); 335 } catch ( \Throwable $e ) { 336 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- no logging abstraction exists in this codebase (grepped); this is the standard PHP fallback so the underlying cause isn't lost once it's converted to a generic JSON message below. 337 error_log( sprintf( 'WCABE G2 export failed: %s', $e->getMessage() ) ); 338 339 return new \WP_REST_Response( 340 [ 341 'code' => 'export_failed', 342 'message' => __( 'Export failed while generating the CSV file.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 343 ], 344 500 345 ); 346 } 347 348 $handle = $stream['handle']; 349 $count = $stream['count']; 350 351 if ( 0 === $count ) { 352 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream from build_csv_stream(). 353 fclose( $handle ); 354 236 355 return new \WP_REST_Response( 237 356 [ … … 243 362 } 244 363 245 $units = [ 246 'weight_unit' => $request->get_param( 'weight_unit' ), 247 'dimension_unit' => $request->get_param( 'dimension_unit' ), 248 ]; 249 250 $export_service = new Export_Service(); 251 $csv = $export_service->generate_csv( $products, $columns, true, $units ); 252 $count = count( $products ); 364 // Checked BEFORE any header is sent — this is the last point at which a 365 // failure can still become a JSON error rather than a truncated 200. 366 if ( ! rewind( $handle ) ) { 367 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream from build_csv_stream(). 368 fclose( $handle ); 369 370 return new \WP_REST_Response( 371 [ 372 'code' => 'export_failed', 373 'message' => __( 'Export failed while generating the CSV file.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 374 ], 375 500 376 ); 377 } 253 378 254 379 header( 'Content-Type: text/csv; charset=UTF-8' ); … … 260 385 header( 'Cache-Control: no-cache, no-store, must-revalidate' ); 261 386 262 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $csv is a CSV-format response body produced by fputcsv() with proper RFC 4180 quoting; Content-Type is text/csv (set above), so HTML escaping is incorrect and would corrupt the output. 263 echo $csv; 387 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- streams a CSV-format response body produced by fputcsv() with proper RFC 4180 quoting; Content-Type is text/csv (set above), so HTML escaping is incorrect and would corrupt the output. 388 fpassthru( $handle ); 389 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream from build_csv_stream(). 390 fclose( $handle ); 264 391 exit; 265 392 } 266 393 267 394 /** 395 * Raise the request's memory and time limits before building the CSV. 396 * 397 * Belt and braces. Note wp_raise_memory_limit() only raises as far as 398 * WP_MAX_MEMORY_LIMIT (256M by default), so on a host already at 256M it is a 399 * no-op — the memory fix that actually matters is the streaming collection in 400 * Product_Service, not this call. wc_set_time_limit(300) — not 0 — because this 401 * is a synchronous web request and should not run unbounded. 402 * 403 * TEST SEAM: a fatal-failure test proxy overrides this to a no-op, so exercising 404 * the failure path doesn't call set_time_limit()/ini_set() on the shared 405 * PHPUnit process for every run. 406 */ 407 protected function raise_resource_limits(): void { 408 wp_raise_memory_limit( 'admin' ); 409 wc_set_time_limit( 300 ); 410 } 411 412 /** 413 * Build the CSV into a temporary stream. 414 * 415 * TEST SEAM: this is export() minus the headers/stream/exit tail, so PHPUnit can 416 * drive the whole collection → CSV bridge through a proxy subclass. 417 * 418 * The stream is php://temp (spills to disk past 2MB) rather than php://output: 419 * an error must still be able to return JSON, and a mid-loop failure must never 420 * emit a truncated 200 CSV. 421 * 422 * @param \WP_REST_Request $request 423 * @return array{handle: resource, count: int} Caller owns the handle and must fclose() it. 424 * @throws \RuntimeException When the stream cannot be opened or written. 425 */ 426 protected function build_csv_stream( \WP_REST_Request $request ): array { 427 $columns = $request->get_param( 'columns' ); 428 $product_ids = $request->get_param( 'product_ids' ); 429 $include_variations = (bool) $request->get_param( 'include_variations' ); 430 431 $units = [ 432 'weight_unit' => $request->get_param( 'weight_unit' ), 433 'dimension_unit' => $request->get_param( 'dimension_unit' ), 434 ]; 435 436 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory PHP stream (disk-backed past 2MB), not a filesystem operation; WP_Filesystem does not apply. 437 $handle = fopen( 'php://temp', 'r+' ); 438 439 if ( false === $handle ) { 440 throw new \RuntimeException( 'Unable to open a temporary stream for CSV export.' ); 441 } 442 443 try { 444 $export_service = new Export_Service(); 445 446 // begin_csv() once, write_rows() per chunk, all on the SAME instance — that 447 // instance holds the resolved Pro column map every row write reads. 448 $resolved = $export_service->begin_csv( $handle, $columns, true, $units ); 449 450 $sink = static function ( array $rows ) use ( $export_service, $handle, $resolved ): void { 451 $export_service->write_rows( $handle, $rows, $resolved ); 452 }; 453 454 $service = new Product_Service(); 455 456 $count = ! empty( $product_ids ) 457 ? $service->stream_products_by_ids( (array) $product_ids, self::MAX_EXPORT_ROWS, $sink ) 458 : $service->stream_products( 459 $this->extract_filters( $request ), 460 $include_variations, 461 self::MAX_EXPORT_ROWS, 462 $sink 463 ); 464 } catch ( \Throwable $e ) { 465 // Catch solely to release the handle — the caller never receives it, so 466 // nothing else could close it. Rethrown unchanged. 467 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream opened above. 468 fclose( $handle ); 469 throw $e; 470 } 471 472 return [ 473 'handle' => $handle, 474 'count' => $count, 475 ]; 476 } 477 478 /** 268 479 * Extract filter parameters from the request. 480 * 481 * Protected rather than private as a TEST SEAM: export() itself ends in 482 * exit(), so it cannot run under PHPUnit — a test subclass exposes this 483 * method instead, which is what keeps the filter→CSV bridge covered. 269 484 * 270 485 * @param \WP_REST_Request $request 271 486 * @return array 272 487 */ 273 pr ivatefunction extract_filters( \WP_REST_Request $request ): array {488 protected function extract_filters( \WP_REST_Request $request ): array { 274 489 $filters = []; 275 490 … … 278 493 $filters['search_name'] = sanitize_text_field( $search_name ); 279 494 } 280 $search_sku = $request->get_param( 'search_sku' ); 281 if ( ! empty( $search_sku ) ) { 282 $filters['search_sku'] = sanitize_text_field( $search_sku ); 283 } 284 495 $search_name_mode = $request->get_param( 'search_name_mode' ); 496 if ( in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 497 $filters['search_name_mode'] = $search_name_mode; 498 } 499 // Strict '' test, not empty(): "0" is a valid SKU. 500 $search_sku = sanitize_text_field( (string) $request->get_param( 'search_sku' ) ); 501 if ( '' !== $search_sku ) { 502 $filters['search_sku'] = $search_sku; 503 } 504 $search_sku_mode = $request->get_param( 'search_sku_mode' ); 505 if ( in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 506 $filters['search_sku_mode'] = $search_sku_mode; 507 } 508 509 // Product URL filter. Deliberately NOT gated on a non-empty value — the 510 // is_empty / is_not_empty operators are effective with none. 511 // Mirrors Products_Controller::get_items(); keep the two in step. 512 $product_url_operator = $request->get_param( 'product_url_operator' ) ?: 'contains'; 513 $product_url_value = View_Service::sanitize_url_filter_value( $request->get_param( 'product_url_value' ) ?? '' ); 514 if ( Product_Service::product_url_filter_is_effective( $product_url_operator, $product_url_value ) ) { 515 $filters['product_url_operator'] = $product_url_operator; 516 $filters['product_url_value'] = $product_url_value; 517 } 518 519 // Term-ID filters. A param that is PRESENT but parses to zero valid IDs must match 520 // NOTHING (the [0] sentinel — no term has ID 0), not drop the filter: an export 521 // that silently widens back to the whole catalog is the worse failure. The presence 522 // test is deliberately `'' !==` rather than `! empty()` — empty('0') is true, so the 523 // single most likely corrupt value would otherwise slip through as "no filter". 524 // Mirrors Products_Controller::get_items(); keep the two in step. 285 525 $category = $request->get_param( 'category' ); 286 if ( ! empty( $category ) ) { 287 $filters['category'] = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 526 if ( null !== $category && '' !== $category ) { 527 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 528 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 529 } 530 531 $tag = $request->get_param( 'tag' ); 532 if ( null !== $tag && '' !== $tag ) { 533 $tag_ids = array_filter( array_map( 'absint', explode( ',', $tag ) ) ); 534 $filters['tag'] = empty( $tag_ids ) ? [ 0 ] : $tag_ids; 288 535 } 289 536 … … 303 550 } 304 551 552 $filters = Filter_Service::apply_shipping_class_param( 553 $request->get_param( 'shipping_class' ), 554 $filters 555 ); 556 305 557 $price_min = $request->get_param( 'price_min' ); 306 558 if ( null !== $price_min && '' !== $price_min ) { … … 313 565 } 314 566 567 // Cost of Goods range — gated so a stale param can't trigger the COGS query 568 // path when the feature is off (matches Products_Controller). 569 if ( Product_Helper::is_cogs_enabled() ) { 570 $cogs_min = $request->get_param( 'cogs_min' ); 571 if ( null !== $cogs_min && '' !== $cogs_min ) { 572 $filters['cogs_min'] = sanitize_text_field( $cogs_min ); 573 } 574 575 $cogs_max = $request->get_param( 'cogs_max' ); 576 if ( null !== $cogs_max && '' !== $cogs_max ) { 577 $filters['cogs_max'] = sanitize_text_field( $cogs_max ); 578 } 579 } 580 315 581 $stock_qty_min = $request->get_param( 'stock_qty_min' ); 316 582 if ( null !== $stock_qty_min && '' !== $stock_qty_min ) { … … 323 589 } 324 590 591 $menu_order_min = $request->get_param( 'menu_order_min' ); 592 if ( null !== $menu_order_min && '' !== $menu_order_min ) { 593 $filters['menu_order_min'] = sanitize_text_field( $menu_order_min ); 594 } 595 596 $menu_order_max = $request->get_param( 'menu_order_max' ); 597 if ( null !== $menu_order_max && '' !== $menu_order_max ) { 598 $filters['menu_order_max'] = sanitize_text_field( $menu_order_max ); 599 } 600 325 601 $manage_stock = $request->get_param( 'manage_stock' ); 326 602 if ( ! empty( $manage_stock ) ) { … … 333 609 } 334 610 335 $date_params = [ 'date_created_after', 'date_created_before', 'date_modified_after', 'date_modified_before' ]; 611 $date_params = [ 612 'date_created_after', 613 'date_created_before', 614 'date_modified_after', 615 'date_modified_before', 616 'sale_start_after', 617 'sale_start_before', 618 'sale_end_after', 619 'sale_end_before', 620 'image_date_after', 621 'image_date_before', 622 ]; 336 623 foreach ( $date_params as $param ) { 337 624 $val = $request->get_param( $param ); -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Products_Controller.php
r3549389 r3663491 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 10 11 use WCABE_G2\Helpers\Permission_Helper; 12 use WCABE_G2\Helpers\Product_Helper; 13 use WCABE_G2\Services\Filter_Service; 11 14 use WCABE_G2\Services\Product_Service; 15 use WCABE_G2\Services\View_Service; 12 16 13 17 defined( 'ABSPATH' ) || exit; … … 30 34 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 31 35 'args' => $this->get_collection_params(), 36 ], 37 ] ); 38 39 register_rest_route( $this->namespace, '/' . $this->rest_base . '/search', [ 40 [ 41 'methods' => \WP_REST_Server::READABLE, 42 'callback' => [ $this, 'search_items' ], 43 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 44 'args' => [ 45 'term' => [ 46 'description' => __( 'Search term — matches product title, SKU, GTIN, or a bare product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 47 'required' => true, 48 'type' => 'string', 49 'minLength' => 1, 50 'sanitize_callback' => 'sanitize_text_field', 51 'validate_callback' => static function ( $value ) { 52 if ( '' === trim( (string) $value ) ) { 53 return new \WP_Error( 54 'rest_invalid_param', 55 __( 'Search term cannot be empty.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 56 [ 'status' => 400 ] 57 ); 58 } 59 return true; 60 }, 61 ], 62 'exclude' => [ 63 'description' => __( 'Product IDs to omit from results (e.g. the product being edited).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 64 'type' => 'array', 65 'default' => [], 66 'items' => [ 'type' => 'integer' ], 67 'sanitize_callback' => static function ( $value ) { 68 if ( ! is_array( $value ) ) { 69 return []; 70 } 71 // intval, NOT absint: absint( -7 ) is 7, so a negative would 72 // silently exclude the wrong product. 73 return array_values( array_filter( 74 array_map( 'intval', $value ), 75 static fn( $id ) => $id > 0 76 ) ); 77 }, 78 ], 79 'limit' => [ 80 'description' => __( 'Maximum number of results.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 81 'type' => 'integer', 82 'default' => 30, 83 'minimum' => 1, 84 'maximum' => 50, 85 'sanitize_callback' => 'absint', 86 'validate_callback' => 'rest_validate_request_arg', 87 ], 88 ], 32 89 ], 33 90 ] ); … … 61 118 ], 62 119 ], 120 'batch_id' => [ 121 'required' => false, 122 'type' => 'string', 123 'description' => __( 'Optional client-generated identifier correlating all chunks of one save.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 124 'validate_callback' => 'rest_validate_request_arg', 125 'sanitize_callback' => 'sanitize_text_field', 126 ], 63 127 ], 64 128 ], 65 129 ] ); 130 } 131 132 /** 133 * Product lookup for the linked-product picker. 134 * 135 * Route parsing and response shaping only — the search itself lives in 136 * Product_Service::search_products(), where the business logic belongs. 137 * 138 * @param \WP_REST_Request $request 139 * @return \WP_REST_Response 140 */ 141 public function search_items( $request ): \WP_REST_Response { 142 $service = new Product_Service(); 143 144 $results = $service->search_products( 145 (string) $request->get_param( 'term' ), 146 (array) $request->get_param( 'exclude' ), 147 (int) $request->get_param( 'limit' ) 148 ); 149 150 return new \WP_REST_Response( $results, 200 ); 66 151 } 67 152 … … 72 157 public function get_items( $request ): \WP_REST_Response { 73 158 $page = absint( $request->get_param( 'page' ) ) ?: 1; 74 $per_page = absint( $request->get_param( 'per_page' ) ) ?: 100; 159 // Clamped again here (the schema's sanitize_callback already ran) because 160 // get_items() must hold the bound for any caller, including internal 161 // dispatches that build a request without the registered schema. 162 $per_page = Page_Size_Helper::clamp( $request->get_param( 'per_page' ) ); 75 163 $include_variations = (bool) $request->get_param( 'include_variations' ); 76 77 /**78 * Filters the maximum number of products returned per request.79 *80 * Default 100 is a universal performance safety cap to prevent memory81 * exhaustion on shared hosting when variations are expanded. Both Free82 * and Pro honor this default — Pro does not raise it. Site admins on83 * infrastructure that can handle larger page sizes may filter this.84 *85 * @param int $max_per_page Default 100.86 */87 $max_per_page = (int) apply_filters( 'wcabe_g2_max_per_page', 100 );88 $per_page = min( $per_page, $max_per_page );89 164 90 165 // Build filters array from request params. … … 95 170 $filters['search_name'] = sanitize_text_field( $search_name ); 96 171 } 97 $search_sku = $request->get_param( 'search_sku' ); 98 if ( ! empty( $search_sku ) ) { 99 $filters['search_sku'] = sanitize_text_field( $search_sku ); 172 $search_name_mode = $request->get_param( 'search_name_mode' ); 173 if ( in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 174 $filters['search_name_mode'] = $search_name_mode; 175 } 176 // Strict '' test, not empty(): "0" is a valid SKU. 177 $search_sku = sanitize_text_field( (string) $request->get_param( 'search_sku' ) ); 178 if ( '' !== $search_sku ) { 179 $filters['search_sku'] = $search_sku; 180 } 181 $search_sku_mode = $request->get_param( 'search_sku_mode' ); 182 if ( in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 183 $filters['search_sku_mode'] = $search_sku_mode; 100 184 } 101 185 $search_description = $request->get_param( 'search_description' ); … … 117 201 } 118 202 203 // Product URL filter. Deliberately NOT gated on a non-empty value: the 204 // is_empty / is_not_empty operators are effective with none, so the usual 205 // `if ( ! empty( $value ) )` shape would silently drop them here. 206 $product_url_operator = $request->get_param( 'product_url_operator' ) ?: 'contains'; 207 $product_url_value = View_Service::sanitize_url_filter_value( $request->get_param( 'product_url_value' ) ?? '' ); 208 if ( Product_Service::product_url_filter_is_effective( $product_url_operator, $product_url_value ) ) { 209 $filters['product_url_operator'] = $product_url_operator; 210 $filters['product_url_value'] = $product_url_value; 211 } 212 213 // Term-ID filters. A param that is PRESENT but parses to zero valid IDs must match 214 // NOTHING (the [0] sentinel — no term has ID 0), not drop the filter: silently 215 // widening a filtered request back to the whole catalog is the worse failure, and 216 // the same parse feeds CSV export. The presence test is deliberately `'' !==` 217 // rather than `! empty()` — empty('0') is true, so the single most likely corrupt 218 // value would otherwise slip through as "no filter". 119 219 $category = $request->get_param( 'category' ); 120 if ( ! empty( $category ) ) { 121 $filters['category'] = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 220 if ( null !== $category && '' !== $category ) { 221 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 222 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 223 } 224 225 $tag = $request->get_param( 'tag' ); 226 if ( null !== $tag && '' !== $tag ) { 227 $tag_ids = array_filter( array_map( 'absint', explode( ',', $tag ) ) ); 228 $filters['tag'] = empty( $tag_ids ) ? [ 0 ] : $tag_ids; 122 229 } 123 230 … … 137 244 } 138 245 246 $filters = Filter_Service::apply_shipping_class_param( 247 $request->get_param( 'shipping_class' ), 248 $filters 249 ); 250 139 251 $price_min = $request->get_param( 'price_min' ); 140 252 if ( null !== $price_min && '' !== $price_min ) { … … 147 259 } 148 260 261 // Cost of Goods range — only honored when the COGS feature is enabled, so a 262 // stale param can't trigger the COGS query path (or get_cogs_value()). 263 if ( Product_Helper::is_cogs_enabled() ) { 264 $cogs_min = $request->get_param( 'cogs_min' ); 265 if ( null !== $cogs_min && '' !== $cogs_min ) { 266 $filters['cogs_min'] = sanitize_text_field( $cogs_min ); 267 } 268 269 $cogs_max = $request->get_param( 'cogs_max' ); 270 if ( null !== $cogs_max && '' !== $cogs_max ) { 271 $filters['cogs_max'] = sanitize_text_field( $cogs_max ); 272 } 273 } 274 149 275 $stock_qty_min = $request->get_param( 'stock_qty_min' ); 150 276 if ( null !== $stock_qty_min && '' !== $stock_qty_min ) { … … 157 283 } 158 284 285 $menu_order_min = $request->get_param( 'menu_order_min' ); 286 if ( null !== $menu_order_min && '' !== $menu_order_min ) { 287 $filters['menu_order_min'] = sanitize_text_field( $menu_order_min ); 288 } 289 290 $menu_order_max = $request->get_param( 'menu_order_max' ); 291 if ( null !== $menu_order_max && '' !== $menu_order_max ) { 292 $filters['menu_order_max'] = sanitize_text_field( $menu_order_max ); 293 } 294 159 295 $manage_stock = $request->get_param( 'manage_stock' ); 160 296 if ( ! empty( $manage_stock ) ) { … … 167 303 } 168 304 169 $date_params = [ 'date_created_after', 'date_created_before', 'date_modified_after', 'date_modified_before' ]; 305 $date_params = [ 306 'date_created_after', 307 'date_created_before', 308 'date_modified_after', 309 'date_modified_before', 310 'sale_start_after', 311 'sale_start_before', 312 'sale_end_after', 313 'sale_end_before', 314 'image_date_after', 315 'image_date_before', 316 ]; 170 317 foreach ( $date_params as $param ) { 171 318 $val = $request->get_param( $param ); … … 202 349 $filters = apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 203 350 351 // Server-side sort. Whitelisted at the schema level (orderby: id|menu_order); 352 // the service maps these to WC/WP_Query orderby values. 353 $orderby = (string) $request->get_param( 'orderby' ); 354 $order = (string) $request->get_param( 'order' ); 355 204 356 $service = new Product_Service(); 205 $data = $service->get_products( $page, $per_page, $include_variations, $filters );357 $data = $service->get_products( $page, $per_page, $include_variations, $filters, $orderby, $order ); 206 358 207 359 $response = rest_ensure_response( $data ); … … 240 392 } 241 393 394 $batch_id = $request->get_param( 'batch_id' ); 395 242 396 $service = new Product_Service(); 243 $results = $service->batch_update( $products ); 397 $results = $service->batch_update( 398 $products, 399 [ 'batch_id' => is_string( $batch_id ) ? $batch_id : null ] 400 ); 244 401 245 402 return rest_ensure_response( [ 'results' => $results ] ); … … 281 438 'description' => __( 'Maximum number of items per page.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 282 439 'type' => 'integer', 283 'default' => 100, 284 'minimum' => 1, 285 'maximum' => 100, 286 'sanitize_callback' => 'absint', 287 'validate_callback' => 'rest_validate_request_arg', 440 'default' => Page_Size_Helper::DEFAULT_PAGE_SIZE, 441 // Deliberately no 'minimum'/'maximum'/'validate_callback': an 442 // out-of-range or malformed per_page must degrade to a clamped 443 // value, never 400 the whole grid fetch. Page_Size_Helper::clamp 444 // bounds every input (including non-scalars) without erroring, so 445 // schema validation would only add a failure mode. The effective 446 // maximum is filterable per site, which a static schema 'maximum' 447 // could not express anyway. 448 'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ], 288 449 ], 289 450 'include_variations' => [ … … 293 454 'sanitize_callback' => 'rest_sanitize_boolean', 294 455 ], 456 'orderby' => [ 457 'description' => __( 'Server-side sort column.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 458 'type' => 'string', 459 'default' => 'id', 460 'enum' => [ 'id', 'menu_order' ], 461 ], 462 'order' => [ 463 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 464 'type' => 'string', 465 'default' => 'desc', 466 'enum' => [ 'asc', 'desc' ], 467 ], 295 468 'search_name' => [ 296 469 'description' => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 298 471 'sanitize_callback' => 'sanitize_text_field', 299 472 ], 473 'search_name_mode' => [ 474 'description' => __( 'Title match mode: all (every word, default), any (any word), or phrase (exact phrase).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 475 'type' => 'string', 476 'default' => 'all', 477 'enum' => Product_Service::TITLE_SEARCH_MODES, 478 'sanitize_callback' => 'sanitize_text_field', 479 ], 300 480 'search_sku' => [ 301 'description' => __( 'Search by SKU. Comma-separated for exact match.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 302 'type' => 'string', 481 /* translators: %d: maximum number of comma-separated SKU values. */ 482 'description' => sprintf( __( 'Search by SKU. Comma-separate multiple values to match any of them under the selected mode (capped at %d values).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), Product_Service::SKU_SEARCH_MAX_TOKENS ), 483 'type' => 'string', 484 'sanitize_callback' => 'sanitize_text_field', 485 ], 486 'search_sku_mode' => [ 487 'description' => __( 'SKU match mode: contains, is_exactly, not_contains, starts_with, or ends_with. Comma-separated values match any of them (none of them for not_contains). Omit or send an empty string for the legacy behaviour: comma-separated = exact, single value = partial.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 488 'type' => 'string', 489 'default' => '', 490 'enum' => array_merge( [ '' ], Product_Service::SKU_SEARCH_MODES ), 303 491 'sanitize_callback' => 'sanitize_text_field', 304 492 ], … … 330 518 'sanitize_callback' => 'sanitize_text_field', 331 519 ], 520 'product_url_operator' => [ 521 'description' => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 522 'type' => 'string', 523 'default' => 'contains', 524 'enum' => Product_Service::PRODUCT_URL_OPERATORS, 525 'sanitize_callback' => 'sanitize_text_field', 526 ], 527 'product_url_value' => [ 528 'description' => __( 'Product URL filter value (substring).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 529 'type' => 'string', 530 // NOT sanitize_text_field: it strips percent-encoded octets (%20, 531 // %2F), which are legitimate content in a URL substring search. 532 'sanitize_callback' => [ View_Service::class, 'sanitize_url_filter_value' ], 533 ], 332 534 'category' => [ 333 535 'description' => __( 'Comma-separated category term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 335 537 'sanitize_callback' => 'sanitize_text_field', 336 538 ], 539 'tag' => [ 540 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 541 'type' => 'string', 542 'sanitize_callback' => 'sanitize_text_field', 543 ], 337 544 'type' => [ 338 545 'description' => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 349 556 'type' => 'string', 350 557 'sanitize_callback' => 'sanitize_text_field', 558 ], 559 'shipping_class' => [ 560 // No sanitize_callback: sanitize_text_field() strips percent-encoded 561 // octets, which is exactly how WP stores non-ASCII term slugs 562 // (sanitize_title() -> utf8_uri_encode()). That would silently reduce 563 // e.g. a Cyrillic class's slug to '', match-noning every request for 564 // it. Filter_Service::apply_shipping_class_param() is the real 565 // sanitizer — sanitize_title() per comma-separated token. 566 'description' => __( 'Comma-separated shipping class slugs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 567 'type' => 'string', 351 568 ], 352 569 'price_min' => [ … … 378 595 }, 379 596 ], 597 'cogs_min' => [ 598 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 599 'type' => 'string', 600 'sanitize_callback' => 'sanitize_text_field', 601 'validate_callback' => function( $value ) { 602 if ( '' === $value ) { 603 return true; 604 } 605 return is_numeric( $value ) ? true : new \WP_Error( 606 'rest_invalid_param', 607 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 608 ); 609 }, 610 ], 611 'cogs_max' => [ 612 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 613 'type' => 'string', 614 'sanitize_callback' => 'sanitize_text_field', 615 'validate_callback' => function( $value ) { 616 if ( '' === $value ) { 617 return true; 618 } 619 return is_numeric( $value ) ? true : new \WP_Error( 620 'rest_invalid_param', 621 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 622 ); 623 }, 624 ], 380 625 'stock_qty_min' => [ 381 626 'description' => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 406 651 }, 407 652 ], 653 'menu_order_min' => [ 654 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 655 'type' => 'string', 656 'sanitize_callback' => 'sanitize_text_field', 657 'validate_callback' => function( $value ) { 658 // Integer-only (menu order is a whole number; may be negative) — 659 // deliberately stricter than the decimal-allowing stock_qty filter. 660 if ( '' === $value ) { 661 return true; 662 } 663 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 664 'rest_invalid_param', 665 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 666 ); 667 }, 668 ], 669 'menu_order_max' => [ 670 'description' => __( 'Maximum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 671 'type' => 'string', 672 'sanitize_callback' => 'sanitize_text_field', 673 'validate_callback' => function( $value ) { 674 if ( '' === $value ) { 675 return true; 676 } 677 return preg_match( '/^-?\d+$/', (string) $value ) ? true : new \WP_Error( 678 'rest_invalid_param', 679 __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 680 ); 681 }, 682 ], 408 683 'manage_stock' => [ 409 684 'description' => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 436 711 'date_modified_before' => [ 437 712 'description' => __( 'Filter products modified on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 713 'type' => 'string', 714 'sanitize_callback' => 'sanitize_text_field', 715 'validate_callback' => [ $this, 'validate_date_param' ], 716 ], 717 'sale_start_after' => [ 718 'description' => __( 'Filter products whose sale starts on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 719 'type' => 'string', 720 'sanitize_callback' => 'sanitize_text_field', 721 'validate_callback' => [ $this, 'validate_date_param' ], 722 ], 723 'sale_start_before' => [ 724 'description' => __( 'Filter products whose sale starts on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 725 'type' => 'string', 726 'sanitize_callback' => 'sanitize_text_field', 727 'validate_callback' => [ $this, 'validate_date_param' ], 728 ], 729 'sale_end_after' => [ 730 'description' => __( 'Filter products whose sale ends on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 731 'type' => 'string', 732 'sanitize_callback' => 'sanitize_text_field', 733 'validate_callback' => [ $this, 'validate_date_param' ], 734 ], 735 'sale_end_before' => [ 736 'description' => __( 'Filter products whose sale ends on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 737 'type' => 'string', 738 'sanitize_callback' => 'sanitize_text_field', 739 'validate_callback' => [ $this, 'validate_date_param' ], 740 ], 741 'image_date_after' => [ 742 'description' => __( 'Filter products whose featured image was uploaded on or after this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 743 'type' => 'string', 744 'sanitize_callback' => 'sanitize_text_field', 745 'validate_callback' => [ $this, 'validate_date_param' ], 746 ], 747 'image_date_before' => [ 748 'description' => __( 'Filter products whose featured image was uploaded on or before this date (YYYY-MM-DD).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 438 749 'type' => 'string', 439 750 'sanitize_callback' => 'sanitize_text_field', -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Settings_Controller.php
r3549389 r3663491 26 26 ], 27 27 'debug_tracing' => [ 28 'type' => 'boolean', 29 'default' => false, 30 ], 31 'trigger_wp_save_hooks' => [ 28 32 'type' => 'boolean', 29 33 'default' => false, -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/User_Settings_Controller.php
r3549389 r3663491 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 10 11 use WCABE_G2\Helpers\Permission_Helper; 11 12 use WCABE_G2\Services\View_Service; … … 27 28 */ 28 29 private const SCHEMA = [ 29 'pageSize' => [ 'type' => 'integer', 'default' => 100],30 'pageSize' => [ 'type' => 'integer', 'default' => Page_Size_Helper::DEFAULT_PAGE_SIZE ], 30 31 'includeVariations' => [ 'type' => 'boolean', 'default' => false ], 31 32 'darkMode' => [ 'type' => 'boolean', 'default' => false ], … … 34 35 'imagePreviewEnabled' => [ 'type' => 'boolean', 'default' => true ], 35 36 'autoLoadProducts' => [ 'type' => 'boolean', 'default' => true ], 37 'showAdminBarShortcut' => [ 'type' => 'boolean', 'default' => true ], 38 'refreshAfterSave' => [ 'type' => 'boolean', 'default' => true ], 39 // Working-layout draft (views working-state redesign). filterDraft is the 40 // filter half (view `filters` shape); activeViewId is the baseline pointer 41 // and the load-precedence "has-draft" marker (nullable — never (string)-cast). 42 'filterDraft' => [ 'type' => 'filters', 'default' => null ], 43 'activeViewId' => [ 'type' => 'nullable_string', 'default' => null ], 44 // Opaque scope stamp recorded alongside filterDraft, identifying the context 45 // the draft's entity ids were resolved in. Tier-neutral: Free never 46 // interprets the value, it only round-trips it so the frontend can compare 47 // the stored stamp against the current one and discard a draft whose entity 48 // ids belong to a different scope (see stripScopedFilters / filterScopeKey 49 // registry slot). Null = no stamp recorded. 50 'filterScopeKey' => [ 'type' => 'nullable_string', 'default' => null ], 36 51 ]; 37 52 … … 58 73 59 74 public function update_settings( \WP_REST_Request $request ): \WP_REST_Response { 75 $body = $request->get_json_params(); 76 77 // Order-safe write gate via a monotonic client _rev (R7.1/R8.1/R8.2). The 78 // settings sync is last-write-wins partial merge, so a stale in-flight request 79 // landing after the unload beacon could clobber individual keys. Stamping every 80 // write with a client _rev and applying-only-if-newer closes that race. 81 // 82 // Read the RAW stored meta for the prior _rev — get_all() is SCHEMA-only and 83 // would drop _rev, so the compare and write-back must use the raw array (R8.1). 84 $raw = get_user_meta( get_current_user_id(), self::META_KEY, true ); 85 if ( ! is_array( $raw ) ) { 86 $raw = []; 87 } 88 // Use the RAW numeric value — do NOT (int)-cast. The client seeds _rev from 89 // Date.now() (~1.75e12), which exceeds PHP_INT_MAX on 32-bit PHP where (int) 90 // saturates to 2147483647, collapsing stored and incoming to the same value so 91 // every write after the first is wrongly rejected as stale (review #8). On 32-bit 92 // JSON decodes such a value to a float; on 64-bit it stays an int. Comparing the 93 // raw numeric values is correct on both (PHP compares numerically) and preserves 94 // the stored int type on 64-bit. is_numeric() already validated them. 95 $stored_rev = ( isset( $raw['_rev'] ) && is_numeric( $raw['_rev'] ) ) ? $raw['_rev'] : null; 96 $incoming_rev = ( isset( $body['_rev'] ) && is_numeric( $body['_rev'] ) ) ? $body['_rev'] : null; 97 98 if ( null !== $incoming_rev ) { 99 // Drop exact-rev replays and older writes. 100 if ( null !== $stored_rev && $incoming_rev <= $stored_rev ) { 101 return rest_ensure_response( self::get_all() ); 102 } 103 } elseif ( null !== $stored_rev ) { 104 // Absent _rev applies ONLY before any _rev is stored (pre-_rev install / 105 // first write). Once a _rev exists, treat a missing _rev as stale — else a 106 // no-rev write would reopen the race (R8.2). 107 return rest_ensure_response( self::get_all() ); 108 } 109 60 110 $current = self::get_all(); 61 $body = $request->get_json_params();62 111 63 112 foreach ( self::SCHEMA as $key => $schema ) { … … 75 124 case 'integer': 76 125 if ( 'pageSize' === $key ) { 77 /** This filter is documented in includes/REST/Products_Controller.php */ 78 $max = (int) apply_filters( 'wcabe_g2_max_per_page', 100 ); 79 $current[ $key ] = max( 1, min( absint( $value ), $max ) ); 126 $current[ $key ] = Page_Size_Helper::clamp( $value ); 80 127 } 81 128 // else: no other integer fields defined. Future integer fields … … 102 149 } 103 150 break; 151 152 case 'filters': 153 if ( null === $value ) { 154 $current[ $key ] = null; 155 } elseif ( is_array( $value ) ) { 156 // Live frontend draft — already the current FilterValues shape, 157 // never a legacy bag shape, so no migration (mirrors columnState). 158 $current[ $key ] = View_Service::sanitize_filter_values( $value ); 159 } 160 break; 161 162 case 'nullable_string': 163 // Preserve null/'' as null (don't (string)-cast) so the load-precedence 164 // marker (activeViewId !== null) stays meaningful. 165 if ( null === $value || '' === $value ) { 166 $current[ $key ] = null; 167 } else { 168 $current[ $key ] = sanitize_text_field( (string) $value ); 169 } 170 break; 104 171 } 105 172 } 106 173 174 // Re-add the rev into the meta array so the $current = get_all() rebuild 175 // (SCHEMA-only) doesn't erase the stored _rev (R8.1). Newest rev wins. 176 if ( null !== $incoming_rev ) { 177 $current['_rev'] = max( $incoming_rev, $stored_rev ?? 0 ); 178 } 179 107 180 update_user_meta( get_current_user_id(), self::META_KEY, $current ); 108 181 109 return rest_ensure_response( $current ); 182 // Return SCHEMA-only settings (get_all strips _rev) so _rev never surfaces 183 // to the client as a setting (R8.1). 184 return rest_ensure_response( self::get_all() ); 185 } 186 187 /** 188 * The stored monotonic sync cursor (_rev), or 0.0 when none has been written yet. 189 * 190 * Localized to the client (window.wcabeG2.userSettingsRev) so it can seed its rev 191 * counter ABOVE the last persisted value. Without this the client seeds from a bare 192 * Date.now(); a backward wall-clock step between sessions (NTP correction, VM resume, 193 * wrong RTC, manual change) then makes every new write's _rev look stale, silently 194 * halting all settings persistence until real time catches up (review #4). This is 195 * transport, deliberately kept out of get_all()/UserSettings so _rev never surfaces 196 * as a setting. Returned as float for 32-bit safety (see update_settings, #8). 197 */ 198 public static function get_stored_rev(): float { 199 $raw = get_user_meta( get_current_user_id(), self::META_KEY, true ); 200 return ( is_array( $raw ) && isset( $raw['_rev'] ) && is_numeric( $raw['_rev'] ) ) ? (float) $raw['_rev'] : 0.0; 110 201 } 111 202 … … 126 217 } 127 218 128 // Defensive clamp on read. The schema and update_settings() already129 // enforce the bound on writes, but values written to user meta outside130 // this controller (legacy installs, future schema changes, direct DB131 // updates) could fall outside it. Same filter and bound as the write path.219 // Defensive clamp on read. update_settings() already enforces the bound on 220 // writes, but values written to user meta outside this controller (legacy 221 // installs, future schema changes, direct DB updates) or a site that has 222 // since LOWERED wcabe_g2_max_per_page could fall outside it. 132 223 if ( isset( $result['pageSize'] ) ) { 133 /** This filter is documented in includes/REST/Products_Controller.php */ 134 $max = (int) apply_filters( 'wcabe_g2_max_per_page', 100 ); 135 $result['pageSize'] = min( max( 1, (int) $result['pageSize'] ), $max ); 224 $result['pageSize'] = Page_Size_Helper::clamp( $result['pageSize'] ); 136 225 } 137 226 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Views_Controller.php
r3549389 r3663491 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 10 11 use WCABE_G2\Helpers\Permission_Helper; 11 12 use WCABE_G2\Services\View_Service; … … 38 39 ); 39 40 } 41 // Validate the SANITIZED value too — WP core runs validate_callback on the 42 // raw param before sanitize_callback, so a raw value like '<b></b>' passes 43 // the non-empty check above but sanitizes to '' and would otherwise persist 44 // a blank-named view. 45 if ( '' === trim( sanitize_text_field( $trimmed ) ) ) { 46 return new \WP_Error( 47 'rest_invalid_param', 48 __( 'View name cannot be empty.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 49 ); 50 } 40 51 return true; 41 52 } … … 67 78 'type' => 'array', 68 79 ], 80 // No minimum/maximum/validate_callback: a malformed page_size 81 // must not fail the whole view save. clamp() bounds any input, 82 // including non-scalars, without erroring. 83 'page_size' => [ 84 'required' => false, 85 'type' => 'integer', 86 'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ], 87 ], 69 88 'is_default' => [ 70 89 'required' => false, … … 100 119 'type' => 'array', 101 120 ], 121 'page_size' => [ 122 'required' => false, 123 'type' => 'integer', 124 'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ], 125 ], 102 126 'is_default' => [ 103 127 'required' => false, … … 144 168 'filters' => $request->get_param( 'filters' ), 145 169 'column_state' => $request->get_param( 'column_state' ), 170 'page_size' => $request->get_param( 'page_size' ), 146 171 'is_default' => $request->get_param( 'is_default' ), 147 172 ]; … … 181 206 $data['column_state'] = $request->get_param( 'column_state' ); 182 207 } 208 if ( null !== $request->get_param( 'page_size' ) ) { 209 $data['page_size'] = $request->get_param( 'page_size' ); 210 } 183 211 if ( null !== $request->get_param( 'is_default' ) ) { 184 212 $data['is_default'] = $request->get_param( 'is_default' ); -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Export_Service.php
r3549389 r3663491 8 8 namespace WCABE_G2\Services; 9 9 10 use WCABE_G2\Helpers\Product_Helper; 11 10 12 defined( 'ABSPATH' ) || exit; 11 13 … … 18 20 */ 19 21 protected array $pro_columns = []; 22 23 /** 24 * Lazily built slug => name map for the Shipping Class column. Null until the 25 * first shipping_class cell is formatted, so exports without that column 26 * never touch the taxonomy. 27 * 28 * @var array<string, string>|null 29 */ 30 protected ?array $shipping_class_names = null; 20 31 21 32 /** … … 44 55 'regular_price' => 'Regular Price', 45 56 'sale_price' => 'Sale Price', 57 'cogs_value' => 'Cost of Goods', 46 58 'stock_quantity' => 'Stock Qty', 47 59 'stock_status' => 'Stock Status', … … 58 70 'date_on_sale_from' => 'Sale From', 59 71 'date_on_sale_to' => 'Sale To', 72 'date_created' => 'Published on', 73 'date_modified' => 'Modified on', 74 'image_date' => 'Image uploaded', 60 75 'featured' => 'Featured', 61 76 'virtual' => 'Virtual', 62 77 'downloadable' => 'Downloadable', 63 78 'backorders' => 'Backorders', 79 'shipping_class' => 'Shipping Class', 80 'menu_order' => 'Menu Order', 81 'button_text' => 'Button Text', 82 'product_url' => 'Product URL', 83 'upsell_ids' => 'Upsells', 84 'cross_sell_ids' => 'Cross-sells', 64 85 ]; 65 86 66 87 /** 67 88 * Generate a CSV string from an array of formatted product data. 89 * 90 * One-shot entry point, kept for callers that already hold every row in memory. 91 * It is a thin composition over begin_csv() + write_rows() — the incremental 92 * primitives the export controller streams through — so both paths produce 93 * byte-identical output by construction. 68 94 * 69 95 * @param array $products Array of formatted product arrays. … … 72 98 * @param array $units Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ]. 73 99 * @return string CSV content. 100 * @throws \RuntimeException When the temporary stream cannot be opened, written, rewound or read. 74 101 */ 75 102 public function generate_csv( array $products, array $columns = [], bool $include_bom = true, array $units = [] ): string { 103 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory PHP stream, not a filesystem operation. Used as the standard fputcsv() handle pattern for building CSV in memory; WP_Filesystem does not apply to memory streams. 104 $handle = fopen( 'php://temp', 'r+' ); 105 106 if ( false === $handle ) { 107 throw new \RuntimeException( 'Unable to open a temporary stream for CSV generation.' ); 108 } 109 110 try { 111 $resolved = $this->begin_csv( $handle, $columns, $include_bom, $units ); 112 $this->write_rows( $handle, $products, $resolved ); 113 114 if ( ! rewind( $handle ) ) { 115 throw new \RuntimeException( 'Unable to rewind the temporary CSV stream.' ); 116 } 117 118 $csv = stream_get_contents( $handle ); 119 120 if ( false === $csv ) { 121 throw new \RuntimeException( 'Unable to read the temporary CSV stream.' ); 122 } 123 124 return $csv; 125 } finally { 126 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the in-memory php://temp stream opened above. In finally so a write failure mid-document never leaks the handle. 127 fclose( $handle ); 128 } 129 } 130 131 /** 132 * Open a CSV document on a stream: resolve the column set, apply the Pro 133 * extra-columns filter, and write the BOM + header row. 134 * 135 * Call EXACTLY ONCE per export, before any write_rows() call, and on the SAME 136 * instance those write_rows() calls use — this is what initializes 137 * $this->pro_columns, which format_cell_value() reads for every 138 * Pro-contributed cell. Calling it per chunk would repeat the BOM and header 139 * row mid-file; calling write_rows() on a different instance would silently 140 * blank every Pro column. 141 * 142 * @param resource $handle Open, writable stream. 143 * @param array $columns Columns to include (keys from EXPORTABLE_COLUMNS). Empty = all. 144 * @param bool $include_bom Prepend UTF-8 BOM for Excel compatibility. 145 * @param array $units Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ]. 146 * @return array Resolved column keys. Pass these to every write_rows() call. 147 * @throws \RuntimeException When a write fails (e.g. the temp stream's backing disk is full). 148 */ 149 public function begin_csv( $handle, array $columns = [], bool $include_bom = true, array $units = [] ): array { 76 150 /** 77 151 * Allow extensions (Pro) to declare additional export columns. … … 81 155 $this->pro_columns = apply_filters( 'wcabe_g2_export_extra_columns', [] ); 82 156 83 // Filter to valid exportable columns only. 157 $columns = $this->resolve_columns( $columns ); 158 159 if ( $include_bom ) { 160 $bom = "\xEF\xBB\xBF"; 161 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- writing to the caller's in-memory php://temp stream. 162 $written = fwrite( $handle, $bom ); 163 164 // Compare the exact byte count, not just !== false: a full stream can 165 // report a SHORT write, which would corrupt the BOM silently. 166 if ( strlen( $bom ) !== $written ) { 167 throw new \RuntimeException( 'Unable to write the CSV byte-order mark.' ); 168 } 169 } 170 171 $this->put_row( $handle, $this->build_headers( $columns, $units ) ); 172 173 return $columns; 174 } 175 176 /** 177 * Append a chunk of formatted product rows to an open CSV document. 178 * 179 * Callable any number of times after begin_csv(), which is what lets the 180 * export stream page-by-page instead of holding every formatted row in memory. 181 * 182 * @param resource $handle Open, writable stream previously passed to begin_csv(). 183 * @param array $products Formatted product arrays for this chunk. 184 * @param array $columns Resolved columns returned by begin_csv(). 185 * @throws \RuntimeException When a write fails. 186 */ 187 public function write_rows( $handle, array $products, array $columns ): void { 188 foreach ( $products as $product ) { 189 $row = []; 190 foreach ( $columns as $col ) { 191 $row[] = $this->format_cell_value( $product, $col ); 192 } 193 $this->put_row( $handle, $row ); 194 } 195 } 196 197 /** 198 * Filter a requested column list down to the ones this export can emit. 199 * 200 * Depends on $this->pro_columns, so it must run after the extra-columns 201 * filter has been applied in begin_csv(). 202 * 203 * @param array $columns Requested columns. Empty = all native exportable columns. 204 * @return array 205 */ 206 private function resolve_columns( array $columns ): array { 84 207 if ( ! empty( $columns ) ) { 85 208 $pro_columns = $this->pro_columns; … … 94 217 } 95 218 96 // Build header labels, adjusting for units. 219 return $columns; 220 } 221 222 /** 223 * Build the header labels for a resolved column list, adjusting for units. 224 * 225 * @param array $columns Resolved column keys. 226 * @param array $units Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ]. 227 * @return array 228 */ 229 private function build_headers( array $columns, array $units ): array { 97 230 $weight_unit = $units['weight_unit'] ?? ''; 98 231 $dimension_unit = $units['dimension_unit'] ?? ''; … … 121 254 } 122 255 123 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory PHP stream, not a filesystem operation. Used as the standard fputcsv() handle pattern for building CSV in memory; WP_Filesystem does not apply to memory streams. 124 $handle = fopen( 'php://temp', 'r+' ); 125 126 if ( $include_bom ) { 127 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- writing to in-memory php://temp stream from line 135. 128 fwrite( $handle, "\xEF\xBB\xBF" ); 129 } 130 131 fputcsv( $handle, $headers ); 132 133 foreach ( $products as $product ) { 134 $row = []; 135 foreach ( $columns as $col ) { 136 $row[] = $this->format_cell_value( $product, $col ); 137 } 138 fputcsv( $handle, $row ); 139 } 140 141 rewind( $handle ); 142 $csv = stream_get_contents( $handle ); 143 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing in-memory php://temp stream from line 135. 144 fclose( $handle ); 145 146 return $csv; 256 return $headers; 257 } 258 259 /** 260 * Write one CSV record, failing loudly. 261 * 262 * A silent truncation here would surface as a valid-looking CSV that is 263 * missing rows, so every write is checked and the controller converts the 264 * failure into a JSON error rather than a partial 200 response. 265 * 266 * The escape argument is explicit because the implicit default is deprecated 267 * from PHP 8.4. '\\' is the historical default and is kept deliberately — 268 * switching to RFC 4180's '' would change how existing exports escape. 269 * 270 * @param resource $handle Open, writable stream. 271 * @param array $row Field values. 272 * @throws \RuntimeException When the write fails. 273 */ 274 private function put_row( $handle, array $row ): void { 275 if ( false === fputcsv( $handle, $row, ',', '"', '\\' ) ) { 276 throw new \RuntimeException( 'Unable to write a CSV row.' ); 277 } 147 278 } 148 279 … … 191 322 return ''; 192 323 324 case 'upsell_ids': 325 case 'cross_sell_ids': 326 // Intentional: export is type-blind. The grid hides `cross_sell_ids` on 327 // grouped/external products (WooCommerce doesn't render Cross-sells there, so it 328 // isn't editable), but the stored meta still exists and CSV must reflect what's 329 // persisted — this is also the only surface that reveals orphan cross-sells on 330 // those types. Do not add a product-type guard here. 331 // Matches WooCommerce's own product CSV exporter: comma-separated, SKU where 332 // the product has one, `id:123` otherwise — so the output stays importable by 333 // WC's native importer. An explicit case is required: the default arm would 334 // (string)-cast the array to "Array" and emit a notice. 335 if ( ! is_array( $value ) ) { 336 return ''; 337 } 338 $refs = []; 339 foreach ( $value as $ref ) { 340 if ( ! is_array( $ref ) || empty( $ref['id'] ) ) { 341 continue; 342 } 343 // Drop DEAD refs (target no longer a product). WC's own exporter does the 344 // same — array_filter( array_map( 'wc_get_product', … ) ) — and for good 345 // reason: `id:N` for a non-existent N makes WC's importer CREATE an "Import 346 // placeholder" product (status "importing") when update_existing is off, so a 347 // G2-export → WC-import round trip would manufacture one junk product per dead 348 // ref. Preserving dead refs in STORAGE stays correct (normalize_product_refs 349 // keeps them, so a chip the user never touches survives); resolving them into 350 // product creation via an interchange format is the bug. Live refs stranded on 351 // a type that hides the field (cross-sells on grouped/external) are NOT missing 352 // and still export — the type-blindness above is unaffected. 353 if ( ! empty( $ref['missing'] ) ) { 354 continue; 355 } 356 $sku = isset( $ref['sku'] ) ? (string) $ref['sku'] : ''; 357 $refs[] = '' !== $sku ? $sku : 'id:' . (int) $ref['id']; 358 } 359 return implode( ', ', $refs ); 360 193 361 case 'manage_stock': 194 362 case 'featured': … … 203 371 return (string) $value; 204 372 373 case 'menu_order': 374 // Always an integer (default 0); 0 is a real position, so export it 375 // rather than blanking like stock_quantity. 376 return (string) (int) $value; 377 205 378 case 'description': 206 379 case 'short_description': 207 380 return wp_strip_all_tags( (string) $value ); 208 381 382 case 'shipping_class': 383 // The stored value is a slug; export the human-readable NAME, which 384 // is what the grid shows and what a spreadsheet user recognises. 385 // A slug whose term has since been deleted falls back to the slug 386 // rather than blanking — losing the value silently would be worse. 387 // '' stays '' (no class / same as parent). 388 $slug = (string) $value; 389 if ( '' === $slug ) { 390 return ''; 391 } 392 if ( null === $this->shipping_class_names ) { 393 $this->shipping_class_names = []; 394 foreach ( Product_Helper::get_shipping_class_terms() as $term ) { 395 $this->shipping_class_names[ $term['slug'] ] = $term['name']; 396 } 397 } 398 return $this->shipping_class_names[ $slug ] ?? $slug; 399 209 400 default: 210 401 return (string) $value; -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Filter_Service.php
r3549389 r3663491 8 8 namespace WCABE_G2\Services; 9 9 10 use WCABE_G2\Helpers\Product_Helper; 11 10 12 defined( 'ABSPATH' ) || exit; 11 13 12 14 class Filter_Service { 15 16 /** 17 * Fold a raw `shipping_class` REST param into a filters array. 18 * 19 * Shared by Products_Controller and Export_Controller so the two request 20 * paths cannot drift — a filter honoured by the grid but dropped by export 21 * is the classic failure here. 22 * 23 * Junk semantics: sanitize_title() maps '!!!' to '', so a naive array_map 24 * would yield [''] — a filter that looks active and matches nothing in a way 25 * nobody can see. Empties are dropped; if the param was PRESENT but nothing 26 * valid survived, the match-none flag is raised instead of leaving the filter 27 * absent (which would silently return the entire catalog). 28 * 29 * The filter callback checks strict '' equality, not array_filter()'s default 30 * truthiness — sanitize_title() can legally produce the string '0' (WP will 31 * generate that slug for a term named e.g. "0" or "0 items"), and a bare 32 * array_filter() call treats '0' as falsy and drops it same as a real empty. 33 * 34 * @param mixed $raw Raw request param (comma-separated slugs). 35 * @param array $filters Filters array to extend. 36 * @return array The filters array. 37 */ 38 public static function apply_shipping_class_param( $raw, array $filters ): array { 39 if ( null === $raw || '' === $raw ) { 40 return $filters; 41 } 42 43 $slugs = array_values( 44 array_filter( 45 array_map( 'sanitize_title', explode( ',', (string) $raw ) ), 46 static fn( string $slug ): bool => '' !== $slug 47 ) 48 ); 49 50 if ( empty( $slugs ) ) { 51 $filters['shipping_class_match_none'] = true; 52 return $filters; 53 } 54 55 $filters['shipping_class'] = $slugs; 56 return $filters; 57 } 13 58 14 59 /** … … 21 66 'categories' => $this->get_category_tree(), 22 67 'tags' => $this->get_tags(), 23 'types' => [ 'simple', 'variable', 'grouped', 'external' ],68 'types' => $this->get_product_type_options(), 24 69 'statuses' => [ 'publish', 'draft', 'pending', 'private' ], 25 70 'stock_statuses' => [ 'instock', 'outofstock', 'onbackorder' ], 71 // Same list the grid editor gets via window.wcabeG2, from one cached 72 // source, so the filter and the column can never disagree on the 73 // vocabulary. Deliberately no count — see get_shipping_class_terms(). 74 'shipping_classes' => Product_Helper::get_shipping_class_terms(), 26 75 ]; 27 76 28 77 return apply_filters( 'wcabe_g2_filter_options', $options ); 78 } 79 80 /** 81 * Product type filter options — the WooCommerce natives first, then every 82 * third-party registered type (e.g. 'bundle'), as value/label pairs (#137). 83 * Labels come from wc_get_product_types() so third-party types show their 84 * plugin-provided name; the frontend overrides the four native labels with 85 * its own short translations. 86 * 87 * @return array Array of [ 'value' => slug, 'label' => label ]. 88 */ 89 private function get_product_type_options(): array { 90 $registered = wc_get_product_types(); 91 $options = []; 92 93 foreach ( [ 'simple', 'variable', 'grouped', 'external' ] as $slug ) { 94 $options[] = [ 95 'value' => $slug, 96 'label' => (string) ( $registered[ $slug ] ?? ucfirst( $slug ) ), 97 ]; 98 unset( $registered[ $slug ] ); 99 } 100 101 foreach ( $registered as $slug => $label ) { 102 $options[] = [ 103 'value' => (string) $slug, 104 'label' => (string) $label, 105 ]; 106 } 107 108 return $options; 29 109 } 30 110 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Operations_Service.php
r3549389 r3663491 69 69 * Duplicate one or more products. 70 70 * 71 * `created_ids` is the authoritative record of what was written: every TOP-LEVEL duplicate 72 * that saved, in creation order (never the variations duplicate_variations() creates). 73 * `products` is the subset that could also be formatted for display, and may be SHORTER — 74 * see the phase-2 comment. Count creations off created_ids; render rows off products. 75 * 71 76 * @param int[] $product_ids Source product IDs. 72 77 * @param int $count Number of copies to create per source product. 73 * @return array { success: true, products: [ ... ], failed: [ ...] }78 * @return array { success: true, created_ids: int[], products: [ ... ], failed: [ ... ], warnings?: string[] } 74 79 */ 75 80 public function duplicate_products( array $product_ids, int $count = 1 ): array { … … 79 84 80 85 $products = []; 86 $fresh_products = []; 81 87 $failed = []; 82 88 $total_var_failures = 0; … … 84 90 if ( empty( $product_ids ) ) { 85 91 return [ 86 'success' => true, 87 'products' => [], 88 'failed' => [], 92 'success' => true, 93 'created_ids' => [], 94 'products' => [], 95 'failed' => [], 89 96 ]; 90 97 } … … 126 133 $duplicate->set_tag_ids( $source->get_tag_ids() ); 127 134 $duplicate->set_attributes( $source->get_attributes() ); 128 $duplicate->set_upsell_ids( $source->get_upsell_ids() ); 129 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids() ); 135 // 'edit' context: the view getters run through 136 // woocommerce_product_get_upsell_ids / _cross_sell_ids, which third-party 137 // code can filter. Copying a filtered projection would bake that plugin's 138 // display-time view permanently into the duplicate's stored data. 139 $duplicate->set_upsell_ids( $source->get_upsell_ids( 'edit' ) ); 140 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids( 'edit' ) ); 130 141 131 142 if ( $source->is_type( 'variable' ) ) { … … 144 155 } 145 156 146 // Re-read from DB to get full data (variation_count, etc.). 157 // Re-read from DB to get full data (variation_count, etc.). Formatting is 158 // deferred until every duplicate exists, so linked-product descriptors 159 // are resolved in one batch rather than per copy — a clone carries the 160 // source's upsells/cross-sells, so every duplicate has refs to resolve. 147 161 $fresh = wc_get_product( $duplicate->get_id() ); 148 162 if ( $fresh ) { 149 $ products[] = Product_Helper::format_product( $fresh );163 $fresh_products[] = $fresh; 150 164 } 151 165 } catch ( \Exception $e ) { … … 164 178 } 165 179 180 // Every duplicate above is already IN THE DATABASE. Formatting is presentation, so 181 // nothing below may throw out of this method: an uncaught exception becomes a 500 (no 182 // REST caller wraps us), the client reports "Failed to duplicate products", and the user 183 // retries — creating a SECOND set of copies. \Throwable, not \Exception: third-party 184 // callbacks on wcabe_g2_format_product raise Error/TypeError as readily as Exception. 185 // 186 // created_ids is the truthful record of what was WRITTEN. $products is only what could 187 // also be RENDERED, and may be shorter. Never conflate them: the client counts creations 188 // off created_ids and prepends rows off products. 189 // 190 // TOP-LEVEL ONLY. $fresh_products holds parent duplicates; the variations that 191 // duplicate_variations() saves are deliberately never collected here. Including them 192 // would inflate the "Duplicated N products" count and auto-select variation rows. 193 $created_ids = array_map( static fn( $p ) => $p->get_id(), $fresh_products ); 194 $format_failures = 0; 195 196 if ( ! empty( $fresh_products ) ) { 197 $ref_map = null; 198 199 try { 200 // The hook lets Free's attribute handler and Pro's taxonomy handler warm their 201 // caches before the loop, matching the contract get_products() and batch_update() 202 // follow. 203 do_action( 'wcabe_g2_before_format_products', $created_ids ); 204 205 $ref_map = Product_Helper::build_product_ref_map( $fresh_products ); 206 } catch ( \Throwable $e ) { 207 // null, NOT []. An empty map makes project_product_refs() mark every ref dead; 208 // null makes format_product() self-resolve per product — slower, but correct. 209 $ref_map = null; 210 } 211 212 foreach ( $fresh_products as $fresh ) { 213 try { 214 $products[] = Product_Helper::format_product( $fresh, $ref_map ); 215 } catch ( \Throwable $e ) { 216 // SKIP the row — never push null. useProductOperations' toast reducer does 217 // `'variation_count' in p`, which TypeErrors on a null element. The duplicate 218 // exists; created_ids still counts it, and the warning below tells the user 219 // to reload to see it. 220 $format_failures++; 221 } 222 } 223 } 224 166 225 $result = [ 167 'success' => true, 168 'products' => $products, 169 'failed' => $failed, 226 'success' => true, 227 'created_ids' => $created_ids, 228 'products' => $products, 229 'failed' => $failed, 170 230 ]; 171 231 232 $warnings = []; 233 172 234 if ( $total_var_failures > 0 ) { 173 $result['warnings'] = [ 174 sprintf( 175 /* translators: %d: number of failed variations */ 176 __( '%d variation(s) failed to duplicate.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 177 $total_var_failures 178 ), 179 ]; 235 $warnings[] = sprintf( 236 /* translators: %d: number of failed variations */ 237 __( '%d variation(s) failed to duplicate.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 238 $total_var_failures 239 ); 240 } 241 242 if ( $format_failures > 0 ) { 243 // Appended, never replacing — a run can hit both a variation failure and a format 244 // failure, and dropping either would misreport what happened. 245 $warnings[] = sprintf( 246 /* translators: %d: number of duplicates that were created but could not be displayed */ 247 __( '%d duplicate(s) were created but could not be displayed. Reload to see them.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 248 $format_failures 249 ); 250 } 251 252 if ( ! empty( $warnings ) ) { 253 $result['warnings'] = $warnings; 180 254 } 181 255 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Product_Service.php
r3549389 r3663491 8 8 namespace WCABE_G2\Services; 9 9 10 use WCABE_G2\Handlers\WP_Save_Hooks_Handler; 10 11 use WCABE_G2\Helpers\Product_Helper; 11 12 … … 13 14 14 15 class Product_Service { 16 17 /** Allowed Title-search match modes. */ 18 public const TITLE_SEARCH_MODES = [ 'phrase', 'all', 'any' ]; 19 20 /** Allowed Product URL filter match modes (mirrors ProductUrlOperator in TS). */ 21 public const PRODUCT_URL_OPERATORS = [ 'contains', 'not_contains', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty' ]; 22 23 /** 24 * Allowed SKU-search match modes (mirrors SkuSearchMode in TS). 25 * 26 * The empty string is deliberately NOT a member: it is the "auto" state, 27 * which routes to the legacy branch (comma-separated => exact IN, single 28 * term => substring LIKE). Keeping it out of the list means every 29 * `in_array( $mode, self::SKU_SEARCH_MODES, true )` gate treats auto and 30 * garbage identically — both fall back to legacy. 31 */ 32 public const SKU_SEARCH_MODES = [ 'contains', 'is_exactly', 'not_contains', 'starts_with', 'ends_with' ]; 33 34 /** 35 * Cap on the number of comma-separated tokens a SKU search term expands to. 36 * 37 * Unlike title search (10 words, a typo-tolerance affordance), pasting a 38 * large SKU list is a genuine bulk-edit workflow, so this is generous. It 39 * still exists because each token becomes its own LIKE/NOT LIKE clause, 40 * doubled again for the variation subquery when include_variations is on — 41 * an unbounded paste is a self-inflicted slow-query / DB-error risk on an 42 * otherwise `manage_woocommerce`-gated endpoint. 43 */ 44 public const SKU_SEARCH_MAX_TOKENS = 200; 45 46 /** 47 * Whether a Product URL operator takes no value. 48 * 49 * These stay effective with an empty value, unlike every other text filter — 50 * callers must not gate them behind a truthiness check on the value. 51 * 52 * @param string $operator 53 * @return bool 54 */ 55 public static function is_product_url_empty_check( string $operator ): bool { 56 return 'is_empty' === $operator || 'is_not_empty' === $operator; 57 } 58 59 /** 60 * Whether the Product URL filter is effective for a given operator/value pair. 61 * 62 * @param string $operator 63 * @param string $value 64 * @return bool 65 */ 66 public static function product_url_filter_is_effective( string $operator, string $value ): bool { 67 return '' !== trim( $value ) || self::is_product_url_empty_check( $operator ); 68 } 69 70 /** 71 * Normalise a raw search_sku_mode filter value to a known mode or '' (auto). 72 * 73 * Deliberately untyped: a direct service caller can put anything in the 74 * filter bag (an array from a malformed request shape, null, an int), and a 75 * `string` type hint would raise a TypeError before this could fall back. 76 * Every consumer — the SQL builder AND the per-variation matching mirrors — 77 * routes through here, so an invalid mode can never make the SQL take the 78 * legacy branch while variation narrowing takes a different one. 79 * 80 * @param mixed $mode Raw filter value. 81 * @return string One of self::SKU_SEARCH_MODES, or '' for auto/legacy. 82 */ 83 public static function normalize_sku_mode( $mode ): string { 84 return is_string( $mode ) && in_array( $mode, self::SKU_SEARCH_MODES, true ) ? $mode : ''; 85 } 86 87 /** 88 * Split a search_sku term into its comma-separated tokens. 89 * 90 * Filters on `'' !== $token` rather than array_filter()'s default 91 * truthiness: "0" is a valid WooCommerce SKU and must survive. 92 * 93 * Truncated to self::SKU_SEARCH_MAX_TOKENS. The cap lives HERE — not in the 94 * SQL builder alone — because this is the one function both the SQL builder 95 * and the PHP variation-narrowing mirror call to get their token list. A cap 96 * applied only on the SQL side would silently reopen the exact SQL/mirror 97 * divergence match modes were built to close: the query would ignore 98 * tokens past the limit while the mirror kept honouring all of them. 99 * 100 * @param string $term Raw (already sanitized) search_sku value. 101 * @return string[] Zero or more non-empty tokens, re-indexed, capped. 102 */ 103 private static function split_sku_term( string $term ): array { 104 $tokens = array_map( 'sanitize_text_field', array_map( 'trim', explode( ',', $term ) ) ); 105 $tokens = array_values( 106 array_filter( 107 $tokens, 108 static fn( string $token ): bool => '' !== $token 109 ) 110 ); 111 return array_slice( $tokens, 0, self::SKU_SEARCH_MAX_TOKENS ); 112 } 113 114 /** Signed 32-bit bounds for wp_posts.menu_order (a MySQL signed INT). */ 115 public const MENU_ORDER_MIN = -2147483648; 116 public const MENU_ORDER_MAX = 2147483647; 117 118 /** 119 * Private WP_Query marker scoping search_products()'s posts_join / posts_where callbacks 120 * to our own query. Without it those filters would apply to every WP_Query alive while 121 * they are registered — see the comment at the callbacks. 122 */ 123 private const REF_SEARCH_QUERY_VAR = 'wcabe_ref_search'; 124 125 /** 126 * Private WP_Query marker scoping get_products()'s Product URL posts_join / posts_where 127 * callbacks to our own query. Same rationale and shape as REF_SEARCH_QUERY_VAR above — 128 * see the comment at those callbacks. 129 */ 130 private const PRODUCT_URL_QUERY_VAR = 'wcabe_product_url_filter'; 15 131 16 132 /** … … 20 136 'simple' => [ 21 137 'name', 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 138 'cogs_value', 139 'date_created', 22 140 'categories', 'tags', 'description', 'short_description', 23 'weight', 'length', 'width', 'height', 141 'weight', 'length', 'width', 'height', 'shipping_class', 24 142 'date_on_sale_from', 'date_on_sale_to', 25 143 'featured', 'virtual', 'downloadable', 26 144 'manage_stock', 'backorders', 27 145 'image_id', 'gallery_image_ids', 146 'upsell_ids', 'cross_sell_ids', 147 'menu_order', 28 148 ], 29 149 'variable' => [ 30 150 'name', 'sku', 'status', 'catalog_visibility', 151 'cogs_value', 152 'date_created', 31 153 'categories', 'tags', 'description', 'short_description', 32 'weight', 'length', 'width', 'height', 154 'weight', 'length', 'width', 'height', 'shipping_class', 33 155 'featured', 34 156 'manage_stock', 'stock_quantity', 'stock_status', 'backorders', 35 157 'image_id', 'gallery_image_ids', 158 'upsell_ids', 'cross_sell_ids', 159 'menu_order', 36 160 ], 37 161 'variation' => [ 38 162 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 163 'cogs_value', 39 164 'description', 40 'weight', 'length', 'width', 'height', 165 'weight', 'length', 'width', 'height', 'shipping_class', 41 166 'date_on_sale_from', 'date_on_sale_to', 42 167 'virtual', 'downloadable', 43 168 'manage_stock', 'backorders', 44 169 'image_id', 170 'menu_order', 45 171 ], 46 172 'grouped' => [ 47 173 'name', 'sku', 'status', 'catalog_visibility', 174 'date_created', 48 175 'categories', 'tags', 'description', 'short_description', 49 176 'featured', 50 177 'image_id', 'gallery_image_ids', 178 // Upsells only. WooCommerce hides Cross-sells on grouped and external products 179 // (hide_if_grouped hide_if_external in its Linked Products metabox): cross-sells are 180 // promoted in the CART, and neither type is ever added to the cart. Writing them 181 // would persist meta that nothing ever renders. Upsells have no such restriction. 182 'upsell_ids', 183 'menu_order', 51 184 ], 52 185 'external' => [ 53 186 'name', 'sku', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility', 187 'cogs_value', 188 'date_created', 54 189 'categories', 'tags', 'description', 'short_description', 55 190 'weight', 'length', 'width', 'height', … … 57 192 'featured', 'virtual', 'downloadable', 58 193 'image_id', 'gallery_image_ids', 194 // Upsells only — see the grouped note above. 195 'upsell_ids', 196 'menu_order', 197 // External-only: the add-to-cart button label (WC_Product_External). 198 'button_text', 199 // External-only: the affiliate/destination link (WC_Product_External). 200 'product_url', 59 201 ], 202 // WooCommerce Product Bundles (third-party type, #137). The 'simple' list 203 // minus virtual/downloadable — Bundles derives those from its own 204 // "assembled/unassembled" settings, so direct writes would fight the plugin. 205 // Prices and stock are valid: static-priced bundles store them natively. 206 'bundle' => [ 207 'name', 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 208 'cogs_value', 209 'date_created', 210 'categories', 'tags', 'description', 'short_description', 211 'weight', 'length', 'width', 'height', 'shipping_class', 212 'date_on_sale_from', 'date_on_sale_to', 213 'featured', 214 'manage_stock', 'backorders', 215 'image_id', 'gallery_image_ids', 216 'upsell_ids', 'cross_sell_ids', 217 'menu_order', 218 ], 219 ]; 220 221 /** 222 * Conservative allowlist for product types with no curated ALLOWED_FIELDS 223 * entry (third-party types such as 'subscription', 'composite', 'booking'). 224 * Type-agnostic post/taxonomy-level fields only — no prices, stock, or 225 * virtual/downloadable, because third-party types commonly derive those 226 * (e.g. Subscriptions syncs price from its own subscription fields). 227 * Cross-sells excluded for the same reason as grouped/external: we cannot 228 * know the type is cart-addable. Mirrored in the TS DEFAULT_EDITABLE_FIELDS 229 * (editable-fields.ts) — keep both in lockstep. 230 */ 231 private const DEFAULT_ALLOWED_FIELDS = [ 232 'name', 'sku', 'status', 'catalog_visibility', 233 'date_created', 234 'categories', 'tags', 'description', 'short_description', 235 'weight', 'length', 'width', 'height', 'shipping_class', 236 'featured', 237 'image_id', 'gallery_image_ids', 238 'upsell_ids', 239 'menu_order', 60 240 ]; 61 241 … … 84 264 85 265 /** 266 * All queryable product types: the WooCommerce natives plus every type a 267 * third-party plugin has registered via the product-type selector (e.g. 268 * 'bundle', 'subscription', 'composite'). The natives are merged in 269 * explicitly so a plugin that filters its own type out of 270 * wc_get_product_types() cannot hide native products from the grid. 271 * 272 * @return string[] Product type slugs. 273 */ 274 public static function get_known_product_types(): array { 275 return array_values( array_unique( array_merge( 276 [ 'simple', 'variable', 'grouped', 'external' ], 277 array_map( 'strval', array_keys( wc_get_product_types() ) ) 278 ) ) ); 279 } 280 281 /** 282 * Variable-capable product types: 'variable' plus every registered type 283 * whose product class extends WC_Product_Variable (e.g. WC Subscriptions' 284 * 'variable-subscription'). These are the types whose rows carry variations 285 * the grid can count, interleave, and expand (#137 follow-up). 286 * 287 * Object-context checks use `instanceof \WC_Product_Variable` directly; 288 * this list serves the query paths (tax_query terms, SQL clauses) that run 289 * before any product object exists. Both derive from the same truth — the 290 * registered product class. 291 * 292 * Deliberately uncached: wc_get_product_types() is a filter call and 293 * get_product_classname() is cheap, and callers outside per-row loops keep 294 * the call count small. 295 * 296 * @return string[] Product type slugs. 297 */ 298 public static function get_variable_product_types(): array { 299 $types = [ 'variable' ]; 300 301 foreach ( array_keys( wc_get_product_types() ) as $slug ) { 302 $classname = \WC_Product_Factory::get_product_classname( 0, (string) $slug ); 303 if ( is_a( $classname, \WC_Product_Variable::class, true ) ) { 304 $types[] = (string) $slug; 305 } 306 } 307 308 return array_values( array_unique( $types ) ); 309 } 310 311 /** 86 312 * Get paginated products. 87 313 * 88 * @param int $page 89 * @param int $per_page 90 * @param bool $include_variations 91 * @param array $filters Optional filter criteria. 314 * @param int $page 315 * @param int $per_page 316 * @param bool $include_variations 317 * @param array $filters Optional filter criteria. 318 * @param string $orderby Sort column: 'id' (default) or 'menu_order'. 319 * @param string $order Sort direction: 'asc' or 'desc' (default). 92 320 * @return array 93 321 */ … … 96 324 int $per_page = 100, 97 325 bool $include_variations = false, 98 array $filters = [] 326 array $filters = [], 327 string $orderby = 'id', 328 string $order = 'desc' 99 329 ): array { 100 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations );330 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations, false, $orderby, $order ); 101 331 102 332 // Allow Pro to prime caches before per-product formatting. … … 104 334 do_action( 'wcabe_g2_before_format_products', $product_ids ); 105 335 106 // Prime the post cache for all gallery attachment IDs in a single query 107 // so that wp_get_attachment_image_url() inside format_product() hits the 108 // object cache instead of making individual DB queries (N+1 prevention). 109 $gallery_ids_to_prime = []; 336 // Prime the post cache for every attachment ID referenced on this page — the 337 // gallery IDs and each product's featured image — in a single query, so that 338 // wp_get_attachment_image_url() and get_image_upload_date() inside 339 // format_product() hit the object cache instead of making individual DB 340 // queries (N+1 prevention). 341 $attachment_ids_to_prime = []; 110 342 foreach ( $query_result['products'] as $product ) { 111 343 $gids = $product->get_gallery_image_ids(); 112 344 if ( ! empty( $gids ) ) { 113 array_push( $gallery_ids_to_prime, ...$gids ); 114 } 115 } 116 if ( ! empty( $gallery_ids_to_prime ) ) { 117 _prime_post_caches( array_unique( $gallery_ids_to_prime ), false, false ); 118 } 119 120 $formatted = array_map( [ Product_Helper::class, 'format_product' ], $query_result['products'] ); 345 array_push( $attachment_ids_to_prime, ...$gids ); 346 } 347 // Both contexts: view feeds wp_get_attachment_image_url() (thumbnail_url), 348 // edit/raw feeds get_image_upload_date() (image_date). They differ only 349 // when a plugin hooks woocommerce_product_get_image_id; array_unique 350 // below collapses them on every other site. 351 $featured_id = (int) $product->get_image_id(); 352 if ( $featured_id > 0 ) { 353 $attachment_ids_to_prime[] = $featured_id; 354 } 355 $raw_featured_id = (int) $product->get_image_id( 'edit' ); 356 if ( $raw_featured_id > 0 && $raw_featured_id !== $featured_id ) { 357 $attachment_ids_to_prime[] = $raw_featured_id; 358 } 359 } 360 if ( ! empty( $attachment_ids_to_prime ) ) { 361 _prime_post_caches( array_unique( $attachment_ids_to_prime ), false, false ); 362 } 363 364 // Batch-resolve every upsell/cross-sell ref on the page in one pass. Returns [] 365 // (and primes nothing) when no product on the page has refs, so stores that don't 366 // use these fields pay nothing. Must be passed into format_product() explicitly — 367 // array_map() would only pass one arg, and the null fallback would resolve each 368 // product's refs individually, i.e. the N+1 this exists to prevent. 369 $ref_map = Product_Helper::build_product_ref_map( $query_result['products'] ); 370 371 $formatted = array_map( 372 static fn( $product ) => Product_Helper::format_product( $product, $ref_map ), 373 $query_result['products'] 374 ); 121 375 122 376 // Interleave variations if requested. … … 128 382 $variation_ids_to_prime = []; 129 383 foreach ( $query_result['products'] as $product ) { 130 if ( $product->is_type( 'variable' ) ) { 384 // instanceof, not is_type('variable'): third-party variable 385 // subclasses (variable-subscription, …) carry variations too (#137). 386 if ( $product instanceof \WC_Product_Variable ) { 131 387 $children = $product->get_children(); 132 388 $children_map[ $product->get_id() ] = $children; … … 198 454 } 199 455 200 if ( ! $product->is_type( 'variable' ) ) { 456 // instanceof, not is_type('variable'): accepts third-party variable 457 // subclasses (variable-subscription, …) whose children are real 458 // variations (#137). 459 if ( ! $product instanceof \WC_Product_Variable ) { 201 460 return new \WP_Error( 202 461 'not_variable', … … 236 495 237 496 /** 497 * Default page size for stream_products() when the caller doesn't specify one. 498 * 499 * The variation-mode value is deliberately smaller. Page size bounds PARENTS, 500 * not emitted rows: get_products() primes and formats every variation of a 501 * page's parents before the stream can slice, so a variation-heavy page is the 502 * export's residual memory spike. 25 parents reduces the typical spike (a single 503 * parent with hundreds of variations can still exceed it). 504 */ 505 private const STREAM_PAGE_SIZE = 100; 506 private const STREAM_PAGE_SIZE_VARIATIONS = 25; 507 508 /** 509 * Page through filtered products, handing each formatted page to $consumer. 510 * 511 * The export path's collection seam. Nothing accumulates here — each page is 512 * released before the next query runs — which is what keeps a 10k-row export 513 * inside a modest memory_limit. Lives in the service (not the controller) 514 * because Export_Controller::export() terminates with exit() and so cannot be 515 * reached from PHPUnit; this method is the covered half. 516 * 517 * $consumer is called once per NON-EMPTY page. Cache releases run for every 518 * page regardless, including pages that yielded nothing and pages whose 519 * collection or consumption threw. 520 * 521 * @param array $filters Sanitized filters. 522 * @param bool $include_variations Interleave variations under their parents. 523 * @param int $max_rows Hard cap on total rows emitted. 524 * @param callable $consumer fn( array $formatted_rows ): void 525 * @param int|null $per_page Page size. Null = STREAM_PAGE_SIZE(_VARIATIONS). 526 * @return int Total rows emitted (0..$max_rows). 527 */ 528 public function stream_products( 529 array $filters, 530 bool $include_variations, 531 int $max_rows, 532 callable $consumer, 533 ?int $per_page = null 534 ): int { 535 if ( $max_rows <= 0 ) { 536 return 0; 537 } 538 539 if ( null === $per_page ) { 540 $per_page = $include_variations ? self::STREAM_PAGE_SIZE_VARIATIONS : self::STREAM_PAGE_SIZE; 541 } 542 543 // The export never reads total_with_variations, but get_products() would run 544 // an extra COUNT for it on EVERY page. Suppress it for the duration of the 545 // loop. A unique closure, not __return_false: removing the shared string 546 // callback could unhook a registration someone else made. 547 $suppress_variation_total = static fn() => false; 548 add_filter( 'wcabe_g2_compute_variation_total', $suppress_variation_total, 999 ); 549 550 $count = 0; 551 $page = 1; 552 553 try { 554 do { 555 // Collection lives INSIDE the try: get_products() primes caches and runs 556 // third-party format filters (Pro's ACF reads, for one), so a throw 557 // during collection must still release what it allocated. 558 try { 559 $result = $this->get_products( $page, $per_page, $include_variations, $filters ); 560 $rows = $result['products']; 561 $total_pages = (int) $result['total_pages']; 562 563 // Detach the page from $result before the next query, or two pages 564 // would be live at once. 565 unset( $result ); 566 567 // Interleaved variations mean a page can overshoot the budget 568 // mid-page. Slicing per page is equivalent to the old 569 // accumulate-then-slice: slicing a concatenation at N is slicing the 570 // last contributing page. 571 $remaining = $max_rows - $count; 572 if ( count( $rows ) > $remaining ) { 573 $rows = array_slice( $rows, 0, $remaining ); 574 } 575 576 $count += count( $rows ); 577 578 if ( ! empty( $rows ) ) { 579 $consumer( $rows ); 580 } 581 } finally { 582 unset( $rows ); 583 Product_Helper::flush_runtime_cache(); 584 585 /** 586 * Fires after each export chunk has been consumed and released. 587 * 588 * Extensions (Pro) use this to drop their own request-local caches — 589 * ACF, for instance, retains every field value it reads for the whole 590 * request, which would otherwise grow with rows × fields. 591 */ 592 do_action( 'wcabe_g2_export_after_chunk' ); 593 } 594 595 $page++; 596 } while ( $count < $max_rows && $page <= $total_pages ); 597 } finally { 598 remove_filter( 'wcabe_g2_compute_variation_total', $suppress_variation_total, 999 ); 599 } 600 601 return $count; 602 } 603 604 /** 605 * Chunked variant of get_products_by_ids() — the "export selected rows" stream. 606 * 607 * Same contract as get_products_by_ids() (normalise, clamp BEFORE any read, drop 608 * non-products, prime once per batch), but only one chunk of WC_Product objects 609 * and formatted arrays is live at a time. 610 * 611 * One intentional difference: the shared ref map and the 612 * wcabe_g2_before_format_products batch span a chunk rather than the whole 613 * selection. Output is identical; selections over $chunk_size pay slightly more 614 * priming queries in exchange for bounded memory. 615 * 616 * @param int[] $ids 617 * @param int $max_rows Hard cap on total rows emitted. 618 * @param callable $consumer fn( array $formatted_rows ): void 619 * @param int $chunk_size Ids per batch. 620 * @return int Total rows emitted (0..$max_rows). 621 */ 622 public function stream_products_by_ids( 623 array $ids, 624 int $max_rows, 625 callable $consumer, 626 int $chunk_size = 100 627 ): int { 628 if ( $max_rows <= 0 ) { 629 return 0; 630 } 631 632 // Clamp the NORMALISED list before any read, so a 50k-id request cannot fan out. 633 $ids = Product_Helper::normalize_product_ids( $ids, $max_rows ); 634 635 if ( empty( $ids ) ) { 636 return 0; 637 } 638 639 $count = 0; 640 641 foreach ( array_chunk( $ids, max( 1, $chunk_size ) ) as $chunk ) { 642 // Collection inside the try for the same reason as stream_products(): 643 // it primes caches and runs format filters before it can return. 644 try { 645 $rows = $this->get_products_by_ids( $chunk, count( $chunk ) ); 646 $count += count( $rows ); 647 648 // A chunk of only dead ids yields nothing — but it still primed post 649 // caches, so the release in finally still has work to do. 650 if ( ! empty( $rows ) ) { 651 $consumer( $rows ); 652 } 653 } finally { 654 unset( $rows ); 655 Product_Helper::flush_runtime_cache(); 656 657 /** This filter is documented in Product_Service::stream_products(). */ 658 do_action( 'wcabe_g2_export_after_chunk' ); 659 } 660 } 661 662 return $count; 663 } 664 665 /** 666 * Format an explicit list of product/variation IDs — the "export selected rows" path. 667 * 668 * Lives here, not in Export_Controller, for two reasons. It is product-collection logic, 669 * which is a service concern (the controller does route parsing and response shaping). 670 * And Export_Controller::export() terminates with exit(), so anything inlined there is 671 * unreachable from PHPUnit — this seam is what makes the selected-rows export testable. 672 * 673 * Contract, in order: 674 * 1. Normalise to positive ints, dedupe (FIRST occurrence wins), preserve caller order. 675 * intval, not absint — absint( -7 ) is 7, which would export an unrelated product. 676 * 2. Clamp the NORMALISED list to $limit, before any read, so a 50k-id request cannot 677 * fan out and so duplicates don't eat the budget. 678 * 3. Drop ids that are not a product or variation (deleted, or never one). 679 * 4. Prime once, then format once against ONE shared ref map — never per row. 680 * 681 * @param int[] $ids 682 * @param int $limit Max rows to return. 683 * @return array[] Formatted product/variation arrays, in the caller's id order. 684 */ 685 public function get_products_by_ids( array $ids, int $limit ): array { 686 if ( $limit <= 0 ) { 687 return []; 688 } 689 690 $source_ids = Product_Helper::prime_product_caches( $ids, $limit ); 691 692 if ( empty( $source_ids ) ) { 693 return []; 694 } 695 696 $wc_products = []; 697 foreach ( $source_ids as $id ) { 698 $wc_product = wc_get_product( $id ); 699 if ( $wc_product ) { 700 $wc_products[] = $wc_product; 701 } 702 } 703 704 if ( empty( $wc_products ) ) { 705 return []; 706 } 707 708 // Same contract as get_products() and batch_update(): let Free's attribute handler and 709 // Pro's taxonomy handler warm their caches before the formatting loop. 710 do_action( 'wcabe_g2_before_format_products', array_map( static fn( $p ) => $p->get_id(), $wc_products ) ); 711 712 // ONE ref map for the whole selection. Formatting per product with no map would resolve 713 // each row's refs individually — the N+1 this exists to prevent, up to $limit rows deep. 714 $ref_map = Product_Helper::build_product_ref_map( $wc_products ); 715 716 $products = []; 717 foreach ( $wc_products as $wc_product ) { 718 $products[] = $wc_product->is_type( 'variation' ) 719 ? Product_Helper::format_variation( $wc_product ) 720 : Product_Helper::format_product( $wc_product, $ref_map ); 721 } 722 723 return $products; 724 } 725 726 /** 727 * Search products and variations for the linked-product picker. 728 * 729 * Deliberately NOT WC_Data_Store::search_products(). Core's query has three properties 730 * that are wrong for a picker: 731 * 732 * 1. It matches post_content and post_excerpt, and applies its LIMIT in SQL (ordered by 733 * post_parent, post_title). A term appearing in a hundred product descriptions 734 * therefore truncates the candidate set BEFORE any post-filter can run — the real 735 * title/SKU match is never returned at all. Recall loss no downstream filter can fix. 736 * 2. It appends a numeric-ID hit AFTER its SQL exclusion and status filtering, with no 737 * exclude check and no status check — so a numeric term defeats $exclude (a product 738 * could offer itself as its own upsell) and resurrects drafts. 739 * 3. It surfaces the PARENTS of matching variations; a picker needs the variation itself. 740 * 741 * This searches exactly the three fields a picker should search — post_title, sku and 742 * global_unique_id (GTIN/EAN, which G1 never had) — so content-only matches cannot enter 743 * the candidate set by construction, and applies exclusion, post type and status 744 * uniformly, including to the numeric pin. 745 * 746 * Variations are included as selectable targets, matching WooCommerce core's own Linked 747 * Products metabox. 748 * 749 * @param string $term Search term (title, SKU, GTIN, or a bare product ID). 750 * @param int[] $exclude Product IDs to omit (e.g. the product being edited). 751 * @param int $limit Max results (clamped to 1..50). 752 * @return array[] Ordered ref descriptors: [ id, name, sku, type, status, missing ]. 753 */ 754 public function search_products( string $term, array $exclude = [], int $limit = 30 ): array { 755 global $wpdb; 756 757 $term = trim( $term ); 758 if ( '' === $term ) { 759 return []; 760 } 761 762 $exclude = array_values( array_filter( array_map( 'intval', $exclude ), static fn( $id ) => $id > 0 ) ); 763 $limit = max( 1, min( $limit, 50 ) ); 764 765 // Same status vocabulary WooCommerce uses for its own product search: private 766 // products are visible only to users who may edit them. 767 $post_statuses = current_user_can( 'edit_private_products' ) 768 ? [ 'private', 'publish' ] 769 : [ 'publish' ]; 770 771 $like = '%' . $wpdb->esc_like( $term ) . '%'; 772 $name_clause = $this->build_name_search_clause( $wpdb, $term, 'phrase' ); 773 774 // Both callbacks take the WP_Query and bail unless it carries our private marker. 775 // posts_join / posts_where fire on EVERY WP_Query for as long as they are registered — 776 // including any a third-party pre_get_posts listener spins up inside ours. Unscoped, 777 // those queries would inherit our WHERE; and because the JOIN is added only once (the 778 // strpos guard), a nested query could even receive the wcabe_ref_lookup WHERE WITHOUT 779 // the JOIN and hard-fail on an unknown column. Same guard shape as 780 // Pro_Revision_Filter_Handler::filter_post_mime_where(). 781 $join_callback = static function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 782 if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) { 783 return $join; 784 } 785 if ( false === strpos( $join, 'wcabe_ref_lookup' ) ) { 786 $join .= " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS wcabe_ref_lookup ON {$wpdb->posts}.ID = wcabe_ref_lookup.product_id "; 787 } 788 return $join; 789 }; 790 791 $where_callback = static function ( string $where, \WP_Query $query ) use ( $wpdb, $name_clause, $like ): string { 792 if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) { 793 return $where; 794 } 795 $sku_clause = $wpdb->prepare( 796 '( wcabe_ref_lookup.sku LIKE %s OR wcabe_ref_lookup.global_unique_id LIKE %s )', 797 $like, 798 $like 799 ); 800 // Title OR SKU OR GTIN. No post_content, no post_excerpt — that is the point. 801 return $where . " AND ( {$name_clause} OR {$sku_clause} ) "; 802 }; 803 804 add_filter( 'posts_join', $join_callback, 10, 2 ); 805 add_filter( 'posts_where', $where_callback, 10, 2 ); 806 807 try { 808 $query = new \WP_Query( [ 809 'post_type' => [ 'product', 'product_variation' ], 810 'post_status' => $post_statuses, 811 'post__not_in' => $exclude, 812 'posts_per_page' => $limit, 813 'fields' => 'ids', 814 'orderby' => 'title', 815 'order' => 'ASC', 816 // No total is consumed, so skip SQL_CALC_FOUND_ROWS. 817 'no_found_rows' => true, 818 'ignore_sticky_posts' => true, 819 // resolve_product_ref_ids() primes exactly the caches it needs. 820 'update_post_meta_cache' => false, 821 'update_post_term_cache' => false, 822 // The marker the two callbacks above key off. Private to us. 823 self::REF_SEARCH_QUERY_VAR => true, 824 ] ); 825 $ids = array_map( 'intval', (array) $query->posts ); 826 } finally { 827 remove_filter( 'posts_join', $join_callback, 10 ); 828 remove_filter( 'posts_where', $where_callback, 10 ); 829 } 830 831 // Numeric ID pin — our ID search; core does not offer one. 832 // 833 // ctype_digit(), NOT is_numeric(). is_numeric() also accepts '1.5', '1e3', '+12' and 834 // '-7', every one of which is a legitimate SKU to search for — and absint('1e3') is 835 // 1000, so searching for a SKU literally named "1e3" would pin an unrelated product 836 // #1000 into the results. Core's search_products() has exactly this bug. 837 // 838 // Exclusion, post type and status are applied HERE, to the pin itself. Core appends 839 // its numeric hit after those filters, which is precisely how a numeric term ends up 840 // defeating $exclude and resurrecting drafts. 841 if ( ctype_digit( $term ) ) { 842 $pin_id = absint( $term ); 843 if ( $pin_id > 0 && ! in_array( $pin_id, $exclude, true ) ) { 844 $pin_post = get_post( $pin_id ); 845 if ( 846 $pin_post 847 && in_array( $pin_post->post_type, [ 'product', 'product_variation' ], true ) 848 && in_array( $pin_post->post_status, $post_statuses, true ) 849 ) { 850 array_unshift( $ids, $pin_id ); 851 // array_unique keeps the FIRST occurrence, so the pin stays on top. 852 $ids = array_values( array_unique( $ids ) ); 853 } 854 } 855 } 856 857 $ids = array_slice( $ids, 0, $limit ); 858 859 if ( empty( $ids ) ) { 860 return []; 861 } 862 863 // Resolve the search HITS themselves. resolve_product_ref_ids(), never 864 // build_product_ref_map() — the latter takes owners and would hand back each hit's 865 // upsells instead of the hit. 866 $map = Product_Helper::resolve_product_ref_ids( $ids ); 867 868 return array_values( array_filter( array_map( 869 static fn( int $id ) => $map[ $id ] ?? null, 870 $ids 871 ) ) ); 872 } 873 874 /** 238 875 * Batch update products. 239 876 * 240 * @param array $items Array of [ 'id' => int, 'changes' => array ]. 877 * @param array $items Array of [ 'id' => int, 'changes' => array ]. 878 * @param array $context Optional per-save context threaded through to the 879 * `wcabe_g2_before_product_save` hook. Shape: [ 'batch_id' => ?string ]. 880 * Free reads nothing from it; extensions may. 241 881 * @return array Array of per-item results. 242 */ 243 public function batch_update( array $items ): array { 882 * 883 * Each item runs one "save cycle", identified by an opaque $save_token threaded 884 * through both the before- and after-save seams, so listeners can correlate the two 885 * halves of ONE cycle even when cycles nest — a save_post listener woken by the 886 * after-save seam can itself call batch_update() on the SAME product id, so post id 887 * alone does not identify a cycle. WP_Save_Hooks_Handler::finish_cycle() in the 888 * finally below is the guaranteed teardown for whatever the before-seam armed. 889 */ 890 public function batch_update( array $items, array $context = [] ): array { 244 891 $results = []; 892 // Successful saves, keyed by their index in $results. Formatting is deferred to 893 // phase 2 (after the loop) so linked-product descriptors are built once, and built 894 // fresh — see the comment there. 895 $saved = []; 896 897 // Batch-prime post, term, and meta caches for every target id in one pass so the 898 // per-item wc_get_product() below — and any listener on wcabe_g2_before_product_save 899 // that re-reads the product (e.g. Operation History's pre/post-image capture, which 900 // formats each product twice) — hit warm caches instead of firing individual queries 901 // per product. Same N+1-prevention rule get_products() follows; a no-op behaviorally. 902 $prime_ids = []; 903 foreach ( $items as $item ) { 904 $prime_id = absint( $item['id'] ?? 0 ); 905 if ( $prime_id ) { 906 $prime_ids[] = $prime_id; 907 } 908 } 909 if ( ! empty( $prime_ids ) && function_exists( '_prime_post_caches' ) ) { 910 _prime_post_caches( array_unique( $prime_ids ), true, true ); 911 } 245 912 246 913 foreach ( $items as $item ) { … … 259 926 } 260 927 928 // Opaque, unique per save cycle. Generated before anything can fail so the 929 // finally below always has a token to tear down with — even on paths that 930 // never armed anything (finish_cycle() no-ops on an unknown token). 931 $save_token = uniqid( 'wcabe_save_', true ); 932 261 933 try { 262 934 $product = wc_get_product( $id ); … … 274 946 275 947 $type = $product->get_type(); 276 $allowed_fields = self::ALLOWED_FIELDS[ $type ] ?? [];277 $allowed_fields = apply_filters( 'wcabe_g2_allowed_fields', $allowed_fields, $product );278 948 279 949 // Validate all changes before applying any. 280 $validation_errors = $this->validate_changes( $product, $changes, $allowed_fields ); 281 $validation_errors = apply_filters( 'wcabe_g2_validate_extra_fields', $validation_errors, $product, $changes ); 950 $validation = $this->validate_item( $product, $changes ); 951 $allowed_fields = $validation['allowed_fields']; 952 $validation_errors = $validation['errors']; 282 953 if ( ! empty( $validation_errors ) ) { 283 954 $results[] = [ … … 305 976 } 306 977 307 do_action( 'wcabe_g2_before_product_save', $product, $changes, $allowed_fields ); 978 // Pre-save snapshot, read before any listener can mutate the post. Passed to 979 // the after-seam so listeners get the same $post_before WordPress hands to 980 // wp_after_insert_post(). Post caches were primed for the whole batch above, 981 // so this is a cache read, not a query. 982 $post_before = get_post( $id ); 983 984 do_action( 'wcabe_g2_before_product_save', $product, $changes, $allowed_fields, $context, $save_token ); 308 985 309 986 $product->save(); 310 987 311 $is_variation = $product->is_type( 'variation' ); 312 $data = $is_variation 313 ? Product_Helper::format_variation( $product ) 314 : Product_Helper::format_product( $product ); 988 // THE WRITE HAS COMMITTED. Everything below is notification, and notification 989 // must never be able to report a committed write as failed — the client would 990 // surface it as an error and re-send rows that were in fact written. 991 // 992 // \Throwable, not \Exception: this seam wakes arbitrary third-party code (its 993 // whole purpose is firing save_post for sync plugins), and the enclosing 994 // catch below only takes \Exception — a listener raising Error/TypeError would 995 // escape batch_update() entirely and 500 the request. Same rule, and the same 996 // reasoning, as the formatting phase below. 997 try { 998 do_action( 'wcabe_g2_after_product_save', $product, $changes, $context, $post_before, $save_token ); 999 } catch ( \Throwable $t ) { 1000 $warnings[] = sprintf( 1001 /* translators: %s: error message from the third-party callback */ 1002 __( 'The product was saved, but another plugin errored while being notified: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1003 $t->getMessage() 1004 ); 1005 } 315 1006 316 1007 $result = [ 317 1008 'id' => $id, 318 1009 'success' => true, 319 'data' => $data,1010 'data' => null, // Filled in phase 2 below. 320 1011 ]; 321 1012 … … 325 1016 326 1017 $results[] = $result; 1018 1019 // Retain the saved object AND its slot in $results, so phase 2 can write 1020 // each formatted row back in place and the response order is preserved. 1021 $saved[ array_key_last( $results ) ] = $product; 327 1022 328 1023 } catch ( \Exception $e ) { … … 334 1029 ], 335 1030 ]; 1031 } finally { 1032 // Unconditional teardown for this cycle — runs on every exit path, including 1033 // the `continue`s above (validation failure, product not found) and any 1034 // exception. Deliberately a direct static call and NOT a do_action: a public 1035 // teardown hook would let a third-party Throwable escape this finally and 1036 // override an already-successful result, re-opening the exact failure mode 1037 // the after-seam's Throwable isolation just closed. Teardown is an internal 1038 // lifecycle detail, not an extension point. 1039 WP_Save_Hooks_Handler::finish_cycle( $save_token ); 1040 } 1041 } 1042 1043 // --- Phase 2: format every successful save against ONE freshly-built ref map. --- 1044 // 1045 // Deliberately after ALL saves, never before. A single batch can (a) give a product 1046 // new upsell IDs, and (b) rename or re-SKU a product that another row in the same 1047 // batch points at. A map built before the loop would miss (a) entirely and carry 1048 // stale descriptors for (b). save() invalidates each product's post cache, so the 1049 // re-prime inside resolve_product_ref_ids() reads post-write values. 1050 // 1051 // EVERY WRITE ABOVE HAS ALREADY COMMITTED. Formatting is presentation, so nothing below 1052 // may throw out of this method: an uncaught exception here becomes a 500 (no REST caller 1053 // wraps us), the client marks the whole chunk failed, and the user re-saves rows that 1054 // were in fact written. \Throwable, not \Exception — wcabe_g2_format_product is a public 1055 // filter and third-party callbacks raise Error/TypeError as readily as Exception. 1056 if ( ! empty( $saved ) ) { 1057 // Re-read every saved product before formatting. NOT an optimisation to be tidied away: 1058 // 1059 // WC_Data::apply_changes() (abstract-wc-data.php:907) merges with array_replace_recursive(), 1060 // and WC_Product does not override it. That merges ARRAY props by INDEX instead of 1061 // replacing them — so an array prop that SHRANK leaves the in-memory object corrupt after 1062 // save(). Upsells [A, B, C] → [B] persists [B] correctly, but the object is left holding 1063 // array_replace_recursive([A,B,C], [B]) === [B, B, C]. Formatting from that object hands 1064 // the grid a row contradicting the write that just succeeded, and the user watches the 1065 // link they removed come straight back. Same trap for gallery_image_ids, categories, tags 1066 // and attributes — every array prop, whenever the new value is shorter than the old. 1067 // 1068 // WooCommerce's own REST controller re-reads after save for exactly this reason 1069 // (WC_REST_CRUD_Controller::save_object). Priming first is what keeps it affordable: post 1070 // and meta for the whole batch come from one prime, so the per-product read stays off the 1071 // query path. 1072 $saved_ids = array_values( array_map( static fn( $p ) => $p->get_id(), $saved ) ); 1073 Product_Helper::prime_product_caches( $saved_ids ); 1074 1075 foreach ( $saved as $index => $product ) { 1076 $reread = wc_get_product( $product->get_id() ); 1077 if ( $reread ) { 1078 $saved[ $index ] = $reread; 1079 } 1080 } 1081 1082 $ref_map = null; 1083 1084 try { 1085 // Same contract as get_products(): let Free's attribute handler and Pro's 1086 // taxonomy handler warm their caches before a batch formatting loop, or their 1087 // per-product term lookups become the N+1 we just removed from ours. 1088 do_action( 'wcabe_g2_before_format_products', $saved_ids ); 1089 1090 $ref_map = Product_Helper::build_product_ref_map( $saved ); 1091 } catch ( \Throwable $e ) { 1092 // null, NOT []. project_product_refs() does `$ref_map[$id] ?? missing_product_ref($id)`, 1093 // so an empty map would mark every ref DEAD and tell the user their upsells are 1094 // all broken. null makes format_product() self-resolve per product — an N+1, but 1095 // correct, on a path that should never fire. 1096 $ref_map = null; 1097 } 1098 1099 foreach ( $saved as $index => $product ) { 1100 try { 1101 $results[ $index ]['data'] = $product->is_type( 'variation' ) 1102 ? Product_Helper::format_variation( $product ) 1103 : Product_Helper::format_product( $product, $ref_map ); 1104 } catch ( \Throwable $e ) { 1105 // The write committed, so `success` stays true — flipping it would strand the 1106 // pending change client-side and invite a duplicate re-send. Drop the key 1107 // rather than nulling it: BatchResult.data is declared OPTIONAL (absent), not 1108 // nullable. The client tolerates a missing data (useSave gates on 1109 // `item.success && item.data`, and clears the pending change either way), and 1110 // the background refetch supplies the true row. 1111 unset( $results[ $index ]['data'] ); 1112 } 336 1113 } 337 1114 } 338 1115 339 1116 return $results; 1117 } 1118 1119 /** 1120 * Resolve the effective field allowlist and validate a change set — WITHOUT 1121 * applying anything. 1122 * 1123 * VALIDATION ONLY — zero side effects: no setters, no save, no hooks beyond 1124 * the two established validation filters. Safe to call as a pure dry-run. 1125 * 1126 * Deliberate shared extension seam (the first since Phase A's $context arg 1127 * on wcabe_g2_before_product_save): batch_update() runs its validation 1128 * through this method, and Pro's Operation History pre-flight calls it 1129 * directly so restorability is judged by the exact allowlist + validation 1130 * pipeline that will perform the write — never by a reimplementation. 1131 * Free functions identically without any caller besides batch_update(). 1132 * 1133 * @param \WC_Product $product The product the changes target. 1134 * @param array $changes Field => value map (input vocabulary). 1135 * @return array{allowed_fields: array, errors: array} allowed_fields is the 1136 * post-filter allowlist for the product's type; errors is an 1137 * array of [ 'field' => string, 'value' => mixed, 'message' => string ]. 1138 */ 1139 public function validate_item( \WC_Product $product, array $changes ): array { 1140 $type = $product->get_type(); 1141 $allowed_fields = self::ALLOWED_FIELDS[ $type ] ?? self::DEFAULT_ALLOWED_FIELDS; 1142 $allowed_fields = apply_filters( 'wcabe_g2_allowed_fields', $allowed_fields, $product ); 1143 1144 $errors = $this->validate_changes( $product, $changes, $allowed_fields ); 1145 $errors = apply_filters( 'wcabe_g2_validate_extra_fields', $errors, $product, $changes ); 1146 1147 return [ 1148 'allowed_fields' => $allowed_fields, 1149 'errors' => $errors, 1150 ]; 340 1151 } 341 1152 … … 360 1171 } 361 1172 362 // Price validation .363 if ( in_array( $field, [ 'regular_price', 'sale_price' ], true ) ) {1173 // Price validation (COGS is a monetary cost — same numeric ≥ 0 rule). 1174 if ( in_array( $field, [ 'regular_price', 'sale_price', 'cogs_value' ], true ) ) { 364 1175 $sanitized = trim( (string) $value ); 365 1176 if ( '' !== $sanitized ) { … … 383 1194 'value' => $value, 384 1195 'message' => __( 'Stock quantity must be a valid number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1196 ]; 1197 } 1198 } elseif ( 'menu_order' === $field ) { 1199 // Menu order is a signed 32-bit integer (default 0). Empty is allowed 1200 // (treated as 0 on save). Decimals/non-numeric are rejected; so are 1201 // values outside the signed-INT range — WordPress runs MySQL non-strict, 1202 // so an out-of-range value would clamp silently on save. 1203 if ( null !== $value && '' !== $value ) { 1204 $str = (string) $value; 1205 if ( ! preg_match( '/^-?\d+$/', $str ) ) { 1206 $errors[] = [ 1207 'field' => $field, 1208 'value' => $value, 1209 'message' => __( 'Menu order must be a whole number.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1210 ]; 1211 } elseif ( ! self::menu_order_in_int32_range( $str ) ) { 1212 $errors[] = [ 1213 'field' => $field, 1214 'value' => $value, 1215 'message' => __( 'Menu order is out of range.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1216 ]; 1217 } 1218 } 1219 } elseif ( 'product_url' === $field ) { 1220 // External-product destination link. Policy (server authoritative): 1221 // - non-string (array/object) is rejected outright: esc_url_raw() 1222 // would throw or a (string) cast would store the literal "Array"; 1223 // - empty clears the field; 1224 // - otherwise valid iff esc_url_raw() keeps something, i.e. only 1225 // disallowed protocols (javascript:, data:, ...) are rejected. 1226 // Anything wp_allowed_protocols() permits (http, https, ftp, 1227 // mailto, tel, ...) is accepted, matching WooCommerce's own 1228 // product-data panel — being stricter would break bulk 1229 // prepend/append over legitimately stored non-http values. 1230 // Schemeless input ("example.com") is accepted and normalized to 1231 // http:// on save. 1232 // 1233 // Validated against the TRIMMED value, matching what the write path 1234 // in apply_field_change() actually stores. 1235 if ( null !== $value && ! is_string( $value ) ) { 1236 $errors[] = [ 1237 'field' => $field, 1238 'value' => $value, 1239 'message' => __( 'Product URL must be text.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1240 ]; 1241 } elseif ( null !== $value && '' !== trim( $value ) && '' === esc_url_raw( trim( $value ) ) ) { 1242 $errors[] = [ 1243 'field' => $field, 1244 'value' => $value, 1245 'message' => __( 'Product URL is not a valid URL.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 385 1246 ]; 386 1247 } … … 412 1273 } 413 1274 } 1275 } elseif ( 'date_created' === $field ) { 1276 // Publish date (post_date) — REQUIRED. Unlike sale dates it cannot be 1277 // emptied (every post has a post_date; an empty value would reset it to 1278 // "now" on save). Must be a valid Y-m-d or Y-m-d H:i:s (a bare date is 1279 // accepted for back-compat; WC pads it to 00:00:00). Future dates are allowed. 1280 $sanitized = sanitize_text_field( (string) $value ); 1281 if ( '' === $sanitized ) { 1282 $errors[] = [ 1283 'field' => $field, 1284 'value' => $value, 1285 'message' => __( 'Published date cannot be empty.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1286 ]; 1287 } elseif ( ! preg_match( '/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/', $sanitized ) ) { 1288 $errors[] = [ 1289 'field' => $field, 1290 'value' => $value, 1291 'message' => __( 'Invalid date format for Published on. Expected YYYY-MM-DD or YYYY-MM-DD HH:MM:SS.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1292 ]; 1293 } else { 1294 $parts = explode( '-', substr( $sanitized, 0, 10 ) ); 1295 $valid = checkdate( (int) $parts[1], (int) $parts[2], (int) $parts[0] ); 1296 if ( $valid && strlen( $sanitized ) > 10 ) { 1297 // Time part present: the regex guarantees an exact HH:MM:SS shape. 1298 list( $h, $i, $s ) = array_map( 'intval', explode( ':', substr( $sanitized, 11 ) ) ); 1299 $valid = ( $h <= 23 && $i <= 59 && $s <= 59 ); 1300 } 1301 if ( ! $valid ) { 1302 $errors[] = [ 1303 'field' => $field, 1304 'value' => $value, 1305 'message' => __( 'Invalid date for Published on.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1306 ]; 1307 } 1308 } 414 1309 } elseif ( in_array( $field, [ 'date_on_sale_from', 'date_on_sale_to' ], true ) ) { 415 1310 // Date validation — must be empty or Y-m-d format. … … 477 1372 // Empty array is valid — this is the "clear gallery" path. 478 1373 foreach ( $value as $gid ) { 479 if ( ! is_numeric( $gid ) || (int) $gid <= 0) {1374 if ( ! self::is_positive_int_id( $gid ) ) { 480 1375 $errors[] = [ 481 1376 'field' => $field, 482 1377 'value' => $value, 483 1378 'message' => __( 'Invalid gallery image ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1379 ]; 1380 break; 1381 } 1382 } 1383 } 1384 } elseif ( in_array( $field, [ 'upsell_ids', 'cross_sell_ids' ], true ) ) { 1385 // Accepts both wire shapes the normaliser accepts: int[] (Operation History 1386 // restore) and [{ id: int }] (the grid). Existence is deliberately NOT 1387 // checked — dead refs are preserved, see normalize_product_refs(). 1388 if ( ! is_array( $value ) ) { 1389 $errors[] = [ 1390 'field' => $field, 1391 'value' => $value, 1392 'message' => __( 'Linked products must be an array of product IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1393 ]; 1394 } else { 1395 // Empty array is valid — this is the "clear" path. 1396 foreach ( $value as $ref ) { 1397 $ref_id = is_array( $ref ) ? ( $ref['id'] ?? null ) : $ref; 1398 if ( ! self::is_positive_int_id( $ref_id ) ) { 1399 $errors[] = [ 1400 'field' => $field, 1401 'value' => $value, 1402 'message' => __( 'Invalid linked product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 484 1403 ]; 485 1404 break; … … 556 1475 ]; 557 1476 } 1477 } elseif ( 'shipping_class' === $field ) { 1478 // Unlike the enums above the vocabulary is per-site, so validate 1479 // against the taxonomy itself. The wire value must be a STRING, and 1480 // only the exact empty string '' means "clear" (no class on a parent, 1481 // "same as parent" on a variation). A non-string (null, false, an 1482 // array) is invalid input, not an implicit clear — and so is a 1483 // non-empty string that sanitize_title() reduces to '' (junk like 1484 // '!!!'). Conflating either with the real empty-string clear would 1485 // silently wipe an existing shipping class instead of rejecting the 1486 // request. 1487 if ( ! is_string( $value ) ) { 1488 $errors[] = [ 1489 'field' => $field, 1490 'value' => $value, 1491 'message' => __( 'Shipping class must be a string.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1492 ]; 1493 } else { 1494 $sanitized = sanitize_title( $value ); 1495 if ( '' !== $value && '' === $sanitized ) { 1496 $errors[] = [ 1497 'field' => $field, 1498 'value' => $value, 1499 'message' => sprintf( 1500 /* translators: %s: shipping class slug */ 1501 __( 'Unknown shipping class: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1502 $value 1503 ), 1504 ]; 1505 } elseif ( '' !== $sanitized && ! get_term_by( 'slug', $sanitized, 'product_shipping_class' ) ) { 1506 $errors[] = [ 1507 'field' => $field, 1508 'value' => $value, 1509 'message' => sprintf( 1510 /* translators: %s: shipping class slug */ 1511 __( 'Unknown shipping class: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1512 $sanitized 1513 ), 1514 ]; 1515 } 1516 } 558 1517 } 559 1518 } … … 579 1538 case 'sku': 580 1539 $product->set_sku( sanitize_text_field( (string) $value ) ); 1540 break; 1541 1542 case 'button_text': 1543 // External-only (allowlist-gated): set_button_text() exists only on 1544 // WC_Product_External. The instanceof guard turns a misconfigured 1545 // wcabe_g2_allowed_fields filter into a no-op instead of a fatal. 1546 if ( $product instanceof \WC_Product_External ) { 1547 $product->set_button_text( sanitize_text_field( (string) $value ) ); 1548 } 1549 break; 1550 1551 case 'product_url': 1552 // External-only (allowlist-gated), same instanceof rationale as 1553 // button_text above. 1554 // 1555 // esc_url_raw() here is load-bearing: WooCommerce sanitizes in the 1556 // GETTER (WC_Product_External::get_product_url() wraps esc_url_raw), 1557 // not the setter — set_product_url() only runs 1558 // htmlspecialchars_decode(). WC's own admin sanitizes before calling 1559 // the setter for the same reason. Passing the raw value would store 1560 // "example.com" in _product_url while the getter and grid display 1561 // "http://example.com", and the SQL filter matches the raw stored 1562 // meta — so the column users see would silently diverge from what 1563 // the filter finds. Canonicalize here so meta, getter and filter agree. 1564 // 1565 // trim() BEFORE esc_url_raw() is load-bearing, not tidiness: esc_url() 1566 // only LTRIMS, then percent-encodes the spaces it kept, so a 1567 // copy-pasted "https://a.com " would be stored as "https://a.com%20" 1568 // — a silently broken affiliate link. Every other text field escapes 1569 // this via sanitize_text_field(), which trims; esc_url_raw() does not. 1570 if ( $product instanceof \WC_Product_External ) { 1571 $product->set_product_url( esc_url_raw( trim( (string) $value ) ) ); 1572 } 581 1573 break; 582 1574 … … 594 1586 break; 595 1587 1588 case 'cogs_value': 1589 // Native WooCommerce Cost of Goods Sold. Route through the CRUD 1590 // setter (not raw post meta) so $product->save() persists it 1591 // rather than clobbering it from the in-memory cogs_value prop. 1592 // No-op when the feature is off — set_cogs_value() would emit 1593 // wc_doing_it_wrong() otherwise. 1594 if ( Product_Helper::is_cogs_enabled() ) { 1595 $sanitized = trim( (string) $value ); 1596 $product->set_cogs_value( '' === $sanitized ? null : (float) wc_format_decimal( $sanitized ) ); 1597 } 1598 break; 1599 596 1600 case 'stock_quantity': 597 1601 if ( null === $value || '' === $value ) { … … 600 1604 $product->set_stock_quantity( wc_stock_amount( $value ) ); 601 1605 } 1606 break; 1607 1608 case 'menu_order': 1609 // Native wp_posts.menu_order. No "unset" state — an empty value 1610 // resets to 0. set_menu_order() casts via intval() (validated as an 1611 // integer upstream in validate_changes()). 1612 $product->set_menu_order( ( '' === $value || null === $value ) ? 0 : (int) $value ); 602 1613 break; 603 1614 … … 678 1689 break; 679 1690 1691 case 'date_created': 1692 // Publish date (post_date). Validation guarantees a non-empty, valid 1693 // Y-m-d or Y-m-d H:i:s; guard defensively so an empty value never resets 1694 // post_date to "now". set_date_created() interprets the string as 1695 // site-local and persists post_date at full date-time granularity (a bare 1696 // Y-m-d is padded to 00:00:00 site time). 1697 $sanitized = sanitize_text_field( (string) $value ); 1698 if ( '' !== $sanitized ) { 1699 $product->set_date_created( $sanitized ); 1700 } 1701 break; 1702 680 1703 case 'date_on_sale_from': 681 1704 $sanitized = sanitize_text_field( (string) $value ); … … 726 1749 $product->set_gallery_image_ids( $ids ); 727 1750 break; 728 } 1751 1752 case 'upsell_ids': 1753 $product->set_upsell_ids( self::normalize_product_refs( $value, $product->get_id() ) ); 1754 break; 1755 1756 case 'cross_sell_ids': 1757 $product->set_cross_sell_ids( self::normalize_product_refs( $value, $product->get_id() ) ); 1758 break; 1759 1760 case 'shipping_class': 1761 $sanitized = sanitize_title( (string) $value ); 1762 if ( '' === $sanitized ) { 1763 // 0 makes WC write an empty term list: no class on a parent, 1764 // "same as parent" on a variation. Both are the same clear. 1765 $product->set_shipping_class_id( 0 ); 1766 break; 1767 } 1768 $term = get_term_by( 'slug', $sanitized, 'product_shipping_class' ); 1769 if ( ! $term ) { 1770 throw new \InvalidArgumentException( 1771 sprintf( 1772 /* translators: %s: shipping class slug */ 1773 esc_html__( 'Unknown shipping class: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1774 esc_html( $sanitized ) 1775 ) 1776 ); 1777 } 1778 $product->set_shipping_class_id( (int) $term->term_id ); 1779 break; 1780 } 1781 } 1782 1783 /** 1784 * Is $v a positive integer id — strictly? 1785 * 1786 * NOT is_numeric() followed by a cast. is_numeric() accepts '1.9', '1e3', '+12', ' 12' and 1787 * '-7', and the (int)/absint() cast that follows SILENTLY REWRITES them: (int)'1e3' is 1000, 1788 * (int)'1.9' is 1, absint('-7') is 7. A value that passed validation would then be written 1789 * as a DIFFERENT id. This is the same trap search_products() documents at its ctype_digit() 1790 * call, and the same one it notes WooCommerce core falls into. 1791 * 1792 * ctype_digit() alone is not enough either: it passes arbitrarily long digit strings, and 1793 * (int)'999999999999999999999999' saturates to PHP_INT_MAX. The round-trip below pins the 1794 * cast as lossless. It also rejects leading-zero forms like '012'. 1795 * 1796 * Shared by the linked-product and gallery_image_ids validators — both feed a cast. 1797 * 1798 * @param mixed $v 1799 * @return bool 1800 */ 1801 private static function is_positive_int_id( $v ): bool { 1802 if ( is_int( $v ) ) { 1803 return $v > 0; 1804 } 1805 1806 if ( is_string( $v ) ) { 1807 return ctype_digit( $v ) && (string) (int) $v === $v && (int) $v > 0; 1808 } 1809 1810 // Floats, bools, null, arrays, objects. A JSON `12` decodes to a PHP int, and JS 1811 // JSON.stringify(12.0) emits "12", so no legitimate client lands here. 1812 return false; 1813 } 1814 1815 /** 1816 * Normalise a linked-product list to an ordered int[]. 1817 * 1818 * Accepts BOTH wire shapes, deliberately: 1819 * 1820 * [ { id: 12, name: …, sku: … }, … ] — from the grid, which needs names to render 1821 * [ 12, 45, 900 ] — raw ints 1822 * 1823 * The raw-int path is not hypothetical. Operation History captures the canonical CRUD 1824 * value (an int[]) and Pro_History_Input_Adapter::to_input() is an identity passthrough, 1825 * so restore replays int[] straight back through here. Without dual-shape support, 1826 * restore breaks. It also makes a future paste-from-spreadsheet path work for free. 1827 * 1828 * Rules, in order: 1829 * 1. Coerce each entry to a positive int (from 'id' when it's an object/array). 1830 * 2. Dedupe, preserving FIRST-occurrence order. Order is data — WooCommerce renders 1831 * upsells in stored order — so never rebuild this from a set. 1832 * 3. Drop self-reference. (Also dropped client-side, per row, so the bulk preview 1833 * shows what actually lands; this is the server-side backstop.) 1834 * 4. Do NOT check existence. WooCommerce core's set_upsell_ids() stores whatever it 1835 * is given, which is exactly why dead refs accumulate in real stores. We match it 1836 * and surface dead refs in the UI instead, so a user who never touches a dead chip 1837 * keeps it. Silently mutating data the user didn't touch is what erodes trust in a 1838 * tool that writes to a hundred products at once. 1839 * 5. An empty array is valid — that is Clear. 1840 * 1841 * Note this runs AFTER validate_changes(), whose is_positive_int_id() gate has already 1842 * rejected any entry whose (int) cast would be lossy — so the casts below are safe. 1843 * 1844 * @param mixed $value int[] or array of { id: int }. 1845 * @param int $self_id The product being written (excluded from its own list). 1846 * @return int[] 1847 */ 1848 private static function normalize_product_refs( $value, int $self_id ): array { 1849 if ( ! is_array( $value ) ) { 1850 return []; 1851 } 1852 1853 $ids = []; 1854 1855 foreach ( $value as $entry ) { 1856 if ( is_array( $entry ) ) { 1857 $id = isset( $entry['id'] ) ? (int) $entry['id'] : 0; 1858 } elseif ( is_object( $entry ) ) { 1859 $id = isset( $entry->id ) ? (int) $entry->id : 0; 1860 } else { 1861 $id = (int) $entry; 1862 } 1863 1864 if ( $id <= 0 || $id === $self_id ) { 1865 continue; 1866 } 1867 1868 // array_search-free dedupe that preserves first-occurrence order. 1869 if ( ! isset( $ids[ $id ] ) ) { 1870 $ids[ $id ] = true; 1871 } 1872 } 1873 1874 return array_map( 'intval', array_keys( $ids ) ); 729 1875 } 730 1876 … … 788 1934 789 1935 /** 1936 * Whether a menu_order string — already matching /^-?\d+$/ — fits the signed 1937 * 32-bit range (self::MENU_ORDER_MIN..MAX). 1938 * 1939 * Compares as strings (magnitude length, then lexically) to stay 1940 * platform-independent: a lossy (int) cast of an out-of-range value clamps to 1941 * PHP_INT_MAX on 32-bit builds and would wrongly pass. Leading zeros are 1942 * tolerated (mirrors the regex and the frontend): '0005' → 5, '0'/'-0' → 0. 1943 * 1944 * @param string $value Digit string, optionally sign-prefixed. 1945 * @return bool 1946 */ 1947 private static function menu_order_in_int32_range( string $value ): bool { 1948 $negative = ( '' !== $value && '-' === $value[0] ); 1949 $magnitude = ltrim( $negative ? substr( $value, 1 ) : $value, '0' ); 1950 if ( '' === $magnitude ) { 1951 return true; // All zeros → 0, always in range. 1952 } 1953 // Derive the bound magnitudes from the constants (string-safe, no abs()). 1954 $limit = $negative ? ltrim( (string) self::MENU_ORDER_MIN, '-' ) : (string) self::MENU_ORDER_MAX; 1955 if ( strlen( $magnitude ) !== strlen( $limit ) ) { 1956 return strlen( $magnitude ) < strlen( $limit ); 1957 } 1958 return strcmp( $magnitude, $limit ) <= 0; 1959 } 1960 1961 /** 790 1962 * Get products via WooCommerce CRUD API. 791 1963 * … … 794 1966 * @param array $filters Optional filter criteria. 795 1967 * @param bool $include_variations Whether variations will be interleaved (affects meta_query behavior). 796 * @param bool $ids_only_all_variable When true, override pagination/type to fetch ALL matching 797 * variable parent IDs (unbounded, fields=ids). Used by the 798 * variation-total helper; products array in this mode is int[]. 1968 * @param bool $ids_only_all_variable When true, override pagination/type to fetch ALL matching 1969 * variable parent IDs (unbounded, fields=ids). Used by the 1970 * variation-total helper; products array in this mode is int[]. 1971 * @param string $orderby Sort column: 'id' (default) or 'menu_order'. 1972 * @param string $order Sort direction: 'asc' or 'desc' (default). 799 1973 * @return array{ products: \WC_Product[]|int[], total: int, total_pages: int } 800 1974 */ … … 804 1978 array $filters = [], 805 1979 bool $include_variations = false, 806 bool $ids_only_all_variable = false 1980 bool $ids_only_all_variable = false, 1981 string $orderby = 'id', 1982 string $order = 'desc' 807 1983 ): array { 1984 // Map the whitelisted orderby to a WC/WP_Query-supported value. 'menu_order' 1985 // passes through to WP_Query natively; append ID as a stable tiebreaker so 1986 // pagination is deterministic when rows share a menu_order (the default 0) — 1987 // WP_Query parses the space-separated form into "menu_order <dir>, ID <dir>". 1988 // Anything else falls back to ID. 1989 $wc_orderby = ( 'menu_order' === $orderby ) ? 'menu_order ID' : 'ID'; 1990 $wc_order = ( 'asc' === strtolower( $order ) ) ? 'ASC' : 'DESC'; 1991 1992 // A shipping_class param that survived REST sanitisation as nothing at all 1993 // (e.g. '!!!') must match NOTHING, never widen to the whole catalog. There 1994 // is no "impossible slug" to encode this with — sanitize_title() output is 1995 // exactly the legal slug vocabulary — so the REST layer sets this flag. 1996 if ( ! empty( $filters['shipping_class_match_none'] ) ) { 1997 return [ 1998 'products' => [], 1999 'total' => 0, 2000 'total_pages' => 1, 2001 ]; 2002 } 2003 808 2004 $args = [ 809 2005 'limit' => $per_page, 810 2006 'page' => $page, 811 'orderby' => 'ID',812 'order' => 'DESC',2007 'orderby' => $wc_orderby, 2008 'order' => $wc_order, 813 2009 'paginate' => true, 814 2010 ]; … … 824 2020 $args['return'] = 'ids'; 825 2021 2022 $variable_types = self::get_variable_product_types(); 826 2023 if ( ! empty( $filters['type'] ) ) { 827 2024 $intersection = array_values( 828 array_intersect( (array) $filters['type'], [ 'variable' ])2025 array_intersect( (array) $filters['type'], $variable_types ) 829 2026 ); 830 2027 if ( empty( $intersection ) ) { … … 837 2034 $filters['type'] = $intersection; 838 2035 } else { 839 $filters['type'] = [ 'variable' ];2036 $filters['type'] = $variable_types; 840 2037 } 841 2038 } … … 844 2041 $args['status'] = ! empty( $filters['status'] ) 845 2042 ? (array) $filters['status'] 846 : [ 'publish', 'draft', 'private', 'pending' ]; 847 848 // Type filter — use provided values or defaults. 2043 // 'future' (Scheduled) is included so products given a future publish 2044 // date — which WordPress auto-transitions publish -> future — stay 2045 // visible in the grid instead of silently disappearing. 2046 : [ 'publish', 'draft', 'private', 'pending', 'future' ]; 2047 2048 // Type filter — use provided values, or default to every known type so 2049 // third-party types (bundle, subscription, …) appear in the grid (#137). 849 2050 $args['type'] = ! empty( $filters['type'] ) 850 2051 ? (array) $filters['type'] 851 : [ 'simple', 'variable', 'grouped', 'external' ];2052 : self::get_known_product_types(); 852 2053 853 2054 // ID filter — simple comparison or expression (CSV + ranges). … … 892 2093 // Search filters — handled via posts_join/posts_where hooks below. 893 2094 $search_name_term = ! empty( $filters['search_name'] ) ? sanitize_text_field( $filters['search_name'] ) : ''; 894 $search_sku_term = ! empty( $filters['search_sku'] ) ? sanitize_text_field( $filters['search_sku'] ) : ''; 2095 // Title match mode: 'all' (every word, AND, default), 'any' (any word, OR), 'phrase' (whole-term LIKE). 2096 $search_name_mode = isset( $filters['search_name_mode'] ) && in_array( $filters['search_name_mode'], self::TITLE_SEARCH_MODES, true ) 2097 ? $filters['search_name_mode'] 2098 : 'all'; 2099 // Strict '' test, not empty(): "0" is a valid SKU and must not be dropped. 2100 $search_sku_term = isset( $filters['search_sku'] ) ? sanitize_text_field( (string) $filters['search_sku'] ) : ''; 2101 $has_search_sku = '' !== $search_sku_term; 2102 // SKU match mode: '' (auto/legacy) or one of self::SKU_SEARCH_MODES. 2103 $search_sku_mode = self::normalize_sku_mode( $filters['search_sku_mode'] ?? '' ); 895 2104 $search_description_term = ! empty( $filters['search_description'] ) ? sanitize_text_field( $filters['search_description'] ) : ''; 896 2105 $search_short_description_term = ! empty( $filters['search_short_description'] ) ? sanitize_text_field( $filters['search_short_description'] ) : ''; 2106 2107 // Product URL filter (external/affiliate link). "Effective" is NOT a 2108 // non-empty-value test: is_empty / is_not_empty carry meaning with none. 2109 $product_url_operator = isset( $filters['product_url_operator'] ) && in_array( $filters['product_url_operator'], self::PRODUCT_URL_OPERATORS, true ) 2110 ? $filters['product_url_operator'] 2111 : 'contains'; 2112 $product_url_value = isset( $filters['product_url_value'] ) ? trim( (string) $filters['product_url_value'] ) : ''; 2113 $has_product_url_filter = self::product_url_filter_is_effective( $product_url_operator, $product_url_value ); 897 2114 898 2115 // Category filter — exact matching (include_children => false). … … 901 2118 } 902 2119 2120 // Tag filter — WC's data store natively maps 'product_tag_id' (term IDs) to a 2121 // product_tag tax_query. Flat taxonomy, so no include_children handling needed 2122 // (unlike category). Note WC's 'tag' arg expects slugs — do not use it. 2123 if ( ! empty( $filters['tag'] ) ) { 2124 $args['product_tag_id'] = array_map( 'absint', (array) $filters['tag'] ); 2125 } 2126 903 2127 // Stock status filter. 904 2128 if ( ! empty( $filters['stock_status'] ) ) { … … 906 2130 } 907 2131 2132 // Shipping class filter. WooCommerce's own 'shipping_class' query var 2133 // builds the tax_query, matching on SLUG (see WC_Product_Data_Store_CPT) — 2134 // which is why the whole feature speaks slugs rather than term IDs. 2135 // Parent-level by design: variations follow whichever parent matched, and 2136 // a variation that overrides its parent's class is not independently 2137 // surfaced. Narrowing per variation would instead HIDE every variation 2138 // that inherits (its own stored class is empty), which is the wrong 2139 // default for the common case. 2140 if ( ! empty( $filters['shipping_class'] ) ) { 2141 $args['shipping_class'] = array_map( 'sanitize_title', (array) $filters['shipping_class'] ); 2142 } 2143 908 2144 // Price range filters — passed as custom args, handled in handle_custom_query_args. 909 2145 if ( isset( $filters['price_min'] ) && '' !== $filters['price_min'] ) { … … 912 2148 if ( isset( $filters['price_max'] ) && '' !== $filters['price_max'] ) { 913 2149 $args['wcabe_price_max'] = wc_format_decimal( $filters['price_max'] ); 2150 } 2151 2152 // Cost of Goods range filters — gated on the COGS feature so direct service 2153 // calls carrying stale cogs_min/max can't reach the variation matcher or 2154 // get_cogs_value() while the feature is disabled. 2155 if ( Product_Helper::is_cogs_enabled() ) { 2156 if ( isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min'] ) { 2157 $args['wcabe_cogs_min'] = (float) $filters['cogs_min']; 2158 } 2159 if ( isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max'] ) { 2160 $args['wcabe_cogs_max'] = (float) $filters['cogs_max']; 2161 } 914 2162 } 915 2163 … … 943 2191 if ( $modified_after || $modified_before ) { 944 2192 $args['date_modified'] = $this->build_date_range( $modified_after, $modified_before ); 2193 } 2194 2195 // Sale-schedule date range filters. WC's native date_on_sale_from/date_on_sale_to 2196 // query args are deliberately NOT used: for day precision WC compares the range 2197 // end against the START of that day and derives both bounds through gmdate(), so 2198 // sale-to timestamps stored at local 23:59:59 (WC admin's own convention) never 2199 // match and non-UTC sites shift by a day. We compute site-local bounds here and 2200 // expand them in the query callbacks below. 2201 list( $sale_start_min, $sale_start_max ) = $this->sale_date_filter_bounds( 2202 $filters['sale_start_after'] ?? '', 2203 $filters['sale_start_before'] ?? '' 2204 ); 2205 list( $sale_end_min, $sale_end_max ) = $this->sale_date_filter_bounds( 2206 $filters['sale_end_after'] ?? '', 2207 $filters['sale_end_before'] ?? '' 2208 ); 2209 2210 if ( null !== $sale_start_min ) { 2211 $args['wcabe_sale_start_min'] = $sale_start_min; 2212 } 2213 if ( null !== $sale_start_max ) { 2214 $args['wcabe_sale_start_max'] = $sale_start_max; 2215 } 2216 if ( null !== $sale_end_min ) { 2217 $args['wcabe_sale_end_min'] = $sale_end_min; 2218 } 2219 if ( null !== $sale_end_max ) { 2220 $args['wcabe_sale_end_max'] = $sale_end_max; 945 2221 } 946 2222 … … 978 2254 979 2255 $has_stock_qty_filter = isset( $args['wcabe_stock_qty_min'] ) || isset( $args['wcabe_stock_qty_max'] ); 2256 $has_cogs_filter = isset( $args['wcabe_cogs_min'] ) || isset( $args['wcabe_cogs_max'] ); 980 2257 $has_manage_stock_filter = ! empty( $args['wcabe_manage_stock'] ); 981 2258 $has_backorders_filter = ! empty( $args['wcabe_backorders'] ); 982 983 // Hook for exact category matching, multi-value stock_status, price, stock qty, manage stock, backorders, and attribute filtering. 984 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || $has_price_filter || $has_stock_qty_filter || $has_manage_stock_filter || $has_backorders_filter || ! empty( $attribute_tax_queries ); 2259 $has_sale_date_filter = isset( $args['wcabe_sale_start_min'] ) || isset( $args['wcabe_sale_start_max'] ) 2260 || isset( $args['wcabe_sale_end_min'] ) || isset( $args['wcabe_sale_end_max'] ); 2261 2262 // Hook for exact category matching, multi-value stock_status, price, stock qty, COGS, manage stock, backorders, sale dates, and attribute filtering. 2263 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || $has_price_filter || $has_stock_qty_filter || $has_cogs_filter || $has_manage_stock_filter || $has_backorders_filter || $has_sale_date_filter || ! empty( $attribute_tax_queries ); 985 2264 986 2265 $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations ): array { … … 1061 2340 } 1062 2341 2342 // Cost of Goods range filtering via _cogs_total_value meta. 2343 // When include_variations is true, handled by the posts_where hook 2344 // below instead — variable parents carry a non-null default cost, so 2345 // the _stock-style "OR IS NULL" trick doesn't apply. 2346 $has_cogs_min = isset( $query_vars['wcabe_cogs_min'] ); 2347 $has_cogs_max = isset( $query_vars['wcabe_cogs_max'] ); 2348 2349 if ( ( $has_cogs_min || $has_cogs_max ) && ! $include_variations ) { 2350 if ( ! isset( $wp_query_args['meta_query'] ) ) { 2351 $wp_query_args['meta_query'] = []; 2352 } 2353 2354 if ( $has_cogs_min && $has_cogs_max ) { 2355 $cogs_range_clause = [ 2356 'key' => '_cogs_total_value', 2357 'value' => [ (float) $query_vars['wcabe_cogs_min'], (float) $query_vars['wcabe_cogs_max'] ], 2358 'compare' => 'BETWEEN', 2359 'type' => 'DECIMAL(10,2)', 2360 ]; 2361 } elseif ( $has_cogs_min ) { 2362 $cogs_range_clause = [ 2363 'key' => '_cogs_total_value', 2364 'value' => (float) $query_vars['wcabe_cogs_min'], 2365 'compare' => '>=', 2366 'type' => 'DECIMAL(10,2)', 2367 ]; 2368 } else { 2369 $cogs_range_clause = [ 2370 'key' => '_cogs_total_value', 2371 'value' => (float) $query_vars['wcabe_cogs_max'], 2372 'compare' => '<=', 2373 'type' => 'DECIMAL(10,2)', 2374 ]; 2375 } 2376 2377 // Guard _cogs_total_value != '' (grouped AND with the range) so a 2378 // stray empty row can't CAST to 0 and match a 0-inclusive range. 2379 $wp_query_args['meta_query'][] = [ 2380 'relation' => 'AND', 2381 [ 2382 'key' => '_cogs_total_value', 2383 'value' => '', 2384 'compare' => '!=', 2385 ], 2386 $cogs_range_clause, 2387 ]; 2388 } 2389 2390 // Sale-schedule date range filtering via _sale_price_dates_from/_to 2391 // (Unix timestamps). When include_variations is true this is handled by 2392 // the posts_where hook below instead, so a variable parent can surface 2393 // via a matching child variation. 2394 $sale_bounds = [ 2395 '_sale_price_dates_from' => [ 2396 $query_vars['wcabe_sale_start_min'] ?? null, 2397 $query_vars['wcabe_sale_start_max'] ?? null, 2398 ], 2399 '_sale_price_dates_to' => [ 2400 $query_vars['wcabe_sale_end_min'] ?? null, 2401 $query_vars['wcabe_sale_end_max'] ?? null, 2402 ], 2403 ]; 2404 2405 $has_sale_filter = false; 2406 foreach ( $sale_bounds as $bounds ) { 2407 if ( null !== $bounds[0] || null !== $bounds[1] ) { 2408 $has_sale_filter = true; 2409 break; 2410 } 2411 } 2412 2413 if ( $has_sale_filter && ! $include_variations ) { 2414 if ( ! isset( $wp_query_args['meta_query'] ) ) { 2415 $wp_query_args['meta_query'] = []; 2416 } 2417 2418 foreach ( $sale_bounds as $meta_key => $bounds ) { 2419 list( $min, $max ) = $bounds; 2420 if ( null === $min && null === $max ) { 2421 continue; 2422 } 2423 2424 if ( null !== $min && null !== $max ) { 2425 $sale_range_clause = [ 2426 'key' => $meta_key, 2427 'value' => [ $min, $max ], 2428 'compare' => 'BETWEEN', 2429 'type' => 'NUMERIC', 2430 ]; 2431 } elseif ( null !== $min ) { 2432 $sale_range_clause = [ 2433 'key' => $meta_key, 2434 'value' => $min, 2435 'compare' => '>=', 2436 'type' => 'NUMERIC', 2437 ]; 2438 } else { 2439 $sale_range_clause = [ 2440 'key' => $meta_key, 2441 'value' => $max, 2442 'compare' => '<=', 2443 'type' => 'NUMERIC', 2444 ]; 2445 } 2446 2447 // Guard != '' (grouped AND with the range) so an empty row can't 2448 // CAST to 0 and satisfy a <=-bounded range. 2449 $wp_query_args['meta_query'][] = [ 2450 'relation' => 'AND', 2451 [ 2452 'key' => $meta_key, 2453 'value' => '', 2454 'compare' => '!=', 2455 ], 2456 $sale_range_clause, 2457 ]; 2458 } 2459 2460 // A variable parent never legitimately carries a sale schedule (the 2461 // plugin treats sale dates as absent on them), and in this mode a child 2462 // never surfaces its parent — but stale _sale_price_dates_* rows can 2463 // survive a simple->variable type conversion, so exclude them outright. 2464 if ( ! isset( $wp_query_args['tax_query'] ) ) { 2465 $wp_query_args['tax_query'] = []; 2466 } 2467 $wp_query_args['tax_query'][] = [ 2468 'taxonomy' => 'product_type', 2469 'field' => 'slug', 2470 'terms' => self::get_variable_product_types(), 2471 'operator' => 'NOT IN', 2472 ]; 2473 } 2474 1063 2475 // Stock quantity range filtering via _stock meta. 1064 2476 // When include_variations is false, standard meta_query applies. … … 1144 2556 $search_where_callback = null; 1145 2557 1146 if ( $search_name_term || $ search_sku_term) {2558 if ( $search_name_term || $has_search_sku ) { 1147 2559 global $wpdb; 1148 2560 1149 2561 // JOIN is only needed when filtering by SKU. 1150 if ( $ search_sku_term) {2562 if ( $has_search_sku ) { 1151 2563 $search_join_callback = function ( string $join ) use ( $wpdb ): string { 1152 2564 if ( strpos( $join, 'wcabe_sku_lookup' ) === false ) { … … 1160 2572 $name_clause = ''; 1161 2573 if ( $search_name_term ) { 1162 $name_like = '%' . $wpdb->esc_like( $search_name_term ) . '%'; 1163 $name_clause = $wpdb->prepare( "( {$wpdb->posts}.post_title LIKE %s )", $name_like ); 2574 $name_clause = $this->build_name_search_clause( $wpdb, $search_name_term, $search_name_mode ); 1164 2575 } 1165 2576 1166 2577 $sku_clause = ''; 1167 if ( $search_sku_term ) { 1168 if ( strpos( $search_sku_term, ',' ) !== false ) { 1169 // CSV mode: exact match against each SKU. 1170 $skus = array_filter( array_map( 'trim', explode( ',', $search_sku_term ) ) ); 1171 $skus = array_map( 'sanitize_text_field', $skus ); 1172 if ( ! empty( $skus ) ) { 1173 $placeholders = implode( ',', array_fill( 0, count( $skus ), '%s' ) ); 1174 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a comma-joined string of `%s` placeholders, one per element of $skus, all expanded by prepare() via spread. 1175 $sku_clause = $wpdb->prepare( 1176 "( wcabe_sku_lookup.sku IN ($placeholders) )", 1177 ...$skus 1178 ); 1179 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 1180 } 1181 } else { 1182 // Single term: LIKE search. 1183 $sku_like = '%' . $wpdb->esc_like( $search_sku_term ) . '%'; 1184 $sku_clause = $wpdb->prepare( '( wcabe_sku_lookup.sku LIKE %s )', $sku_like ); 1185 } 2578 if ( $has_search_sku ) { 2579 $sku_clause = $this->build_sku_search_clause( $wpdb, $search_sku_term, $search_sku_mode, $include_variations ); 1186 2580 } 1187 2581 … … 1207 2601 $description_where_callback = function ( string $where ) use ( $wpdb, $desc_like, $include_variations ): string { 1208 2602 if ( $include_variations ) { 1209 $where .= $wpdb->prepare( 1210 " AND ( {$wpdb->posts}.post_content LIKE %s OR {$wpdb->posts}.ID IN (" 2603 // The child branch is gated on the parent being variable — a 2604 // stale variation left behind by a variable->simple type 2605 // conversion must not surface its now-simple parent. 2606 $is_variable = $this->sql_is_variable_product(); 2607 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $is_variable is this class's own hardcoded SQL literal (sql_is_variable_product()); the search term is passed through prepare() via %s. 2608 $where .= $wpdb->prepare( 2609 " AND ( {$wpdb->posts}.post_content LIKE %s OR ( {$is_variable} AND {$wpdb->posts}.ID IN (" 1211 2610 . " SELECT p.post_parent FROM {$wpdb->posts} p" 1212 2611 . " INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id" … … 1214 2613 . " AND pm.meta_key = '_variation_description'" 1215 2614 . " AND pm.meta_value LIKE %s" 1216 . ' ) ) ',2615 . ' ) ) )', 1217 2616 $desc_like, 1218 2617 $desc_like 1219 2618 ); 2619 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1220 2620 } else { 1221 2621 $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like ); … … 1326 2726 } 1327 2727 2728 // Product URL filtering (external/affiliate link, _product_url meta). 2729 // Raw SQL rather than meta_query for the same reason as stock qty above: 2730 // WP's meta_query cannot express "meta row missing", which the is_empty 2731 // operator needs. Every column is alias-qualified — an unqualified 2732 // post_id/meta_key/meta_value becomes ambiguous the moment another meta 2733 // join (e.g. stock qty) is active in the same query. 2734 $product_url_join_callback = null; 2735 $product_url_where_callback = null; 2736 $product_url_marker_callback = null; 2737 2738 if ( $has_product_url_filter ) { 2739 global $wpdb; 2740 2741 // posts_join / posts_where fire on EVERY WP_Query for as long as they are 2742 // registered — including any a third-party pre_get_posts listener spins up 2743 // inside ours. Unscoped, those queries would inherit our WHERE; and because 2744 // the JOIN is added only once (the strpos guard), a nested query could even 2745 // receive the WHERE WITHOUT the JOIN and hard-fail on an unknown column. 2746 // So both callbacks below bail unless the query carries our private marker, 2747 // which this callback stamps onto our own query only. Same guard shape as 2748 // search_products() / REF_SEARCH_QUERY_VAR. 2749 $product_url_marker_callback = static function ( array $wp_query_args ): array { 2750 $wp_query_args[ self::PRODUCT_URL_QUERY_VAR ] = true; 2751 return $wp_query_args; 2752 }; 2753 2754 $product_url_join_callback = function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 2755 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 2756 return $join; 2757 } 2758 if ( strpos( $join, 'wcabe_product_url_meta' ) === false ) { 2759 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_product_url_meta ON ( {$wpdb->posts}.ID = wcabe_product_url_meta.post_id AND wcabe_product_url_meta.meta_key = '_product_url' ) "; 2760 } 2761 return $join; 2762 }; 2763 2764 $is_external_sql = $this->sql_is_external_product(); 2765 2766 $product_url_where_callback = function ( string $where, \WP_Query $query ) use ( $wpdb, $product_url_operator, $product_url_value, $is_external_sql ): string { 2767 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 2768 return $where; 2769 } 2770 2771 $col = 'wcabe_product_url_meta.meta_value'; 2772 $has_a_url = "( {$col} IS NOT NULL AND {$col} <> '' )"; 2773 2774 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $has_a_url and $col are this method's own hardcoded column expressions; the user-supplied URL value is passed through prepare() via %s in every branch. 2775 switch ( $product_url_operator ) { 2776 case 'is_empty': 2777 $clause = "( {$col} IS NULL OR {$col} = '' )"; 2778 break; 2779 2780 case 'is_not_empty': 2781 $clause = $has_a_url; 2782 break; 2783 2784 case 'not_contains': 2785 // Scoped to rows that HAVE a URL, so this reads as "URLs that 2786 // don't contain X" rather than "every external product without one". 2787 $clause = $wpdb->prepare( 2788 "( {$has_a_url} AND {$col} NOT LIKE %s )", 2789 '%' . $wpdb->esc_like( $product_url_value ) . '%' 2790 ); 2791 break; 2792 2793 case 'starts_with': 2794 $clause = $wpdb->prepare( 2795 "( {$has_a_url} AND {$col} LIKE %s )", 2796 $wpdb->esc_like( $product_url_value ) . '%' 2797 ); 2798 break; 2799 2800 case 'ends_with': 2801 $clause = $wpdb->prepare( 2802 "( {$has_a_url} AND {$col} LIKE %s )", 2803 '%' . $wpdb->esc_like( $product_url_value ) 2804 ); 2805 break; 2806 2807 case 'contains': 2808 default: 2809 $clause = $wpdb->prepare( 2810 "( {$has_a_url} AND {$col} LIKE %s )", 2811 '%' . $wpdb->esc_like( $product_url_value ) . '%' 2812 ); 2813 break; 2814 } 2815 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 2816 2817 // EVERY operator is scoped to external products, not just is_empty. 2818 // product_url is an external-only field: Product_Helper::format_product() 2819 // and the CSV exporter both emit '' for any other type, and the grid cell 2820 // is blank and read-only there. A non-external row carries _product_url 2821 // only as STALE meta (converted external → simple), so matching it would 2822 // surface a row whose Product URL is blank on every surface the user can 2823 // see, with no way to tell why it matched. Scoping the whole filter keeps 2824 // "what the filter finds" and "what the column shows" the same set. 2825 return $where . " AND ( {$clause} AND {$is_external_sql} ) "; 2826 }; 2827 } 2828 2829 // ── Combined variation-aware filtering: COGS, menu order, sale dates, image date ── 2830 // 2831 // ONE callback pair covers all four deliberately. Each of them can surface a 2832 // variable parent either on its OWN value or through a child variation, and 2833 // emitting them as separate `( own_i OR child_i )` clauses would let *different* 2834 // siblings satisfy different filters — surfacing a parent whose children are 2835 // then all rejected by variation_matches_filters(), i.e. an empty parent row. 2836 // (The sale-date filter already solved this across its own two ranges; this 2837 // generalises the same fix across filters.) The emitted shape is 2838 // 2839 // ( own_A AND own_B AND … ) OR ( is_variable AND EXISTS ( … child_A AND child_B … ) ) 2840 // 2841 // so a variable parent surfaces only when it matches entirely on its own 2842 // values, or when ONE renderable child satisfies every active predicate — 2843 // exactly what variation_matches_filters() accepts when it narrows the 2844 // children. Distributing the child branch this way also emits the correlated 2845 // subquery once instead of once per filter. With a single filter active the 2846 // shape collapses to that filter's previous `( own OR child )` clause, so 2847 // single-filter behaviour is unchanged. 2848 // 2849 // SCOPE: these four filters only. Price, stock status, stock quantity, manage 2850 // stock, backorders, SKU search and description search surface parents through 2851 // other mechanisms (meta_query, the OR-NULL stock posts_where above, IN 2852 // subqueries below) and are NOT folded in — a filter from this group combined 2853 // with one of those can still produce an empty parent row. Known and accepted; 2854 // folding the rest in is tracked separately. 2855 // 2856 // The child status list matches WC get_children() (publish/private only) so a 2857 // parent never surfaces via a variation that won't render. The child branch is 2858 // gated on the parent being variable — a variable->simple type conversion can 2859 // leave published variation posts behind, and a stale child must not surface 2860 // its now-simple parent (interleaving only narrows children of variable 2861 // parents, so such a false positive is uncorrectable downstream). COGS and 2862 // sale dates participate only when include_variations is on; with it off they 2863 // stay on their meta_query paths and never reach this callback, so with 2864 // variations off only menu order and image date are emitted here. 2865 $combined_join_callback = null; 2866 $combined_where_callback = null; 2867 2868 $cogs_min = isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min'] ? (float) $filters['cogs_min'] : null; 2869 $cogs_max = isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max'] ? (float) $filters['cogs_max'] : null; 2870 2871 $menu_order_min = ( isset( $filters['menu_order_min'] ) && '' !== $filters['menu_order_min'] ) ? (int) $filters['menu_order_min'] : null; 2872 $menu_order_max = ( isset( $filters['menu_order_max'] ) && '' !== $filters['menu_order_max'] ) ? (int) $filters['menu_order_max'] : null; 2873 2874 // Image-uploaded bounds. The signal is the featured image attachment's 2875 // post_date — a site-local DATETIME, the same basis the grid renders — so 2876 // day-precision Y-m-d bounds expand to literal day-start / day-end strings and 2877 // compare directly. No timestamp arithmetic, hence no DST edge (contrast the 2878 // sale-date bounds above, which are Unix timestamps stored in meta). 2879 $image_date_after = isset( $filters['image_date_after'] ) ? (string) $filters['image_date_after'] : ''; 2880 $image_date_before = isset( $filters['image_date_before'] ) ? (string) $filters['image_date_before'] : ''; 2881 $image_date_min = '' !== $image_date_after ? $image_date_after . ' 00:00:00' : null; 2882 $image_date_max = '' !== $image_date_before ? $image_date_before . ' 23:59:59' : null; 2883 2884 $use_cogs = $include_variations && $has_cogs_filter; 2885 $use_sale = $include_variations && $has_sale_date_filter; 2886 $use_menu = ( null !== $menu_order_min || null !== $menu_order_max ); 2887 $use_image = ( null !== $image_date_min || null !== $image_date_max ); 2888 2889 if ( $use_cogs || $use_sale || $use_menu || $use_image ) { 2890 global $wpdb; 2891 2892 // Active sale ranges (either or both of from/to may be filtered). 2893 $sale_predicates = ! $use_sale ? [] : array_values( 2894 array_filter( 2895 [ 2896 [ 2897 'meta_key' => '_sale_price_dates_from', 2898 'parent_alias' => 'wcabe_sale_from_meta', 2899 'child_alias' => 'wcabe_sale_from_child', 2900 'min' => $sale_start_min, 2901 'max' => $sale_start_max, 2902 ], 2903 [ 2904 'meta_key' => '_sale_price_dates_to', 2905 'parent_alias' => 'wcabe_sale_to_meta', 2906 'child_alias' => 'wcabe_sale_to_child', 2907 'min' => $sale_end_min, 2908 'max' => $sale_end_max, 2909 ], 2910 ], 2911 static function ( array $range ): bool { 2912 return null !== $range['min'] || null !== $range['max']; 2913 } 2914 ) 2915 ); 2916 2917 // Only COGS, sale dates and image date need parent-side joins; menu_order 2918 // reads a native wp_posts column. 2919 if ( $use_cogs || $use_sale || $use_image ) { 2920 $combined_join_callback = function ( string $join ) use ( $wpdb, $use_cogs, $use_image, $sale_predicates ): string { 2921 if ( $use_cogs && strpos( $join, 'wcabe_cogs_meta' ) === false ) { 2922 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_cogs_meta ON ( {$wpdb->posts}.ID = wcabe_cogs_meta.post_id AND wcabe_cogs_meta.meta_key = '_cogs_total_value' ) "; 2923 } 2924 2925 foreach ( $sale_predicates as $range ) { 2926 if ( strpos( $join, $range['parent_alias'] ) === false ) { 2927 $join .= " LEFT JOIN {$wpdb->postmeta} AS {$range['parent_alias']} ON ( {$wpdb->posts}.ID = {$range['parent_alias']}.post_id AND {$range['parent_alias']}.meta_key = '{$range['meta_key']}' ) "; 2928 } 2929 } 2930 2931 if ( $use_image && strpos( $join, 'wcabe_img_meta' ) === false ) { 2932 // No GROUP BY guard: a duplicate _thumbnail_id meta row would 2933 // duplicate the product row. Same accepted exposure as 2934 // wcabe_stock_meta / wcabe_cogs_meta — _thumbnail_id is written 2935 // through update_post_meta (single-row) on every core/WC path. 2936 // The post_type gate mirrors Product_Helper::get_image_upload_date(), 2937 // so a _thumbnail_id pointing at a non-attachment is excluded here 2938 // and renders blank there. 2939 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_img_meta ON ( {$wpdb->posts}.ID = wcabe_img_meta.post_id AND wcabe_img_meta.meta_key = '_thumbnail_id' ) "; 2940 $join .= " LEFT JOIN {$wpdb->posts} AS wcabe_img ON ( wcabe_img.ID = CAST( wcabe_img_meta.meta_value AS UNSIGNED ) AND wcabe_img.post_type = 'attachment' ) "; 2941 } 2942 2943 return $join; 2944 }; 2945 } 2946 2947 $combined_where_callback = function ( string $where ) use ( 2948 $wpdb, 2949 $include_variations, 2950 $use_cogs, 2951 $use_sale, 2952 $use_menu, 2953 $use_image, 2954 $sale_predicates, 2955 $cogs_min, 2956 $cogs_max, 2957 $menu_order_min, 2958 $menu_order_max, 2959 $image_date_min, 2960 $image_date_max 2961 ): string { 2962 // Bounded comparison in the three shapes prepare() needs. $expr and $ph 2963 // are always this method's own literals, never request data. 2964 $build_bound = function ( string $expr, $min, $max, string $ph ) use ( $wpdb ): string { 2965 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $expr is a hardcoded column/CAST expression and $ph a hardcoded placeholder token (invisible to the sniff but expanded by prepare()); the bound values are passed through prepare(). 2966 if ( null !== $min && null !== $max ) { 2967 return $wpdb->prepare( "( {$expr} BETWEEN {$ph} AND {$ph} )", $min, $max ); 2968 } 2969 if ( null !== $min ) { 2970 return $wpdb->prepare( "( {$expr} >= {$ph} )", $min ); 2971 } 2972 return $wpdb->prepare( "( {$expr} <= {$ph} )", $max ); 2973 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 2974 }; 2975 2976 $is_variable = $this->sql_is_variable_product(); 2977 2978 $own_parts = []; 2979 $child_parts = []; 2980 $child_joins = ''; 2981 2982 if ( $use_cogs ) { 2983 $own_col = 'wcabe_cogs_meta.meta_value'; 2984 $child_col = 'wcabe_child_cogs.meta_value'; 2985 $child_joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_cogs ON ( wcabe_child_cogs.post_id = c.ID AND wcabe_child_cogs.meta_key = '_cogs_total_value' ) "; 2986 $own_parts[] = "( {$own_col} <> '' AND " . $build_bound( "CAST( {$own_col} AS DECIMAL(10,2) )", $cogs_min, $cogs_max, '%f' ) . ' )'; 2987 $child_parts[] = "( {$child_col} <> '' AND " . $build_bound( "CAST( {$child_col} AS DECIMAL(10,2) )", $cogs_min, $cogs_max, '%f' ) . ' )'; 2988 } 2989 2990 if ( $use_menu ) { 2991 $own_parts[] = $build_bound( "{$wpdb->posts}.menu_order", $menu_order_min, $menu_order_max, '%d' ); 2992 $child_parts[] = $build_bound( 'c.menu_order', $menu_order_min, $menu_order_max, '%d' ); 2993 } 2994 2995 if ( $use_image ) { 2996 $child_joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_img_meta ON ( wcabe_child_img_meta.post_id = c.ID AND wcabe_child_img_meta.meta_key = '_thumbnail_id' ) "; 2997 $child_joins .= " INNER JOIN {$wpdb->posts} AS wcabe_child_img ON ( wcabe_child_img.ID = CAST( wcabe_child_img_meta.meta_value AS UNSIGNED ) AND wcabe_child_img.post_type = 'attachment' ) "; 2998 // A product with no image, a stale _thumbnail_id, or one pointing at 2999 // a non-attachment leaves post_date NULL through the LEFT JOIN, and 3000 // NULL fails every comparison — excluded, as intended. 3001 $own_parts[] = $build_bound( 'wcabe_img.post_date', $image_date_min, $image_date_max, '%s' ); 3002 $child_parts[] = $build_bound( 'wcabe_child_img.post_date', $image_date_min, $image_date_max, '%s' ); 3003 } 3004 3005 foreach ( $sale_predicates as $range ) { 3006 $parent_col = "{$range['parent_alias']}.meta_value"; 3007 $child_col = "{$range['child_alias']}.meta_value"; 3008 $child_joins .= " INNER JOIN {$wpdb->postmeta} AS {$range['child_alias']} ON ( {$range['child_alias']}.post_id = c.ID AND {$range['child_alias']}.meta_key = '{$range['meta_key']}' ) "; 3009 $own_parts[] = "( {$parent_col} <> '' AND " . $build_bound( "CAST( {$parent_col} AS UNSIGNED )", $range['min'], $range['max'], '%d' ) . ' )'; 3010 $child_parts[] = "( {$child_col} <> '' AND " . $build_bound( "CAST( {$child_col} AS UNSIGNED )", $range['min'], $range['max'], '%d' ) . ' )'; 3011 } 3012 3013 if ( $use_sale ) { 3014 // A variable parent's own sale meta is necessarily stale (the plugin 3015 // treats sale dates as absent on variable parents and never writes 3016 // them), so while a sale filter is active such a parent may only 3017 // match through a child variation. 3018 $own_parts[] = "NOT {$is_variable}"; 3019 } 3020 3021 $own = empty( $own_parts ) ? '' : '( ' . implode( ' AND ', $own_parts ) . ' )'; 3022 3023 $child = ''; 3024 if ( $include_variations && ! empty( $child_parts ) ) { 3025 $child = "( {$is_variable} AND EXISTS ( SELECT 1 FROM {$wpdb->posts} c{$child_joins}" 3026 . " WHERE c.post_parent = {$wpdb->posts}.ID AND c.post_type = 'product_variation'" 3027 . " AND c.post_status IN ( 'publish', 'private' ) AND " . implode( ' AND ', $child_parts ) . ' ) )'; 3028 } 3029 3030 // Parenthesize so the OR cannot escape the query's other predicates. 3031 if ( '' !== $own && '' !== $child ) { 3032 $where .= " AND ( {$own} OR {$child} ) "; 3033 } elseif ( '' !== $own ) { 3034 $where .= " AND {$own} "; 3035 } elseif ( '' !== $child ) { 3036 $where .= " AND {$child} "; 3037 } 3038 3039 return $where; 3040 }; 3041 } 3042 1328 3043 if ( $needs_custom_query ) { 1329 3044 add_filter( … … 1338 3053 add_filter( 'posts_join', $stock_qty_join_callback, 10, 1 ); 1339 3054 add_filter( 'posts_where', $stock_qty_where_callback, 10, 1 ); 3055 } 3056 3057 if ( $product_url_join_callback ) { 3058 // Marker first — it must be on the query args before WP_Query is built. 3059 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10, 1 ); 3060 add_filter( 'posts_join', $product_url_join_callback, 10, 2 ); 3061 add_filter( 'posts_where', $product_url_where_callback, 10, 2 ); 3062 } 3063 3064 if ( $combined_join_callback ) { 3065 add_filter( 'posts_join', $combined_join_callback, 10, 1 ); 3066 } 3067 if ( $combined_where_callback ) { 3068 add_filter( 'posts_where', $combined_where_callback, 10, 1 ); 1340 3069 } 1341 3070 … … 1371 3100 remove_filter( 'posts_where', $stock_qty_where_callback, 10 ); 1372 3101 } 3102 if ( $product_url_join_callback ) { 3103 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10 ); 3104 remove_filter( 'posts_join', $product_url_join_callback, 10 ); 3105 remove_filter( 'posts_where', $product_url_where_callback, 10 ); 3106 } 3107 if ( $combined_join_callback ) { 3108 remove_filter( 'posts_join', $combined_join_callback, 10 ); 3109 } 3110 if ( $combined_where_callback ) { 3111 remove_filter( 'posts_where', $combined_where_callback, 10 ); 3112 } 1373 3113 if ( $search_join_callback ) { 1374 3114 remove_filter( 'posts_join', $search_join_callback, 10 ); … … 1405 3145 1406 3146 /** 3147 * Build the post_title search WHERE clause for the given match mode. 3148 * 3149 * - 'all' (default): every whitespace-separated word must appear (joined with AND). 3150 * - 'any': at least one word must appear (joined with OR). 3151 * - 'phrase' or a single-word term: the whole term as one LIKE 3152 * (matches the historical behavior — contiguous substring, order-sensitive). 3153 * 3154 * Multi-word matching splits on whitespace and caps at 10 words to bound the 3155 * number of LIKE clauses. The fragment is parenthesized so the caller can 3156 * safely AND it with the SKU clause. 3157 * 3158 * @param \wpdb $wpdb WordPress database handle. 3159 * @param string $term Sanitized search term. 3160 * @param string $mode One of 'phrase', 'all', 'any'. 3161 * @return string Prepared SQL fragment (already wrapped in parentheses). 3162 */ 3163 private function build_name_search_clause( \wpdb $wpdb, string $term, string $mode ): string { 3164 $words = []; 3165 if ( 'phrase' !== $mode ) { 3166 $split = preg_split( '/\s+/', trim( $term ), -1, PREG_SPLIT_NO_EMPTY ); 3167 $words = is_array( $split ) ? $split : []; 3168 } 3169 3170 // Phrase mode, or fewer than two words: original whole-term LIKE. 3171 if ( count( $words ) < 2 ) { 3172 $like = '%' . $wpdb->esc_like( $term ) . '%'; 3173 return $wpdb->prepare( "( {$wpdb->posts}.post_title LIKE %s )", $like ); 3174 } 3175 3176 // Cap the word count to bound the LIKE chain length. 3177 $words = array_slice( $words, 0, 10 ); 3178 $connector = ( 'any' === $mode ) ? 'OR' : 'AND'; 3179 3180 $likes = []; 3181 $values = []; 3182 foreach ( $words as $word ) { 3183 $likes[] = "{$wpdb->posts}.post_title LIKE %s"; 3184 $values[] = '%' . $wpdb->esc_like( $word ) . '%'; 3185 } 3186 3187 $sql = '( ' . implode( " {$connector} ", $likes ) . ' )'; 3188 3189 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $sql is a fixed chain of `%s` placeholders (one per word), each expanded by prepare() via the spread of $values below. 3190 return $wpdb->prepare( $sql, ...$values ); 3191 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 3192 } 3193 3194 /** 3195 * Build the SKU search WHERE clause for the given match mode. 3196 * 3197 * A comma-separated term is a LIST OF VALUES applied under the selected 3198 * mode — the mode is authoritative, the comma only multiplies it. The four 3199 * positive modes OR their values together (match ANY); not_contains ANDs 3200 * them (match NONE). 3201 * 3202 * Mode '' is the legacy "auto" behaviour, preserved for requests and saved 3203 * views written before match modes existed: a comma in the term means 3204 * exact IN, otherwise a whole-term substring LIKE. It maps onto 3205 * is_exactly / contains, which produce that same SQL — with two 3206 * deliberate exceptions, not regressions: "0" is now treated as a real 3207 * token (see split_sku_term()) instead of being dropped by truthiness 3208 * filtering, and the token count is capped at self::SKU_SEARCH_MAX_TOKENS. 3209 * 3210 * not_contains reads the SKU through COALESCE so a product with no SKU (or 3211 * no lookup row at all — the JOIN is a LEFT JOIN) counts as "does not 3212 * contain". The positive modes deliberately keep the bare column: NULL LIKE 3213 * is NULL, which correctly excludes, and the plain column stays 3214 * index-eligible for the anchored LIKE of starts_with. 3215 * 3216 * The fragment is parenthesized so the caller can safely AND it with the 3217 * name clause. 3218 * 3219 * @param \wpdb $wpdb 3220 * @param string $term Raw (already sanitized) search_sku value. 3221 * @param string $mode One of self::SKU_SEARCH_MODES, or '' for auto/legacy. 3222 * @param bool $include_variations Whether to also surface parents via a matching child. 3223 * @return string SQL fragment, or '' when the term holds no usable tokens. 3224 */ 3225 private function build_sku_search_clause( \wpdb $wpdb, string $term, string $mode, bool $include_variations ): string { 3226 $mode = self::normalize_sku_mode( $mode ); 3227 $tokens = self::split_sku_term( $term ); 3228 3229 if ( '' === $mode ) { 3230 // Legacy auto: the comma was the (implicit) mode selector. 3231 $mode = ( strpos( $term, ',' ) !== false ) ? 'is_exactly' : 'contains'; 3232 } 3233 3234 // Comma-only term ( ",,, " ) yields no tokens — no clause, as before. 3235 if ( empty( $tokens ) ) { 3236 return ''; 3237 } 3238 3239 $negated = ( 'not_contains' === $mode ); 3240 3241 /** 3242 * Build a parenthesized predicate over one column for every token. 3243 * 3244 * @param string $column SQL column expression. 3245 * @return array{0: string, 1: string[]} [ sql, prepare values ] 3246 */ 3247 $build_predicate = function ( string $column ) use ( $wpdb, $mode, $tokens, $negated ): array { 3248 if ( 'is_exactly' === $mode ) { 3249 $placeholders = implode( ',', array_fill( 0, count( $tokens ), '%s' ) ); 3250 return [ "( {$column} IN ({$placeholders}) )", $tokens ]; 3251 } 3252 3253 $operator = $negated ? 'NOT LIKE' : 'LIKE'; 3254 $connector = $negated ? ' AND ' : ' OR '; 3255 3256 $parts = []; 3257 $values = []; 3258 foreach ( $tokens as $token ) { 3259 $escaped = $wpdb->esc_like( $token ); 3260 switch ( $mode ) { 3261 case 'starts_with': 3262 $values[] = $escaped . '%'; 3263 break; 3264 case 'ends_with': 3265 $values[] = '%' . $escaped; 3266 break; 3267 default: // contains / not_contains. 3268 $values[] = '%' . $escaped . '%'; 3269 break; 3270 } 3271 $parts[] = "{$column} {$operator} %s"; 3272 } 3273 3274 return [ '( ' . implode( $connector, $parts ) . ' )', $values ]; 3275 }; 3276 3277 $parent_column = $negated ? "COALESCE( wcabe_sku_lookup.sku, '' )" : 'wcabe_sku_lookup.sku'; 3278 [ $parent_sql, $parent_values ] = $build_predicate( $parent_column ); 3279 3280 if ( ! $include_variations ) { 3281 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $parent_sql is a fixed chain of `%s` placeholders (one per token), each expanded by prepare() via the spread below. 3282 return $wpdb->prepare( $parent_sql, ...$parent_values ); 3283 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 3284 } 3285 3286 // Also surface a parent whose child variation matches, so 3287 // interleave_variations() can show that variation. Status predicate 3288 // matches WC get_children(), which returns only publish/private 3289 // variations. The child branch is gated on the parent being variable — 3290 // a stale variation left behind by a variable->simple type conversion 3291 // must not surface its now-simple parent. 3292 // 3293 // not_contains joins LEFT with meta_key in the ON clause so a variation 3294 // with no _sku row still counts as "does not contain"; an INNER JOIN 3295 // would make it invisible. 3296 $child_column = $negated ? "COALESCE( pm.meta_value, '' )" : 'pm.meta_value'; 3297 [ $child_sql, $child_values ] = $build_predicate( $child_column ); 3298 3299 $child_join = $negated 3300 ? "LEFT JOIN {$wpdb->postmeta} pm ON ( p.ID = pm.post_id AND pm.meta_key = '_sku' )" 3301 : "INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id"; 3302 $meta_key_where = $negated ? '' : " AND pm.meta_key = '_sku'"; 3303 3304 $is_variable = $this->sql_is_variable_product(); 3305 3306 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $parent_sql/$child_sql are fixed chains of `%s` placeholders (one per token), all expanded by prepare() via the merged spread below. 3307 return $wpdb->prepare( 3308 "( {$parent_sql}" 3309 . " OR ( {$is_variable} AND {$wpdb->posts}.ID IN (" 3310 . " SELECT p.post_parent FROM {$wpdb->posts} p" 3311 . " {$child_join}" 3312 . " WHERE p.post_type = 'product_variation'" 3313 . " AND p.post_status IN ('publish','private')" 3314 . $meta_key_where 3315 . " AND {$child_sql}" 3316 . ' ) ) )', 3317 ...array_merge( $parent_values, $child_values ) 3318 ); 3319 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 3320 } 3321 3322 /** 1407 3323 * Build a WC date range string for date_created / date_modified args. 1408 3324 * … … 1427 3343 } 1428 3344 return '<=' . $before; 3345 } 3346 3347 /** 3348 * Resolve a sale-date filter range to site-local Unix timestamp bounds. 3349 * 3350 * _sale_price_dates_from / _sale_price_dates_to are stored as Unix timestamps 3351 * derived from site-local dates, and the grid renders them in site time — so 3352 * bounds must be computed in site time too. 3353 * 3354 * The upper bound is derived from an explicit local 23:59:59, NOT from 3355 * "local midnight + DAY_IN_SECONDS - 1": a local day is 23 or 25 hours long 3356 * across a DST transition, so the arithmetic form would spill into the next 3357 * day or drop the final hour. 3358 * 3359 * @param string $after Start date (YYYY-MM-DD) or empty. 3360 * @param string $before End date (YYYY-MM-DD) or empty. 3361 * @return array{0: ?int, 1: ?int} [ min_timestamp, max_timestamp ], null when unbounded. 3362 */ 3363 private function sale_date_filter_bounds( string $after, string $before ): array { 3364 $min = '' !== $after ? wc_string_to_datetime( $after )->getTimestamp() : null; 3365 $max = '' !== $before ? wc_string_to_datetime( $before . ' 23:59:59' )->getTimestamp() : null; 3366 3367 return [ $min, $max ]; 3368 } 3369 3370 /** 3371 * SQL EXISTS expression testing whether the current wp_posts row is a 3372 * variable product. 3373 * 3374 * Used by the posts_where filter callbacks to gate child-variation EXISTS 3375 * branches on the parent actually being variable. A variable->simple type 3376 * conversion can leave published variation posts behind, and without the 3377 * gate a stale child's matching meta would surface its now-simple parent — 3378 * a false-positive row interleave_variations() can neither narrow nor 3379 * remove, since it only processes children of variable parents. 3380 * 3381 * @return string 3382 */ 3383 private function sql_is_variable_product(): string { 3384 global $wpdb; 3385 3386 // Covers third-party variable subclasses (variable-subscription, …), not 3387 // just the native 'variable' slug (#137). Slugs come from registered 3388 // product classes, not user input, but are escaped anyway. 3389 $slugs_in = implode( 3390 ',', 3391 array_map( 3392 static fn( string $slug ): string => "'" . esc_sql( $slug ) . "'", 3393 self::get_variable_product_types() 3394 ) 3395 ); 3396 3397 return "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} tr" 3398 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 3399 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 3400 . " WHERE tr.object_id = {$wpdb->posts}.ID AND tt.taxonomy = 'product_type' AND t.slug IN ( {$slugs_in} ) )"; 3401 } 3402 3403 /** 3404 * SQL predicate matching external (affiliate) products. 3405 * 3406 * Mirrors sql_is_variable_product(). Scopes EVERY Product URL filter operator 3407 * to the only type that surfaces the field — see the note at the filter's 3408 * where-callback. 3409 * 3410 * @return string 3411 */ 3412 private function sql_is_external_product(): string { 3413 global $wpdb; 3414 3415 return "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} tr" 3416 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 3417 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 3418 . " WHERE tr.object_id = {$wpdb->posts}.ID AND tt.taxonomy = 'product_type' AND t.slug = 'external' )"; 1429 3419 } 1430 3420 … … 1518 3508 $result[] = $product; 1519 3509 1520 if ( $product['type'] === 'variable' && $product['variation_count'] > 0 ) { 3510 // variation_count is only non-zero for variable-capable products 3511 // (format_product gates it on instanceof WC_Product_Variable), so it 3512 // doubles as the "has interleavable children" signal — covering 3513 // third-party variable subclasses, not just type === 'variable' (#137). 3514 if ( ! empty( $product['variation_count'] ) ) { 1521 3515 // Use pre-collected children IDs when available, falling back to 1522 3516 // loading the parent product (should only happen if called without … … 1528 3522 } 1529 3523 3524 // If the parent surfaced via its OWN SKU, show all its variations: 3525 // a SKU match on the product itself means "show this product", not 3526 // "filter its variants". Other meta filters still narrow children. 3527 // 3528 // NOT for not_contains: under a negative mode nearly every parent 3529 // matches on its own SKU, so honouring the rule there would switch 3530 // variation narrowing off wholesale and show children whose SKUs DO 3531 // contain the term. Accepted consequence: a parent that passes 3532 // not_contains while all of its children fail renders as an empty 3533 // parent row — the same class of edge as the limitation below. 3534 // 3535 // Inverse edge, positive modes: build_sku_search_clause() ORs the 3536 // parent's own SKU with "any child matches" — a parent whose OWN 3537 // SKU does NOT match still surfaces (and its non-matching own SKU 3538 // is what the grid displays) as long as at least one child does. 3539 // That parent row's visible SKU therefore will not itself contain 3540 // the searched term. Intentional: the parent row is a container for 3541 // the matching child, not a claim that the parent's own SKU matches. 3542 // 3543 // Known limitation: when search_sku and search_description match 3544 // DIFFERENT siblings, the SQL ORs surface the parent but the 3545 // per-child AND below can hide both → empty parent. Pre-existing 3546 // with search_description; not resolved here. 3547 $child_filters = $filters; 3548 $child_has_meta_filter = $has_meta_filter; 3549 $sku_mode = self::normalize_sku_mode( $filters['search_sku_mode'] ?? '' ); 3550 if ( 3551 $has_meta_filter 3552 && 'not_contains' !== $sku_mode 3553 && true === $this->sku_term_matches( (string) ( $product['sku'] ?? '' ), (string) ( $filters['search_sku'] ?? '' ), $sku_mode ) 3554 ) { 3555 unset( $child_filters['search_sku'], $child_filters['search_sku_mode'] ); 3556 $child_has_meta_filter = $this->has_variation_meta_filters( $child_filters ); 3557 } 3558 1530 3559 foreach ( $children as $child_id ) { 1531 3560 $variation = wc_get_product( $child_id ); 1532 3561 if ( $variation && $variation->is_type( 'variation' ) ) { 1533 if ( $ has_meta_filter && ! $this->variation_matches_filters( $variation, $filters ) ) {3562 if ( $child_has_meta_filter && ! $this->variation_matches_filters( $variation, $child_filters ) ) { 1534 3563 continue; 1535 3564 } … … 1556 3585 * Returns an approximate flag because the raw COUNT(*) does not replicate 1557 3586 * variation_matches_filters() — variation-level filters (price, stock_qty, 1558 * m anage_stock, backorders, search_description) cause an over-count at1559 * render time. Accepted as a safe upper bound.3587 * menu_order, manage_stock, backorders, search_description, search_sku) cause 3588 * an over-count at render time. Accepted as a safe upper bound. 1560 3589 * 1561 3590 * @param int $total_parents Total parents matching filters (from main query). … … 1581 3610 foreach ( array_chunk( $variable_parent_ids, 1000 ) as $chunk ) { 1582 3611 $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); 1583 // post_status NOT IN ('trash', 'auto-draft') matches what the main1584 // rendering path surfaces: get_children() returns all non-trashed1585 // child IDs, and wc_get_product() returns any non-auto-draft1586 // variation. Earlier version used IN ('publish', 'private'), which1587 // under-counted drafts. See Product_Service::get_variations() for1588 // th e matching path. Parent status filtering is already applied1589 // viathe parent-ID query above; this clause is the independent3612 // post_status IN ('publish', 'private') mirrors the render path: 3613 // WC_Product_Variable::get_children() queries variation children with 3614 // post_status => array( publish, private ) (verified in WC 10.8.1, 3615 // class-wc-product-variable-data-store-cpt.php), so the grid only ever 3616 // renders those. Counting draft/pending here would over-report rows 3617 // that never display. Parent status filtering is already applied via 3618 // the parent-ID query above; this clause is the independent 1590 3619 // VARIATION-status axis in WC's data model. 1591 3620 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a comma-joined string of `%d` placeholders, one per element of $chunk, all expanded by prepare() via spread. … … 1593 3622 "SELECT COUNT(*) FROM {$wpdb->posts} 1594 3623 WHERE post_type = 'product_variation' 1595 AND post_status NOT IN ('trash', 'auto-draft')3624 AND post_status IN ('publish', 'private') 1596 3625 AND post_parent IN ($placeholders)", 1597 3626 ...$chunk … … 1648 3677 return true; 1649 3678 } 3679 if ( isset( $filters['menu_order_min'] ) && '' !== $filters['menu_order_min'] ) { 3680 return true; 3681 } 3682 if ( isset( $filters['menu_order_max'] ) && '' !== $filters['menu_order_max'] ) { 3683 return true; 3684 } 3685 if ( 3686 ( ( isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min'] ) || ( isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max'] ) ) 3687 && Product_Helper::is_cogs_enabled() 3688 ) { 3689 return true; 3690 } 1650 3691 if ( ! empty( $filters['manage_stock'] ) ) { 1651 3692 return true; … … 1654 3695 return true; 1655 3696 } 3697 foreach ( [ 'sale_start_after', 'sale_start_before', 'sale_end_after', 'sale_end_before' ] as $sale_param ) { 3698 if ( isset( $filters[ $sale_param ] ) && '' !== $filters[ $sale_param ] ) { 3699 return true; 3700 } 3701 } 3702 foreach ( [ 'image_date_after', 'image_date_before' ] as $image_param ) { 3703 if ( isset( $filters[ $image_param ] ) && '' !== $filters[ $image_param ] ) { 3704 return true; 3705 } 3706 } 1656 3707 if ( ! empty( $filters['search_description'] ) ) { 1657 3708 return true; 1658 3709 } 3710 // Strict '' test, not empty(): "0" is a valid SKU. 3711 if ( $this->sku_term_is_effective( (string) ( $filters['search_sku'] ?? '' ) ) ) { 3712 return true; 3713 } 1659 3714 // search_short_description is not checked — variations don't have short 1660 3715 // descriptions; the parent-level post_excerpt filter is sufficient. 3716 // product_url is not checked either — only external products carry one, 3717 // and an external product is never variable, so the filter can never 3718 // surface a parent through a child variation. 1661 3719 return false; 3720 } 3721 3722 /** 3723 * Whether a search_sku term would produce an actual SQL clause. False for an 3724 * empty term or a comma-only term with no real tokens — the query builds no 3725 * clause in those cases. Shared so per-variation narrowing and the 3726 * approximate-count flag agree on what counts as an active SKU filter. 3727 * 3728 * Deliberately takes no mode: effectiveness is mode-invariant, because every 3729 * mode (and the legacy auto branch) builds its clause from the same token 3730 * list — see build_sku_search_clause(), which returns '' on an empty list 3731 * regardless of mode. 3732 * 3733 * @param string $term Raw search_sku filter value. 3734 * @return bool 3735 */ 3736 private function sku_term_is_effective( string $term ): bool { 3737 return [] !== self::split_sku_term( sanitize_text_field( $term ) ); 3738 } 3739 3740 /** 3741 * Match a SKU against a search_sku term + mode, mirroring the SQL built by 3742 * build_sku_search_clause() and its sanitization. Returns null when the term 3743 * is not an effective filter, so callers neither surface nor narrow on it — 3744 * callers compare against true/false strictly and rely on that third state. 3745 * mb_* keeps case-folding parity with MySQL's default utf8mb4_*_ci collation 3746 * for multibyte SKUs. 3747 * 3748 * The positive modes match ANY token; not_contains must miss EVERY token, 3749 * and an empty SKU therefore matches it — mirroring the COALESCE in the SQL. 3750 * 3751 * @param string $sku The SKU to test. 3752 * @param string $term Raw search_sku filter value. 3753 * @param mixed $mode Raw search_sku_mode filter value (normalised here). 3754 * @return bool|null 3755 */ 3756 private function sku_term_matches( string $sku, string $term, $mode = '' ): ?bool { 3757 if ( ! $this->sku_term_is_effective( $term ) ) { 3758 return null; 3759 } 3760 3761 $term = sanitize_text_field( $term ); 3762 $mode = self::normalize_sku_mode( $mode ); 3763 $tokens = self::split_sku_term( $term ); 3764 3765 if ( '' === $mode ) { 3766 // Legacy auto: the comma was the (implicit) mode selector. 3767 $mode = ( strpos( $term, ',' ) !== false ) ? 'is_exactly' : 'contains'; 3768 } 3769 3770 $sku_folded = mb_strtolower( $sku ); 3771 3772 if ( 'is_exactly' === $mode ) { 3773 $folded = array_map( 3774 static fn( string $token ): string => mb_strtolower( $token ), 3775 $tokens 3776 ); 3777 return in_array( $sku_folded, $folded, true ); 3778 } 3779 3780 foreach ( $tokens as $token ) { 3781 $token_folded = mb_strtolower( $token ); 3782 3783 switch ( $mode ) { 3784 case 'starts_with': 3785 $hit = 0 === mb_strpos( $sku_folded, $token_folded ); 3786 break; 3787 case 'ends_with': 3788 $token_length = mb_strlen( $token_folded ); 3789 $hit = $token_length <= mb_strlen( $sku_folded ) 3790 && mb_substr( $sku_folded, -$token_length ) === $token_folded; 3791 break; 3792 default: // contains / not_contains. 3793 $hit = false !== mb_strpos( $sku_folded, $token_folded ); 3794 break; 3795 } 3796 3797 if ( 'not_contains' === $mode ) { 3798 // Must miss every token — one hit disqualifies. 3799 if ( $hit ) { 3800 return false; 3801 } 3802 } elseif ( $hit ) { 3803 // Positive modes match any token — one hit is enough. 3804 return true; 3805 } 3806 } 3807 3808 return 'not_contains' === $mode; 1662 3809 } 1663 3810 … … 1708 3855 } 1709 3856 3857 // Cost of Goods range — match on the variation's OWN defined value (null = 3858 // inherits parent, treated as no-match, like stock qty). Gated on 3859 // is_cogs_enabled() so get_cogs_value() never fires when the feature is off. 3860 $has_cogs_min = isset( $filters['cogs_min'] ) && '' !== $filters['cogs_min']; 3861 $has_cogs_max = isset( $filters['cogs_max'] ) && '' !== $filters['cogs_max']; 3862 if ( ( $has_cogs_min || $has_cogs_max ) && Product_Helper::is_cogs_enabled() ) { 3863 $cogs = $variation->get_cogs_value(); 3864 if ( null === $cogs ) { 3865 return false; 3866 } 3867 if ( $has_cogs_min && $cogs < (float) $filters['cogs_min'] ) { 3868 return false; 3869 } 3870 if ( $has_cogs_max && $cogs > (float) $filters['cogs_max'] ) { 3871 return false; 3872 } 3873 } 3874 3875 // Menu order range — the variation's own value (get_menu_order() is always 3876 // an int, default 0; no null-guard needed like stock qty / COGS). 3877 $has_menu_min = isset( $filters['menu_order_min'] ) && '' !== $filters['menu_order_min']; 3878 $has_menu_max = isset( $filters['menu_order_max'] ) && '' !== $filters['menu_order_max']; 3879 if ( $has_menu_min || $has_menu_max ) { 3880 $order = $variation->get_menu_order(); 3881 if ( $has_menu_min && $order < (int) $filters['menu_order_min'] ) { 3882 return false; 3883 } 3884 if ( $has_menu_max && $order > (int) $filters['menu_order_max'] ) { 3885 return false; 3886 } 3887 } 3888 3889 // Sale-schedule date ranges — the variation's OWN schedule (null = no sale 3890 // scheduled, treated as no-match, like stock qty / COGS). Y-m-d strings are 3891 // zero-padded so lexical comparison is chronological, and WC_DateTime::date() 3892 // renders in site time — the same basis as the SQL bounds. 3893 $sale_date_ranges = [ 3894 [ 'get_date_on_sale_from', 'sale_start_after', 'sale_start_before' ], 3895 [ 'get_date_on_sale_to', 'sale_end_after', 'sale_end_before' ], 3896 ]; 3897 foreach ( $sale_date_ranges as list( $getter, $after_key, $before_key ) ) { 3898 $has_after = isset( $filters[ $after_key ] ) && '' !== $filters[ $after_key ]; 3899 $has_before = isset( $filters[ $before_key ] ) && '' !== $filters[ $before_key ]; 3900 if ( ! $has_after && ! $has_before ) { 3901 continue; 3902 } 3903 3904 $sale_date = $variation->{$getter}(); 3905 if ( null === $sale_date ) { 3906 return false; 3907 } 3908 3909 $value = $sale_date->date( 'Y-m-d' ); 3910 if ( $has_after && $value < $filters[ $after_key ] ) { 3911 return false; 3912 } 3913 if ( $has_before && $value > $filters[ $before_key ] ) { 3914 return false; 3915 } 3916 } 3917 3918 // Image-uploaded date range — the variation's OWN featured image ('edit' 3919 // context bypasses the filter that inherits the parent image for imageless 3920 // variations, matching format_variation()). No own image, a stale 3921 // _thumbnail_id, or one pointing at a non-attachment = no match, like stock 3922 // qty / COGS / sale dates. Y-m-d strings are zero-padded so lexical 3923 // comparison is chronological, and the attachment post_date is site-local — 3924 // the same basis as the SQL bounds. 3925 $has_image_after = isset( $filters['image_date_after'] ) && '' !== $filters['image_date_after']; 3926 $has_image_before = isset( $filters['image_date_before'] ) && '' !== $filters['image_date_before']; 3927 if ( $has_image_after || $has_image_before ) { 3928 $image_date = Product_Helper::get_image_upload_date( (int) $variation->get_image_id( 'edit' ) ); 3929 if ( '' === $image_date ) { 3930 return false; 3931 } 3932 3933 $value = substr( $image_date, 0, 10 ); 3934 if ( $has_image_after && $value < $filters['image_date_after'] ) { 3935 return false; 3936 } 3937 if ( $has_image_before && $value > $filters['image_date_before'] ) { 3938 return false; 3939 } 3940 } 3941 1710 3942 // Manage stock. 1711 3943 if ( ! empty( $filters['manage_stock'] ) ) { … … 1737 3969 } 1738 3970 3971 // SKU (_sku meta). A null result means the term is not an effective 3972 // filter (empty / comma-only) — the SQL builds no clause then, so don't 3973 // narrow here either. See sku_term_matches() for per-mode parity. 3974 // 3975 // 'edit' context, deliberately: WC_Product_Variation::get_sku() falls back 3976 // to the PARENT's SKU whenever the variation's own value is empty() — and 3977 // PHP counts the perfectly valid SKU "0" as empty. The SQL matches on the 3978 // variation's own _sku meta, so the mirror must read the own value or it 3979 // narrows on a SKU the query never looked at. 3980 if ( 3981 false === $this->sku_term_matches( 3982 (string) $variation->get_sku( 'edit' ), 3983 (string) ( $filters['search_sku'] ?? '' ), 3984 $filters['search_sku_mode'] ?? '' 3985 ) 3986 ) { 3987 return false; 3988 } 3989 1739 3990 // Short description — variations don't have one (no postmeta equivalent), 1740 3991 // so skip this check; the parent-level post_excerpt filter already ran. -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/View_Service.php
r3549389 r3663491 7 7 8 8 namespace WCABE_G2\Services; 9 10 use WCABE_G2\Helpers\Page_Size_Helper; 9 11 10 12 defined( 'ABSPATH' ) || exit; … … 95 97 $view['filters'] = $this->migrate_filters_shape( $view['filters'] ); 96 98 } 99 // Bound a stored page size on read: a site that has since LOWERED 100 // wcabe_g2_max_per_page must not hand the client a size the products 101 // route would silently reduce. Views saved before page_size existed 102 // are deliberately NOT backfilled — the frontend falls back to its 103 // effective default, which keeps "absent" distinguishable from "100". 104 if ( isset( $view['page_size'] ) ) { 105 $view['page_size'] = Page_Size_Helper::clamp( $view['page_size'] ); 106 } 97 107 } 98 108 unset( $view ); … … 118 128 'filters' => $this->sanitize_filters( $data['filters'] ?? [] ), 119 129 'column_state' => self::sanitize_column_state( $data['column_state'] ?? [] ), 130 'page_size' => Page_Size_Helper::clamp( $data['page_size'] ?? null ), 120 131 'is_default' => ! empty( $data['is_default'] ), 121 132 'created_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), … … 173 184 } 174 185 186 if ( isset( $data['page_size'] ) ) { 187 $views[ $index ]['page_size'] = Page_Size_Helper::clamp( $data['page_size'] ); 188 } 189 175 190 if ( isset( $data['is_default'] ) ) { 176 191 $views[ $index ]['is_default'] = (bool) $data['is_default']; … … 283 298 * Sanitize filters to match the expected FilterValues shape. 284 299 * 300 * Thin instance wrapper: migrate any legacy saved-view bag shape, then delegate 301 * to the public static sanitizer. Stays private/instance-bound only because the 302 * migration call (migrate_filters_shape) is instance-bound. 303 * 285 304 * @param array $filters 286 305 * @return array 287 306 */ 288 307 private function sanitize_filters( array $filters ): array { 308 return self::sanitize_filter_values( $this->migrate_filters_shape( $filters ) ); 309 } 310 311 /** 312 * Sanitize a URL-substring filter value. 313 * 314 * Deliberately NOT sanitize_text_field(): that strips percent-encoded octets 315 * (see wp-includes/formatting.php), so searching for "%20" or "%2F" — both 316 * common in real product URLs — would silently match nothing. Strips control 317 * characters and markup, preserves %XX. SQL safety is handled downstream by 318 * $wpdb->prepare() + esc_like(); this is only about control chars and tags. 319 * 320 * @param mixed $value Raw filter value. 321 * @return string 322 */ 323 public static function sanitize_url_filter_value( $value ): string { 324 if ( ! is_scalar( $value ) ) { 325 return ''; 326 } 327 328 $value = wp_check_invalid_utf8( (string) $value ); 329 $value = wp_strip_all_tags( $value ); 330 // Null bytes and C0/C1 control characters (incl. newlines and tabs). 331 $value = preg_replace( '/[\x00-\x1F\x7F-\x9F]/u', '', $value ); 332 333 return trim( (string) $value ); 334 } 335 336 /** 337 * Sanitize a FilterValues blob (scalar fields + dynamic bags) into the canonical 338 * shape. Public + static so reusable outside view CRUD — the User_Settings 339 * controller calls it for the filterDraft field (mirrors sanitize_column_state). 340 * 341 * No migration here: callers that may receive a legacy bag shape (saved views) 342 * migrate first via the wrapper; live frontend draft data is already current. 343 * The migrate-then-sanitize order is behavior-preserving — migration only renames 344 * the dynamic bags (which the body re-reads), and never touches the scalar fields. 345 * 346 * @param array $filters 347 * @return array 348 */ 349 public static function sanitize_filter_values( array $filters ): array { 289 350 $allowed_operators = [ '=', '>', '<', '>=', '<=', 'expression' ]; 290 351 $id_operator = $filters['id_operator'] ?? '='; … … 293 354 } 294 355 356 $search_name_mode = $filters['search_name_mode'] ?? 'all'; 357 if ( ! in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 358 $search_name_mode = 'all'; 359 } 360 361 // SKU match mode. '' is the legacy "auto" state (comma = exact, single 362 // value = partial), so an unknown mode falls back to it rather than to a 363 // concrete mode. Sanitize the term FIRST — sanitize_text_field trims, so 364 // a whitespace-only term canonicalizes to '' here — and drop the mode 365 // with it: a mode left behind an empty term would make a freshly loaded 366 // view report a phantom "Modified" (same rationale as product URL below). 367 $search_sku = sanitize_text_field( $filters['search_sku'] ?? '' ); 368 $search_sku_mode = $filters['search_sku_mode'] ?? ''; 369 if ( ! in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 370 $search_sku_mode = ''; 371 } 372 if ( '' === $search_sku ) { 373 $search_sku_mode = ''; 374 } 375 376 // Product URL filter. Canonicalized here as well as in the client store, 377 // because a view can also be created/imported straight through the REST 378 // API: a stale value hidden behind an empty-check operator would persist 379 // and — since the dirty-state baseline compares against the STORED view — 380 // make a freshly loaded view report a phantom "Modified". 381 $product_url_operator = $filters['product_url_operator'] ?? 'contains'; 382 if ( ! in_array( $product_url_operator, Product_Service::PRODUCT_URL_OPERATORS, true ) ) { 383 $product_url_operator = 'contains'; 384 } 385 $product_url_value = self::sanitize_url_filter_value( $filters['product_url_value'] ?? '' ); 386 if ( Product_Service::is_product_url_empty_check( $product_url_operator ) ) { 387 $product_url_value = ''; 388 } elseif ( '' === $product_url_value ) { 389 $product_url_operator = 'contains'; 390 } 391 295 392 $sanitized = [ 296 393 // Original fields. 297 394 'search_name' => sanitize_text_field( $filters['search_name'] ?? '' ), 298 'search_sku' => sanitize_text_field( $filters['search_sku'] ?? '' ), 395 'search_name_mode' => $search_name_mode, 396 'search_sku' => $search_sku, 397 'search_sku_mode' => $search_sku_mode, 299 398 'category' => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ), 399 'tag' => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ), 300 400 'type' => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ), 301 401 'status' => array_map( 'sanitize_text_field', (array) ( $filters['status'] ?? [] ) ), 302 402 'stock_status' => array_map( 'sanitize_text_field', (array) ( $filters['stock_status'] ?? [] ) ), 403 // Slugs. The filter callback drops values that sanitize_title reduced to 404 // '' (junk like '!!!'), and array_values re-indexes so the result is a 405 // JSON array rather than an object — a stored [''] would otherwise read 406 // back as an active filter that matches nothing visible. Strict '' 407 // equality, not array_filter()'s default truthiness: sanitize_title() 408 // can legally produce the string '0', which truthiness would drop too. 409 'shipping_class' => array_values( 410 array_filter( 411 array_map( 'sanitize_title', (array) ( $filters['shipping_class'] ?? [] ) ), 412 static fn( string $slug ): bool => '' !== $slug 413 ) 414 ), 303 415 'price_min' => sanitize_text_field( $filters['price_min'] ?? '' ), 304 416 'price_max' => sanitize_text_field( $filters['price_max'] ?? '' ), 417 'cogs_min' => sanitize_text_field( $filters['cogs_min'] ?? '' ), 418 'cogs_max' => sanitize_text_field( $filters['cogs_max'] ?? '' ), 305 419 'stock_qty_min' => sanitize_text_field( $filters['stock_qty_min'] ?? '' ), 306 420 'stock_qty_max' => sanitize_text_field( $filters['stock_qty_max'] ?? '' ), 421 'menu_order_min' => sanitize_text_field( $filters['menu_order_min'] ?? '' ), 422 'menu_order_max' => sanitize_text_field( $filters['menu_order_max'] ?? '' ), 307 423 'manage_stock' => array_map( 'sanitize_text_field', (array) ( $filters['manage_stock'] ?? [] ) ), 308 424 'backorders' => array_map( 'sanitize_text_field', (array) ( $filters['backorders'] ?? [] ) ), … … 315 431 'id_operator' => $id_operator, 316 432 'id_value' => sanitize_text_field( $filters['id_value'] ?? '' ), 433 434 // Product URL filter. NOTE: the value uses sanitize_url_filter_value(), 435 // not sanitize_text_field() — the latter strips percent-encoded octets 436 // (%20, %2F), silently corrupting legitimate URL substring searches. 437 'product_url_operator' => $product_url_operator, 438 'product_url_value' => $product_url_value, 317 439 318 440 // Date filters. … … 321 443 'date_modified_from' => sanitize_text_field( $filters['date_modified_from'] ?? '' ), 322 444 'date_modified_to' => sanitize_text_field( $filters['date_modified_to'] ?? '' ), 445 'sale_start_from' => sanitize_text_field( $filters['sale_start_from'] ?? '' ), 446 'sale_start_to' => sanitize_text_field( $filters['sale_start_to'] ?? '' ), 447 'sale_end_from' => sanitize_text_field( $filters['sale_end_from'] ?? '' ), 448 'sale_end_to' => sanitize_text_field( $filters['sale_end_to'] ?? '' ), 449 'image_date_from' => sanitize_text_field( $filters['image_date_from'] ?? '' ), 450 'image_date_to' => sanitize_text_field( $filters['image_date_to'] ?? '' ), 323 451 324 452 // Dynamic filter bags — sanitized below. … … 326 454 'extension_filters' => [], 327 455 ]; 328 329 // Normalize any pre-Phase-B saved-view shape (old acf_filters /330 // custom_meta_filters / taxonomy_filters keys) into extension_filters331 // before the dynamic-bag sanitization below reads it.332 $filters = $this->migrate_filters_shape( $filters );333 456 334 457 // Attribute filters: Record<string, number[]> — Free feature. -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/readme.txt
r3549389 r3663491 3 3 Tags: bulk edit, product editor, product bulk edit, woocommerce products, inventory 4 4 Requires at least: 6.0 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1. 0.07 Stable tag: 1.2.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 31 31 * **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style 32 32 * **Bulk operations** — apply percentage price adjustments, find-and-replace, text append and prepend, and bulk category and tag assignment across many products at once 33 * **Saved views** — save filter sets and column layouts as named views for one-click recall 34 * **Filters** — filter by category, product type, stock status, name, SKU (CSV SKU lists supported), description, date created or modified, and more 33 * **Wide field coverage** — prices, stock, SKUs, statuses, categories, tags, shipping class, menu order, upsells and cross-sells, product URL and button text for external products, and more 34 * **Saved views** — save filter sets and column layouts as named views for one-click recall, and share them between sites with view import/export 35 * **Filters** — filter by category, tags, product type, stock status, name and SKU (with exact/contains match modes and CSV SKU lists), description, dates created, modified, on sale, or image uploaded, and more 35 36 * **Column management** — show, hide, reorder, resize, and pin columns; each user's layout is saved separately 36 37 * **Keyboard shortcuts** — navigate and edit without touching the mouse 37 38 * **Product operations** — duplicate and change status right from the grid 38 39 * **Image and gallery management** — set featured images and manage product galleries directly from the grid 39 * **CSV export** — export the current filtered view to CSV in one click 40 * **CSV export** — export the current filtered view to CSV in one click, including large catalogs 40 41 * **Dark mode** — switch between light and dark themes 41 * **Fast loading** — virtualized rendering handles thousands of products without the browser choking 42 * **Fast loading** — virtualized rendering handles thousands of products without the browser choking, with a configurable page size 43 * **Plays well with others** — products from third-party type plugins (such as bundles or subscriptions) appear in the grid with their common fields editable, and an optional setting re-fires WordPress save hooks so sync and cache plugins stay in step 42 44 43 45 = Built on proper WooCommerce APIs = … … 51 53 = Pro version = 52 54 53 If you need more — regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/).55 If you need more — paste from Excel/Google Sheets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/). 54 56 55 57 The free version is genuinely useful on its own. Pro is for stores that need more advanced bulk-editing workflows or fields the free version doesn’t touch. … … 95 97 = Can I undo changes? = 96 98 97 Edits are staged as pending changes in the grid before you save, so you can undo them or discard them entirely while they're unsaved. Once saved, changes go to the database and cannot be undone by the plugin itself — keep a backup, as you would with any bulk operation. (A proper edit history with rollback is planned for the Pro version.)99 Edits are staged as pending changes in the grid before you save, so you can undo them or discard them entirely while they're unsaved. Once saved, the free version can't undo them — keep a backup, as you would with any bulk operation. The Pro version adds Operation History, which records each save so you can review and restore previous field values (see below). 98 100 99 101 = Is there a Pro version? = 100 102 101 Yes. Pro adds regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, fine-grained selection tools for the current grid page, and more. It's available at [wpmelon.com](https://wpmelon.com/bulk-edit/). The free version is a complete, standalone tool — Pro extends it, it doesn't gate it. 103 Yes. Pro adds paste from Excel/Google Sheets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more. It's available at [wpmelon.com](https://wpmelon.com/bulk-edit/). The free version is a complete, standalone tool — Pro extends it, it doesn't gate it. 104 105 = What does Operation History store? (Pro) = 106 107 Operation History is a Pro feature, currently in beta and off by default. When you turn it on, each bulk save is recorded locally so you can review it later and restore previous field values. Each record stores the before and after values of the fields that save changed, the product and variation IDs involved, the user who made the change, and timestamps. The data lives in custom database tables on your own site and is never sent off-site. Records are kept for 30 days by default (configurable) and pruned automatically. You can turn recording on or off, clear the history at any time, and optionally have all history data removed when you uninstall Pro. The free version records nothing and creates no history tables. 102 108 103 109 = Does it support custom post types? = … … 128 134 == Changelog == 129 135 136 = 1.2.0 = 137 * Added Cost of Goods (COGS) as an editable product column when WooCommerce's Cost of Goods Sold feature is enabled. Edit it inline or with the bulk-edit panel, filter products by a Cost of Goods range, and include it in CSV exports. 138 * Added a Published on column — show it in the grid, edit its full date and time inline, and bulk-edit it — plus a read-only Modified on column. Both are available in CSV export. 139 * Products with a future Published on date now appear in the grid as "Scheduled" (and can be filtered by that status) instead of being hidden from the default view. 140 * The Title filter now matches all words by default, with a choice of how multi-word searches behave. When you type two or more words, a mode dropdown appears: "All words" (the default) finds titles containing every word in any order, "Any word" matches titles containing at least one word, and "Exact phrase" matches the words exactly as typed. 141 * You can now include product attribute columns (such as Color or Size) in the CSV export. Open the Export dialog and tick the attribute columns you want — each cell lists the product's terms separated by " | ". 142 * Your column layout and filters are now remembered automatically across page reloads and navigation — no need to save a view first. If you have a default view, it's still applied on load; otherwise your last working layout is restored. 143 * Added a Default view at the top of the Views menu that resets your columns and filters to their defaults in one click. The old per-view "Set as default" star is retired — your working layout now persists on its own, and any existing default view is still applied on load. 144 * Switching views (or to Default) with unsaved column or filter changes now asks whether to Save, Disregard, or Cancel, so a modified layout isn't lost by accident. On the Default view the prompt lets you name and save the changes as a new view before switching. 145 * The Views button now shows "· Modified" when your columns or filters differ from the selected view (or the Default). Use Update to save the changes back to the view, or Revert to discard them; on the Default view, "Save as new view" lets you keep them as a brand-new view. 146 * Added Menu order as a product column. View it in the grid, edit it inline or with the bulk-edit panel, filter products by a menu order range, sort the whole catalog by menu order, and include it in CSV exports. 147 * Added Upsells and Cross-sells as editable product columns. Pick linked products from a searchable modal (by title, SKU, GTIN, or product ID), reorder them — WooCommerce shows them in the order you set — and bulk-edit them across a selection with Replace all, Add, or Remove. Both columns are available in CSV export. Links pointing at deleted products are shown as a warning rather than silently discarded, so you decide when to remove them, and a link to a draft or disabled product is flagged so you know it won't appear on your storefront. Cross-sells are editable on simple and variable products only, matching WooCommerce, which hides the field on grouped and external products because cross-sells are promoted in the cart. 148 * More products-per-page choices (250, 500 and 1000), plus a "Custom…" option for any page size up to 10,000. Your page size is now saved as part of your views, so it comes back with a saved view and survives a reload. 149 * Added Sale From and Sale To date-range filters. Variable products are included when one of their variations matches the sale schedule you filtered for, and only the matching variations are shown. 150 * Added a Tags filter. Pick one or more product tags to narrow the grid to products carrying any of them; it combines with all other filters and applies to CSV exports too. 151 * Added the Button text field for External/Affiliate products. View and edit the add-to-cart button label inline, change it with the bulk-edit panel (set, prepend, append, find & replace, clear), and include it in CSV exports. 152 * Added an "Image uploaded" filter and grid/CSV column showing when each product's featured image was uploaded. Filter by a date range to find products whose images changed since a given date — useful when syncing images between sites. Variations are matched on their own images, and the column is available in the column picker and CSV exports. 153 * Export any saved grid view as a JSON file or copyable text, and import a view exported from another account or site. 154 * Added support for third-party product types. WooCommerce Product Bundles products now appear in the grid with full inline and bulk editing (prices, stock, categories, images, and more), and other plugin-registered types (subscriptions, composites, etc.) show up with a safe set of editable fields. The Type filter and the grid's type badges show every registered product type under its own plugin-provided name. Variable-style third-party products (such as variable subscriptions) expand to show their variations, just like native variable products. 155 * Added a "Refresh list after saving" setting. Turn it off to keep edited products visible in the grid after saving — even when they no longer match the active filters — until you reload the list. 156 * Added a Product URL column for External/Affiliate products — show it in the grid, edit it inline and in bulk (including find & replace for affiliate domain migrations), and export it to CSV. A new Product URL filter finds products by their link, with contains, does not contain, starts with, ends with, is empty and is not empty modes. 157 * The SKU filter now has a match-mode dropdown: Contains, Is exactly, Does not contain, Starts with, and Ends with. Comma-separate several SKUs to match any of them under the chosen mode (or none of them, for "Does not contain"), up to 200 values. The dropdown starts on Auto, which keeps the original behavior — a single SKU is a partial match, comma-separated SKUs are exact matches — so existing saved views and searches are unchanged. Also fixed: searching for the SKU "0" now filters products correctly instead of being ignored. 158 * Added a compatibility setting that triggers the standard WordPress save hooks after each save, so sync and integration plugins detect changes made in the editor. Find it under "Notify other plugins of changes" in Settings — it is off by default, and the info button beside it lists exactly which hooks are fired. 159 * Added Shipping class as a product column. View it in the grid, edit it inline or with the bulk-edit panel — including a "Same as parent" option on variations — filter products by shipping class, and include it in CSV exports. 160 * Renamed the Created product filter to Published on to align with WordPress/WooCommerce publishing terminology, and renamed Modified to Modified on for consistency. 161 * The Published on, Modified on, Sale from, and Sale to filters now appear in the filter panel only when their columns are shown in the grid, like the other filters. A date filter that has a value set stays visible even when its column is hidden. 162 * Updated the Pro upgrade dialog with the latest Pro features (undo & rollback, paste from Excel, WPML support) and current early-bird pricing for annual and lifetime licenses. 163 * Fixed searching by a variation's SKU returning no results. When variations are shown in the grid, a SKU search now matches variation SKUs and displays the matching variation under its parent. Searching a product's own SKU keeps all of its variations visible. 164 * Fixed bulk-edit searches and saves failing after the page was left open for a long time. The page now refreshes its security token automatically, and shows a clear "session expired or permissions changed — reload" message in the rare case it can't recover. 165 * Fixed several Default-view and layout issues: selecting Default now restores columns you had hidden back to their factory visibility; deleting the view you're currently on returns you to Default and flags the layout as modified when it differs, instead of leaving a stale layout mislabeled as clean; and switching views no longer shows columns from your previous layout when "auto-load products" is turned off. 166 * Fixed the Views "· Modified" indicator disappearing when you chose "Update with current state" on a view other than the one currently selected. 167 * Fixed the Views "· Modified" indicator misbehaving: reverting a column change now clears it (previously it could stay stuck on), and re-selecting the same categories or filters in a different order no longer flags the layout as modified. 168 * Fixed the selected view being lost after a page reload — the active view is now correctly remembered instead of quietly reverting to Default on the next load. 169 * Fixed saving a new view before the product grid has loaded (with auto-load off) storing an empty column layout — a new view now captures your current columns in that case. 170 * Fixed column layout changes being silently discarded when updating a view while product auto-loading is turned off — the view now saves your columns, and the layout no longer keeps showing as modified afterward. 171 * Cancelling a bulk save (or restore) while a batch was mid-request no longer reports that in-progress batch as a failed save. For saves, the items in it are now shown as not attempted. 172 * Deleting the plugin now removes its settings and per-user interface preferences from the database, instead of leaving them behind. 173 * Clicking Stop during a save now cancels the in-progress request immediately, instead of only skipping the batches that hadn't started yet. Rows that still finished saving on the server are reconciled afterward, so they no longer linger as unsaved. 174 * Pressing Stop during a save no longer hides a real login or permission failure. If your session expired or your permissions changed mid-save, you now get the reload prompt instead of a misleading "save stopped" message, so you know the remaining products weren't saved and why. 175 * Save-stopped notifications are now translatable. The messages shown when you stop a save part-way through — including the counts of what was saved, what's still pending, and any per-product errors — now appear in your site's language. 176 * When a save (or a Pro Operation History restore) is rejected because you are no longer authenticated and the site replies with an HTTP 401 error (e.g. Application Passwords or an authentication plugin), the session-expired warning now appears — including when Stop was pressed mid-operation — instead of the failure looking like a cancelled run. Rejected save/restore requests also now show the actual server error message instead of the generic "Unknown server error" text, which is now translatable. 177 * Turning off "Manage stock" now discards any staged Stock quantity / Backorders edits for that product no matter how the change is made — inline edit, fill handle, bulk edit, or paste — so stale stock values can no longer be saved once stock management is switched off. 178 * Fixed an error thrown when clicking "Clear all filters" on the "No products match your filters" screen. 179 * Switching to another app or browser tab while editing a cell no longer closes the editor. When you come back, the cell is still in edit mode with the cursor where you left it — you can keep typing, or paste a copied value straight into the cell. 180 * Enter-to-edit-the-next-row and the variation Select/Deselect buttons no longer stop working after a load error, an empty filter result, or when auto-loading is disabled. 181 * Fixed a Cost of Goods filter issue where a product that had been converted from variable to simple could wrongly appear in the filtered results when a leftover hidden variation matched the filter. 182 * Fixed the Menu order filter, SKU search, and Description search wrongly including a product that had been converted from variable to simple when a leftover hidden variation matched the filter. 183 * Fixed date picker calendar popups following the operating system's light/dark theme instead of the plugin's selected theme. 184 * Fixed toggling "Include Variations" reverting the grid's column layout (hidden columns, widths, pinning) to an older state and incorrectly marking the loaded view as "Modified". Also fixed Settings → "Reset layout" not restoring the default column visibility (previously hidden columns could stay hidden after a reset). 185 * Fixed CSV exports failing on large product sets with many columns selected. The file is now built page by page and caches are released as it goes, so the export uses far less server memory. When an export does fail, the error now names the cause and status (for example "Export failed (500): …") instead of a generic "Export failed". 186 187 = 1.0.1 = 188 * Added a Bulk Edit shortcut to the WordPress admin bar for quick access to the editor from anywhere in wp-admin. It can be turned off per user from the Settings menu and is on by default. 189 * Fixed tall popovers overflowing past the bottom of the screen on short browser windows; they now fit within the visible area and scroll internally. 190 * Fixed the bulk edit page not scrolling on short browser windows, which left the toolbar, pagination, and lower admin menu items clipped and unreachable. 191 * Fixed form fields rendering with light backgrounds in dark mode inside popover panels (filters, Selection Manager, dropdowns), and dark-mode styling for date and time inputs — native calendar/clock icons and picker popups now follow the dark theme too. 192 * Fixed the horizontal scrollbar floating in the middle of the grid instead of staying at the bottom after collapsing or expanding the Filters or Bulk Edit panel. 193 130 194 = 1.0.0 = 131 195 * Initial release. … … 133 197 == Upgrade Notice == 134 198 199 = 1.2.0 = 200 Major feature release: upsells/cross-sells, shipping class, product URL and button text editing, tags and sale-date filters, view import/export, SKU match modes, third-party product types, and many fixes. 201 135 202 = 1.0.0 = 136 203 Initial release. -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/wpmelon-advanced-bulk-edit-for-woocommerce.php
r3549389 r3663491 4 4 * Plugin URI: https://wpmelon.com/bulk-edit/ 5 5 * Description: Excel-like bulk editing interface for WooCommerce products 6 * Version: 1. 0.06 * Version: 1.2.0 7 7 * Author: WPMelon 8 8 * Author URI: https://wpmelon.com/ … … 10 10 * Domain Path: /languages 11 11 * Requires at least: 6.0 12 * Tested up to: 7. 012 * Tested up to: 7.1 13 13 * Requires PHP: 8.0 14 14 * Requires Plugins: woocommerce 15 15 * WC requires at least: 10.0 16 * WC tested up to: 1 0.716 * WC tested up to: 11.0.1 17 17 * License: GPLv2 or later 18 18 * License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 126 126 define( 'WCABE_G2_LOADED', true ); 127 127 128 define( 'WCABE_G2_VERSION', '1. 0.0' );128 define( 'WCABE_G2_VERSION', '1.2.0' ); 129 129 define( 'WCABE_G2_PLUGIN_FILE', __FILE__ ); 130 130 define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)