Plugin Directory

Changeset 3663491


Ignore:
Timestamp:
08/24/2026 01:03:28 PM (2 months ago)
Author:
wpmelon
Message:

Release 1.2.0

Location:
wpmelon-advanced-bulk-edit-for-woocommerce
Files:
10 added
4 deleted
36 edited
1 copied

Legend:

Unmodified
Added
Removed
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/assets/dist/.vite/manifest.json

    r3549389 r3663491  
    55  },
    66  "assets/src/main.tsx": {
    7     "file": "assets/main-CPlqEen8.js",
     7    "file": "assets/main-V9rY9XMr.js",
    88    "name": "main",
    99    "src": "assets/src/main.tsx",
    1010    "isEntry": true,
    1111    "css": [
    12       "assets/main-DuaYQxSj.css"
     12      "assets/main-D5Lgb2qa.css"
    1313    ],
    1414    "assets": [
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Admin.php

    r3549389 r3663491  
    2020                        'wcabe-g2',
    2121                        [ $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                        ]
    2255                );
    2356        }
    … …  
    5184                        'dimensionUnit'     => get_option( 'woocommerce_dimension_unit', 'cm' ),
    5285                        '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() ),
    5393                        '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(),
    54103                        '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(),
    55109                        'pluginSettings'    => REST\Settings_Controller::get_all(),
    56110                        '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(),
    57114                ];
    58115
    … …  
    111168                        'wcabe-g2-admin',
    112169                        $dist_url . $entry['file'],
    113                         [ 'wp-element', 'wp-i18n' ],
     170                        [ 'wp-element', 'wp-i18n', 'wp-api-fetch' ],
    114171                        WCABE_G2_VERSION,
    115172                        true
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Handlers/Attribute_Handler.php

    r3549389 r3663491  
    4646
    4747        /**
    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.
    5053         */
    5154        private function ensure_term_maps(): void {
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Handlers/Attribute_Save_Handler.php

    r3549389 r3663491  
    100100         */
    101101        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
    102110                $lookup = self::get_field_lookup();
    103111
    … …  
    164172         */
    165173        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
    166180                $this->new_terms_created = false;
    167181                $lookup     = self::get_field_lookup();
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Helpers/Product_Helper.php

    r3549389 r3663491  
    1313
    1414        /**
     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        /**
    15167         * Format a WC_Product into a flat array for the grid.
    16168         *
    17169         * @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).
    18175         * @return array
    19176         */
    20         public static function format_product( \WC_Product $product ): array {
     177        public static function format_product( \WC_Product $product, ?array $ref_map = null ): array {
    21178                $image_id = $product->get_image_id();
    22179                $thumbnail_url = $image_id
    … …  
    24181                        : '';
    25182
     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.
    26186                $variation_count = 0;
    27                 if ( $product->is_type( 'variable' ) ) {
     187                if ( $product instanceof \WC_Product_Variable ) {
    28188                        $variation_count = count( $product->get_children() );
    29189                }
    … …  
    37197                $tags = self::resolve_term_names( $product->get_tag_ids(), 'product_tag' );
    38198
    39                 // Sale dates — not on variable parents.
     199                // Sale dates — not on variable parents (incl. variable subclasses).
    40200                $date_on_sale_from = '';
    41201                $date_on_sale_to   = '';
    42                 if ( ! $product->is_type( 'variable' ) ) {
     202                if ( ! $product instanceof \WC_Product_Variable ) {
    43203                        $from = $product->get_date_on_sale_from();
    44204                        $to   = $product->get_date_on_sale_to();
    … …  
    46206                        $date_on_sale_to   = $to ? $to->date( 'Y-m-d' ) : '';
    47207                }
     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();
    48215
    49216                $gallery_ids  = $product->get_gallery_image_ids();
    … …  
    54221                                $gallery_urls[] = $url ?: '';
    55222                        }
     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 ) );
    56233                }
    57234
    … …  
    77254                        'categories'             => $categories,
    78255                        '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 ),
    79261                        'description'            => $product->get_description(),
    80262                        'short_description'      => $product->get_short_description(),
    … …  
    89271                        'downloadable'           => $product->get_downloadable(),
    90272                        '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 ),
    91312                ];
    92313
    … …  
    96317        /**
    97318         * 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.
    98323         *
    99324         * @param int[]  $term_ids
    … …  
    101326         * @return array Array of [ 'id' => int, 'name' => string ].
    102327         */
    103         private static function resolve_term_names( array $term_ids, string $taxonomy ): array {
     328        public static function resolve_term_names( array $term_ids, string $taxonomy ): array {
    104329                if ( empty( $term_ids ) ) {
    105330                        return [];
    … …  
    126351
    127352        /**
     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        /**
    128642         * Format a WC_Product_Variation into a flat array for the grid.
    129643         *
    … …  
    141655                $to   = $variation->get_date_on_sale_to();
    142656
    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 );
    172658
    173659                $data = [
    … …  
    199685                        'downloadable'           => $variation->get_downloadable(),
    200686                        '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 ),
    201703                ];
    202704
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Main.php

    r3549389 r3663491  
    2929                $admin = new Admin();
    3030                add_action( 'admin_menu', [ $admin, 'register_menu' ] );
     31                add_action( 'admin_bar_menu', [ $admin, 'add_admin_bar_link' ], 99 );
    3132                add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
    3233
    … …  
    4041                // Debug tracing — appends server metrics to REST responses when enabled.
    4142                new Handlers\Debug_Trace_Handler();
     43
     44                // WordPress save-hook compatibility — Free feature, controlled by plugin setting.
     45                new Handlers\WP_Save_Hooks_Handler();
    4246        }
    4347
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Export_Controller.php

    r3549389 r3663491  
    1111use WCABE_G2\Helpers\Product_Helper;
    1212use WCABE_G2\Services\Export_Service;
     13use WCABE_G2\Services\Filter_Service;
    1314use WCABE_G2\Services\Product_Service;
     15use WCABE_G2\Services\View_Service;
    1416
    1517defined( 'ABSPATH' ) || exit;
    … …  
    4143                                                'default'           => [],
    4244                                                '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).
    4348                                                'sanitize_callback' => function ( $value ) {
    4449                                                        if ( ! is_array( $value ) ) {
    4550                                                                return [];
    4651                                                        }
    47                                                         return array_filter( array_map( 'absint', $value ) );
     52                                                        return Product_Helper::normalize_product_ids( $value );
    4853                                                },
    4954                                        ],
    … …  
    5964                                                'sanitize_callback' => 'sanitize_text_field',
    6065                                        ],
     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                                        ],
    6173                                        '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' ],
    6598                                        ],
    6699                                        'category'           => [
    … …  
    69102                                                'sanitize_callback' => 'sanitize_text_field',
    70103                                        ],
     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                                        ],
    71109                                        'type'               => [
    72110                                                'description'       => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    84122                                                'sanitize_callback' => 'sanitize_text_field',
    85123                                        ],
     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                                        ],
    86134                                        'price_min'          => [
    87135                                                'description'       => __( 'Minimum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    104152                                                'sanitize_callback' => 'sanitize_text_field',
    105153                                        ],
     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                                        ],
    106192                                        'manage_stock'       => [
    107193                                                'description'       => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    134220                                        'date_modified_before' => [
    135221                                                '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' ),
    136258                                                'type'              => 'string',
    137259                                                'sanitize_callback' => 'sanitize_text_field',
    … …  
    183305         * Export products as CSV.
    184306         *
    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.
    186312         * 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.
    187326         *
    188327         * @param \WP_REST_Request $request
    … …  
    190329         */
    191330        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
    236355                        return new \WP_REST_Response(
    237356                                [
    … …  
    243362                }
    244363
    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                }
    253378
    254379                header( 'Content-Type: text/csv; charset=UTF-8' );
    … …  
    260385                header( 'Cache-Control: no-cache, no-store, must-revalidate' );
    261386
    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 );
    264391                exit;
    265392        }
    266393
    267394        /**
     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        /**
    268479         * 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.
    269484         *
    270485         * @param \WP_REST_Request $request
    271486         * @return array
    272487         */
    273         private function extract_filters( \WP_REST_Request $request ): array {
     488        protected function extract_filters( \WP_REST_Request $request ): array {
    274489                $filters = [];
    275490
    … …  
    278493                        $filters['search_name'] = sanitize_text_field( $search_name );
    279494                }
    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.
    285525                $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;
    288535                }
    289536
    … …  
    303550                }
    304551
     552                $filters = Filter_Service::apply_shipping_class_param(
     553                        $request->get_param( 'shipping_class' ),
     554                        $filters
     555                );
     556
    305557                $price_min = $request->get_param( 'price_min' );
    306558                if ( null !== $price_min && '' !== $price_min ) {
    … …  
    313565                }
    314566
     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
    315581                $stock_qty_min = $request->get_param( 'stock_qty_min' );
    316582                if ( null !== $stock_qty_min && '' !== $stock_qty_min ) {
    … …  
    323589                }
    324590
     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
    325601                $manage_stock = $request->get_param( 'manage_stock' );
    326602                if ( ! empty( $manage_stock ) ) {
    … …  
    333609                }
    334610
    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                ];
    336623                foreach ( $date_params as $param ) {
    337624                        $val = $request->get_param( $param );
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Products_Controller.php

    r3549389 r3663491  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Page_Size_Helper;
    1011use WCABE_G2\Helpers\Permission_Helper;
     12use WCABE_G2\Helpers\Product_Helper;
     13use WCABE_G2\Services\Filter_Service;
    1114use WCABE_G2\Services\Product_Service;
     15use WCABE_G2\Services\View_Service;
    1216
    1317defined( 'ABSPATH' ) || exit;
    … …  
    3034                                'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ],
    3135                                '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                                ],
    3289                        ],
    3390                ] );
    … …  
    61118                                                ],
    62119                                        ],
     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                                        ],
    63127                                ],
    64128                        ],
    65129                ] );
     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 );
    66151        }
    67152
    … …  
    72157        public function get_items( $request ): \WP_REST_Response {
    73158                $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' ) );
    75163                $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 memory
    81                  * exhaustion on shared hosting when variations are expanded. Both Free
    82                  * and Pro honor this default — Pro does not raise it. Site admins on
    83                  * 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 );
    89164
    90165                // Build filters array from request params.
    … …  
    95170                        $filters['search_name'] = sanitize_text_field( $search_name );
    96171                }
    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;
    100184                }
    101185                $search_description = $request->get_param( 'search_description' );
    … …  
    117201                }
    118202
     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".
    119219                $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;
    122229                }
    123230
    … …  
    137244                }
    138245
     246                $filters = Filter_Service::apply_shipping_class_param(
     247                        $request->get_param( 'shipping_class' ),
     248                        $filters
     249                );
     250
    139251                $price_min = $request->get_param( 'price_min' );
    140252                if ( null !== $price_min && '' !== $price_min ) {
    … …  
    147259                }
    148260
     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
    149275                $stock_qty_min = $request->get_param( 'stock_qty_min' );
    150276                if ( null !== $stock_qty_min && '' !== $stock_qty_min ) {
    … …  
    157283                }
    158284
     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
    159295                $manage_stock = $request->get_param( 'manage_stock' );
    160296                if ( ! empty( $manage_stock ) ) {
    … …  
    167303                }
    168304
    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                ];
    170317                foreach ( $date_params as $param ) {
    171318                        $val = $request->get_param( $param );
    … …  
    202349                $filters = apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request );
    203350
     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
    204356                $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 );
    206358
    207359                $response = rest_ensure_response( $data );
    … …  
    240392                }
    241393
     394                $batch_id = $request->get_param( 'batch_id' );
     395
    242396                $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                );
    244401
    245402                return rest_ensure_response( [ 'results' => $results ] );
    … …  
    281438                                'description'       => __( 'Maximum number of items per page.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    282439                                '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' ],
    288449                        ],
    289450                        'include_variations' => [
    … …  
    293454                                'sanitize_callback' => 'rest_sanitize_boolean',
    294455                        ],
     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                        ],
    295468                        'search_name' => [
    296469                                'description'       => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    298471                                'sanitize_callback' => 'sanitize_text_field',
    299472                        ],
     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                        ],
    300480                        '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 ),
    303491                                'sanitize_callback' => 'sanitize_text_field',
    304492                        ],
    … …  
    330518                                'sanitize_callback' => 'sanitize_text_field',
    331519                        ],
     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                        ],
    332534                        'category' => [
    333535                                'description'       => __( 'Comma-separated category term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    335537                                'sanitize_callback' => 'sanitize_text_field',
    336538                        ],
     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                        ],
    337544                        'type' => [
    338545                                'description'       => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    349556                                'type'              => 'string',
    350557                                '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',
    351568                        ],
    352569                        'price_min' => [
    … …  
    378595                                },
    379596                        ],
     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                        ],
    380625                        'stock_qty_min' => [
    381626                                'description'       => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    406651                                },
    407652                        ],
     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                        ],
    408683                        'manage_stock' => [
    409684                                'description'       => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    436711                        'date_modified_before' => [
    437712                                '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' ),
    438749                                'type'              => 'string',
    439750                                'sanitize_callback' => 'sanitize_text_field',
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Settings_Controller.php

    r3549389 r3663491  
    2626                ],
    2727                'debug_tracing' => [
     28                        'type'    => 'boolean',
     29                        'default' => false,
     30                ],
     31                'trigger_wp_save_hooks' => [
    2832                        'type'    => 'boolean',
    2933                        'default' => false,
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/User_Settings_Controller.php

    r3549389 r3663491  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Page_Size_Helper;
    1011use WCABE_G2\Helpers\Permission_Helper;
    1112use WCABE_G2\Services\View_Service;
    … …  
    2728         */
    2829        private const SCHEMA = [
    29                 'pageSize'            => [ 'type' => 'integer', 'default' => 100 ],
     30                'pageSize'            => [ 'type' => 'integer', 'default' => Page_Size_Helper::DEFAULT_PAGE_SIZE ],
    3031                'includeVariations'   => [ 'type' => 'boolean', 'default' => false ],
    3132                'darkMode'            => [ 'type' => 'boolean', 'default' => false ],
    … …  
    3435                'imagePreviewEnabled' => [ 'type' => 'boolean', 'default' => true ],
    3536                '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 ],
    3651        ];
    3752
    … …  
    5873
    5974        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
    60110                $current = self::get_all();
    61                 $body    = $request->get_json_params();
    62111
    63112                foreach ( self::SCHEMA as $key => $schema ) {
    … …  
    75124                                case 'integer':
    76125                                        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 );
    80127                                        }
    81128                                        // else: no other integer fields defined. Future integer fields
    … …  
    102149                                        }
    103150                                        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;
    104171                        }
    105172                }
    106173
     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
    107180                update_user_meta( get_current_user_id(), self::META_KEY, $current );
    108181
    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;
    110201        }
    111202
    … …  
    126217                }
    127218
    128                 // Defensive clamp on read. The schema and update_settings() already
    129                 // enforce the bound on writes, but values written to user meta outside
    130                 // this controller (legacy installs, future schema changes, direct DB
    131                 // 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.
    132223                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'] );
    136225                }
    137226
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/REST/Views_Controller.php

    r3549389 r3663491  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Page_Size_Helper;
    1011use WCABE_G2\Helpers\Permission_Helper;
    1112use WCABE_G2\Services\View_Service;
    … …  
    3839                        );
    3940                }
     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                }
    4051                return true;
    4152        }
    … …  
    6778                                                'type'     => 'array',
    6879                                        ],
     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                                        ],
    6988                                        'is_default' => [
    7089                                                'required' => false,
    … …  
    100119                                                'type'     => 'array',
    101120                                        ],
     121                                        'page_size' => [
     122                                                'required'          => false,
     123                                                'type'              => 'integer',
     124                                                'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ],
     125                                        ],
    102126                                        'is_default' => [
    103127                                                'required' => false,
    … …  
    144168                        'filters'      => $request->get_param( 'filters' ),
    145169                        'column_state' => $request->get_param( 'column_state' ),
     170                        'page_size'    => $request->get_param( 'page_size' ),
    146171                        'is_default'   => $request->get_param( 'is_default' ),
    147172                ];
    … …  
    181206                        $data['column_state'] = $request->get_param( 'column_state' );
    182207                }
     208                if ( null !== $request->get_param( 'page_size' ) ) {
     209                        $data['page_size'] = $request->get_param( 'page_size' );
     210                }
    183211                if ( null !== $request->get_param( 'is_default' ) ) {
    184212                        $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  
    88namespace WCABE_G2\Services;
    99
     10use WCABE_G2\Helpers\Product_Helper;
     11
    1012defined( 'ABSPATH' ) || exit;
    1113
    … …  
    1820         */
    1921        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;
    2031
    2132        /**
    … …  
    4455                'regular_price'      => 'Regular Price',
    4556                'sale_price'         => 'Sale Price',
     57                'cogs_value'         => 'Cost of Goods',
    4658                'stock_quantity'     => 'Stock Qty',
    4759                'stock_status'       => 'Stock Status',
    … …  
    5870                'date_on_sale_from'  => 'Sale From',
    5971                'date_on_sale_to'    => 'Sale To',
     72                'date_created'       => 'Published on',
     73                'date_modified'      => 'Modified on',
     74                'image_date'         => 'Image uploaded',
    6075                'featured'           => 'Featured',
    6176                'virtual'            => 'Virtual',
    6277                'downloadable'       => 'Downloadable',
    6378                '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',
    6485        ];
    6586
    6687        /**
    6788         * 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.
    6894         *
    6995         * @param array  $products    Array of formatted product arrays.
    … …  
    7298         * @param array  $units       Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ].
    7399         * @return string CSV content.
     100         * @throws \RuntimeException When the temporary stream cannot be opened, written, rewound or read.
    74101         */
    75102        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 {
    76150                /**
    77151                 * Allow extensions (Pro) to declare additional export columns.
    … …  
    81155                $this->pro_columns = apply_filters( 'wcabe_g2_export_extra_columns', [] );
    82156
    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 {
    84207                if ( ! empty( $columns ) ) {
    85208                        $pro_columns = $this->pro_columns;
    … …  
    94217                }
    95218
    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 {
    97230                $weight_unit    = $units['weight_unit'] ?? '';
    98231                $dimension_unit = $units['dimension_unit'] ?? '';
    … …  
    121254                }
    122255
    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                }
    147278        }
    148279
    … …  
    191322                                return '';
    192323
     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
    193361                        case 'manage_stock':
    194362                        case 'featured':
    … …  
    203371                                return (string) $value;
    204372
     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
    205378                        case 'description':
    206379                        case 'short_description':
    207380                                return wp_strip_all_tags( (string) $value );
    208381
     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
    209400                        default:
    210401                                return (string) $value;
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/Filter_Service.php

    r3549389 r3663491  
    88namespace WCABE_G2\Services;
    99
     10use WCABE_G2\Helpers\Product_Helper;
     11
    1012defined( 'ABSPATH' ) || exit;
    1113
    1214class 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        }
    1358
    1459        /**
    … …  
    2166                        'categories'     => $this->get_category_tree(),
    2267                        'tags'           => $this->get_tags(),
    23                         'types'          => [ 'simple', 'variable', 'grouped', 'external' ],
     68                        'types'          => $this->get_product_type_options(),
    2469                        'statuses'       => [ 'publish', 'draft', 'pending', 'private' ],
    2570                        '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(),
    2675                ];
    2776
    2877                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;
    29109        }
    30110
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/Operations_Service.php

    r3549389 r3663491  
    6969         * Duplicate one or more products.
    7070         *
     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         *
    7176         * @param int[] $product_ids Source product IDs.
    7277         * @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[] }
    7479         */
    7580        public function duplicate_products( array $product_ids, int $count = 1 ): array {
    … …  
    7984
    8085                $products           = [];
     86                $fresh_products     = [];
    8187                $failed             = [];
    8288                $total_var_failures = 0;
    … …  
    8490                if ( empty( $product_ids ) ) {
    8591                        return [
    86                                 'success'  => true,
    87                                 'products' => [],
    88                                 'failed'   => [],
     92                                'success'     => true,
     93                                'created_ids' => [],
     94                                'products'    => [],
     95                                'failed'      => [],
    8996                        ];
    9097                }
    … …  
    126133                                $duplicate->set_tag_ids( $source->get_tag_ids() );
    127134                                $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' ) );
    130141
    131142                                if ( $source->is_type( 'variable' ) ) {
    … …  
    144155                                        }
    145156
    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.
    147161                                        $fresh = wc_get_product( $duplicate->get_id() );
    148162                                        if ( $fresh ) {
    149                                                 $products[] = Product_Helper::format_product( $fresh );
     163                                                $fresh_products[] = $fresh;
    150164                                        }
    151165                                } catch ( \Exception $e ) {
    … …  
    164178                }
    165179
     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
    166225                $result = [
    167                         'success'  => true,
    168                         'products' => $products,
    169                         'failed'   => $failed,
     226                        'success'     => true,
     227                        'created_ids' => $created_ids,
     228                        'products'    => $products,
     229                        'failed'      => $failed,
    170230                ];
    171231
     232                $warnings = [];
     233
    172234                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;
    180254                }
    181255
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/includes/Services/Product_Service.php

    r3549389 r3663491  
    88namespace WCABE_G2\Services;
    99
     10use WCABE_G2\Handlers\WP_Save_Hooks_Handler;
    1011use WCABE_G2\Helpers\Product_Helper;
    1112
    … …  
    1314
    1415class 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';
    15131
    16132        /**
    … …  
    20136                'simple'   => [
    21137                        'name', 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility',
     138                        'cogs_value',
     139                        'date_created',
    22140                        'categories', 'tags', 'description', 'short_description',
    23                         'weight', 'length', 'width', 'height',
     141                        'weight', 'length', 'width', 'height', 'shipping_class',
    24142                        'date_on_sale_from', 'date_on_sale_to',
    25143                        'featured', 'virtual', 'downloadable',
    26144                        'manage_stock', 'backorders',
    27145                        'image_id', 'gallery_image_ids',
     146                        'upsell_ids', 'cross_sell_ids',
     147                        'menu_order',
    28148                ],
    29149                'variable' => [
    30150                        'name', 'sku', 'status', 'catalog_visibility',
     151                        'cogs_value',
     152                        'date_created',
    31153                        'categories', 'tags', 'description', 'short_description',
    32                         'weight', 'length', 'width', 'height',
     154                        'weight', 'length', 'width', 'height', 'shipping_class',
    33155                        'featured',
    34156                        'manage_stock', 'stock_quantity', 'stock_status', 'backorders',
    35157                        'image_id', 'gallery_image_ids',
     158                        'upsell_ids', 'cross_sell_ids',
     159                        'menu_order',
    36160                ],
    37161                'variation' => [
    38162                        'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status',
     163                        'cogs_value',
    39164                        'description',
    40                         'weight', 'length', 'width', 'height',
     165                        'weight', 'length', 'width', 'height', 'shipping_class',
    41166                        'date_on_sale_from', 'date_on_sale_to',
    42167                        'virtual', 'downloadable',
    43168                        'manage_stock', 'backorders',
    44169                        'image_id',
     170                        'menu_order',
    45171                ],
    46172                'grouped'  => [
    47173                        'name', 'sku', 'status', 'catalog_visibility',
     174                        'date_created',
    48175                        'categories', 'tags', 'description', 'short_description',
    49176                        'featured',
    50177                        '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',
    51184                ],
    52185                'external' => [
    53186                        'name', 'sku', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility',
     187                        'cogs_value',
     188                        'date_created',
    54189                        'categories', 'tags', 'description', 'short_description',
    55190                        'weight', 'length', 'width', 'height',
    … …  
    57192                        'featured', 'virtual', 'downloadable',
    58193                        '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',
    59201                ],
     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',
    60240        ];
    61241
    … …  
    84264
    85265        /**
     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        /**
    86312         * Get paginated products.
    87313         *
    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).
    92320         * @return array
    93321         */
    … …  
    96324                int $per_page = 100,
    97325                bool $include_variations = false,
    98                 array $filters = []
     326                array $filters = [],
     327                string $orderby = 'id',
     328                string $order = 'desc'
    99329        ): 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 );
    101331
    102332                // Allow Pro to prime caches before per-product formatting.
    … …  
    104334                do_action( 'wcabe_g2_before_format_products', $product_ids );
    105335
    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 = [];
    110342                foreach ( $query_result['products'] as $product ) {
    111343                        $gids = $product->get_gallery_image_ids();
    112344                        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                );
    121375
    122376                // Interleave variations if requested.
    … …  
    128382                        $variation_ids_to_prime = [];
    129383                        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 ) {
    131387                                        $children = $product->get_children();
    132388                                        $children_map[ $product->get_id() ] = $children;
    … …  
    198454                }
    199455
    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 ) {
    201460                        return new \WP_Error(
    202461                                'not_variable',
    … …  
    236495
    237496        /**
     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        /**
    238875         * Batch update products.
    239876         *
    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.
    241881         * @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 {
    244891                $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                }
    245912
    246913                foreach ( $items as $item ) {
    … …  
    259926                        }
    260927
     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
    261933                        try {
    262934                                $product = wc_get_product( $id );
    … …  
    274946
    275947                                $type = $product->get_type();
    276                                 $allowed_fields = self::ALLOWED_FIELDS[ $type ] ?? [];
    277                                 $allowed_fields = apply_filters( 'wcabe_g2_allowed_fields', $allowed_fields, $product );
    278948
    279949                                // 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'];
    282953                                if ( ! empty( $validation_errors ) ) {
    283954                                        $results[] = [
    … …  
    305976                                }
    306977
    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 );
    308985
    309986                                $product->save();
    310987
    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                                }
    3151006
    3161007                                $result = [
    3171008                                        'id'      => $id,
    3181009                                        'success' => true,
    319                                         'data'    => $data,
     1010                                        'data'    => null, // Filled in phase 2 below.
    3201011                                ];
    3211012
    … …  
    3251016
    3261017                                $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;
    3271022
    3281023                        } catch ( \Exception $e ) {
    … …  
    3341029                                        ],
    3351030                                ];
     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                                }
    3361113                        }
    3371114                }
    3381115
    3391116                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                ];
    3401151        }
    3411152
    … …  
    3601171                        }
    3611172
    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 ) ) {
    3641175                                $sanitized = trim( (string) $value );
    3651176                                if ( '' !== $sanitized ) {
    … …  
    3831194                                                'value'   => $value,
    3841195                                                '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' ),
    3851246                                        ];
    3861247                                }
    … …  
    4121273                                        }
    4131274                                }
     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                                }
    4141309                        } elseif ( in_array( $field, [ 'date_on_sale_from', 'date_on_sale_to' ], true ) ) {
    4151310                                // Date validation — must be empty or Y-m-d format.
    … …  
    4771372                                        // Empty array is valid — this is the "clear gallery" path.
    4781373                                        foreach ( $value as $gid ) {
    479                                                 if ( ! is_numeric( $gid ) || (int) $gid <= 0 ) {
     1374                                                if ( ! self::is_positive_int_id( $gid ) ) {
    4801375                                                        $errors[] = [
    4811376                                                                'field'   => $field,
    4821377                                                                'value'   => $value,
    4831378                                                                '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' ),
    4841403                                                        ];
    4851404                                                        break;
    … …  
    5561475                                        ];
    5571476                                }
     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                                }
    5581517                        }
    5591518                }
    … …  
    5791538                        case 'sku':
    5801539                                $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                                }
    5811573                                break;
    5821574
    … …  
    5941586                                break;
    5951587
     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
    5961600                        case 'stock_quantity':
    5971601                                if ( null === $value || '' === $value ) {
    … …  
    6001604                                        $product->set_stock_quantity( wc_stock_amount( $value ) );
    6011605                                }
     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 );
    6021613                                break;
    6031614
    … …  
    6781689                                break;
    6791690
     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
    6801703                        case 'date_on_sale_from':
    6811704                                $sanitized = sanitize_text_field( (string) $value );
    … …  
    7261749                                $product->set_gallery_image_ids( $ids );
    7271750                                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 ) );
    7291875        }
    7301876
    … …  
    7881934
    7891935        /**
     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        /**
    7901962         * Get products via WooCommerce CRUD API.
    7911963         *
    … …  
    7941966         * @param array $filters Optional filter criteria.
    7951967         * @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).
    7991973         * @return array{ products: \WC_Product[]|int[], total: int, total_pages: int }
    8001974         */
    … …  
    8041978                array $filters = [],
    8051979                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'
    8071983        ): 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
    8082004                $args = [
    8092005                        'limit'    => $per_page,
    8102006                        'page'     => $page,
    811                         'orderby'  => 'ID',
    812                         'order'    => 'DESC',
     2007                        'orderby'  => $wc_orderby,
     2008                        'order'    => $wc_order,
    8132009                        'paginate' => true,
    8142010                ];
    … …  
    8242020                        $args['return']   = 'ids';
    8252021
     2022                        $variable_types = self::get_variable_product_types();
    8262023                        if ( ! empty( $filters['type'] ) ) {
    8272024                                $intersection = array_values(
    828                                         array_intersect( (array) $filters['type'], [ 'variable' ] )
     2025                                        array_intersect( (array) $filters['type'], $variable_types )
    8292026                                );
    8302027                                if ( empty( $intersection ) ) {
    … …  
    8372034                                $filters['type'] = $intersection;
    8382035                        } else {
    839                                 $filters['type'] = [ 'variable' ];
     2036                                $filters['type'] = $variable_types;
    8402037                        }
    8412038                }
    … …  
    8442041                $args['status'] = ! empty( $filters['status'] )
    8452042                        ? (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).
    8492050                $args['type'] = ! empty( $filters['type'] )
    8502051                        ? (array) $filters['type']
    851                         : [ 'simple', 'variable', 'grouped', 'external' ];
     2052                        : self::get_known_product_types();
    8522053
    8532054                // ID filter — simple comparison or expression (CSV + ranges).
    … …  
    8922093                // Search filters — handled via posts_join/posts_where hooks below.
    8932094                $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'] ?? '' );
    8952104                $search_description_term       = ! empty( $filters['search_description'] )       ? sanitize_text_field( $filters['search_description'] )       : '';
    8962105                $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 );
    8972114
    8982115                // Category filter — exact matching (include_children => false).
    … …  
    9012118                }
    9022119
     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
    9032127                // Stock status filter.
    9042128                if ( ! empty( $filters['stock_status'] ) ) {
    … …  
    9062130                }
    9072131
     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
    9082144                // Price range filters — passed as custom args, handled in handle_custom_query_args.
    9092145                if ( isset( $filters['price_min'] ) && '' !== $filters['price_min'] ) {
    … …  
    9122148                if ( isset( $filters['price_max'] ) && '' !== $filters['price_max'] ) {
    9132149                        $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                        }
    9142162                }
    9152163
    … …  
    9432191                if ( $modified_after || $modified_before ) {
    9442192                        $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;
    9452221                }
    9462222
    … …  
    9782254
    9792255                $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'] );
    9802257                $has_manage_stock_filter = ! empty( $args['wcabe_manage_stock'] );
    9812258                $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 );
    9852264
    9862265                $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations ): array {
    … …  
    10612340                        }
    10622341
     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
    10632475                        // Stock quantity range filtering via _stock meta.
    10642476                        // When include_variations is false, standard meta_query applies.
    … …  
    11442556                $search_where_callback = null;
    11452557
    1146                 if ( $search_name_term || $search_sku_term ) {
     2558                if ( $search_name_term || $has_search_sku ) {
    11472559                        global $wpdb;
    11482560
    11492561                        // JOIN is only needed when filtering by SKU.
    1150                         if ( $search_sku_term ) {
     2562                        if ( $has_search_sku ) {
    11512563                                $search_join_callback = function ( string $join ) use ( $wpdb ): string {
    11522564                                        if ( strpos( $join, 'wcabe_sku_lookup' ) === false ) {
    … …  
    11602572                        $name_clause = '';
    11612573                        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 );
    11642575                        }
    11652576
    11662577                        $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 );
    11862580                        }
    11872581
    … …  
    12072601                        $description_where_callback = function ( string $where ) use ( $wpdb, $desc_like, $include_variations ): string {
    12082602                                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 ("
    12112610                                                . " SELECT p.post_parent FROM {$wpdb->posts} p"
    12122611                                                . " INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id"
    … …  
    12142613                                                . " AND pm.meta_key = '_variation_description'"
    12152614                                                . " AND pm.meta_value LIKE %s"
    1216                                                 . ' ) )',
     2615                                                . ' ) ) )',
    12172616                                                $desc_like,
    12182617                                                $desc_like
    12192618                                        );
     2619                                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    12202620                                } else {
    12212621                                        $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like );
    … …  
    13262726                }
    13272727
     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
    13283043                if ( $needs_custom_query ) {
    13293044                        add_filter(
    … …  
    13383053                        add_filter( 'posts_join', $stock_qty_join_callback, 10, 1 );
    13393054                        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 );
    13403069                }
    13413070
    … …  
    13713100                                remove_filter( 'posts_where', $stock_qty_where_callback, 10 );
    13723101                        }
     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                        }
    13733113                        if ( $search_join_callback ) {
    13743114                                remove_filter( 'posts_join', $search_join_callback, 10 );
    … …  
    14053145
    14063146        /**
     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        /**
    14073323         * Build a WC date range string for date_created / date_modified args.
    14083324         *
    … …  
    14273343                }
    14283344                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' )";
    14293419        }
    14303420
    … …  
    15183508                        $result[] = $product;
    15193509
    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'] ) ) {
    15213515                                // Use pre-collected children IDs when available, falling back to
    15223516                                // loading the parent product (should only happen if called without
    … …  
    15283522                                }
    15293523
     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
    15303559                                foreach ( $children as $child_id ) {
    15313560                                        $variation = wc_get_product( $child_id );
    15323561                                        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 ) ) {
    15343563                                                        continue;
    15353564                                                }
    … …  
    15563585         * Returns an approximate flag because the raw COUNT(*) does not replicate
    15573586         * variation_matches_filters() — variation-level filters (price, stock_qty,
    1558          * manage_stock, backorders, search_description) cause an over-count at
    1559          * 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.
    15603589         *
    15613590         * @param int   $total_parents Total parents matching filters (from main query).
    … …  
    15813610                foreach ( array_chunk( $variable_parent_ids, 1000 ) as $chunk ) {
    15823611                        $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
    1583                         // post_status NOT IN ('trash', 'auto-draft') matches what the main
    1584                         // rendering path surfaces: get_children() returns all non-trashed
    1585                         // child IDs, and wc_get_product() returns any non-auto-draft
    1586                         // variation. Earlier version used IN ('publish', 'private'), which
    1587                         // under-counted drafts. See Product_Service::get_variations() for
    1588                         // the matching path. Parent status filtering is already applied
    1589                         // via the parent-ID query above; this clause is the independent
     3612                        // 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
    15903619                        // VARIATION-status axis in WC's data model.
    15913620                        // 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.
    … …  
    15933622                                "SELECT COUNT(*) FROM {$wpdb->posts}
    15943623                                 WHERE post_type = 'product_variation'
    1595                                    AND post_status NOT IN ('trash', 'auto-draft')
     3624                                   AND post_status IN ('publish', 'private')
    15963625                                   AND post_parent IN ($placeholders)",
    15973626                                ...$chunk
    … …  
    16483677                        return true;
    16493678                }
     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                }
    16503691                if ( ! empty( $filters['manage_stock'] ) ) {
    16513692                        return true;
    … …  
    16543695                        return true;
    16553696                }
     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                }
    16563707                if ( ! empty( $filters['search_description'] ) ) {
    16573708                        return true;
    16583709                }
     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                }
    16593714                // search_short_description is not checked — variations don't have short
    16603715                // 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.
    16613719                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;
    16623809        }
    16633810
    … …  
    17083855                }
    17093856
     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
    17103942                // Manage stock.
    17113943                if ( ! empty( $filters['manage_stock'] ) ) {
    … …  
    17373969                }
    17383970
     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
    17393990                // Short description — variations don't have one (no postmeta equivalent),
    17403991                // 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  
    77
    88namespace WCABE_G2\Services;
     9
     10use WCABE_G2\Helpers\Page_Size_Helper;
    911
    1012defined( 'ABSPATH' ) || exit;
    … …  
    9597                                $view['filters'] = $this->migrate_filters_shape( $view['filters'] );
    9698                        }
     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                        }
    97107                }
    98108                unset( $view );
    … …  
    118128                        'filters'      => $this->sanitize_filters( $data['filters'] ?? [] ),
    119129                        'column_state' => self::sanitize_column_state( $data['column_state'] ?? [] ),
     130                        'page_size'    => Page_Size_Helper::clamp( $data['page_size'] ?? null ),
    120131                        'is_default'   => ! empty( $data['is_default'] ),
    121132                        'created_at'   => gmdate( 'Y-m-d\TH:i:s\Z' ),
    … …  
    173184                }
    174185
     186                if ( isset( $data['page_size'] ) ) {
     187                        $views[ $index ]['page_size'] = Page_Size_Helper::clamp( $data['page_size'] );
     188                }
     189
    175190                if ( isset( $data['is_default'] ) ) {
    176191                        $views[ $index ]['is_default'] = (bool) $data['is_default'];
    … …  
    283298         * Sanitize filters to match the expected FilterValues shape.
    284299         *
     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         *
    285304         * @param array $filters
    286305         * @return array
    287306         */
    288307        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 {
    289350                $allowed_operators = [ '=', '>', '<', '>=', '<=', 'expression' ];
    290351                $id_operator       = $filters['id_operator'] ?? '=';
    … …  
    293354                }
    294355
     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
    295392                $sanitized = [
    296393                        // Original fields.
    297394                        '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,
    299398                        'category'                => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ),
     399                        'tag'                     => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ),
    300400                        'type'                    => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ),
    301401                        'status'                  => array_map( 'sanitize_text_field', (array) ( $filters['status'] ?? [] ) ),
    302402                        '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                        ),
    303415                        'price_min'               => sanitize_text_field( $filters['price_min'] ?? '' ),
    304416                        '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'] ?? '' ),
    305419                        'stock_qty_min'           => sanitize_text_field( $filters['stock_qty_min'] ?? '' ),
    306420                        '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'] ?? '' ),
    307423                        'manage_stock'            => array_map( 'sanitize_text_field', (array) ( $filters['manage_stock'] ?? [] ) ),
    308424                        'backorders'              => array_map( 'sanitize_text_field', (array) ( $filters['backorders'] ?? [] ) ),
    … …  
    315431                        'id_operator' => $id_operator,
    316432                        '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,
    317439
    318440                        // Date filters.
    … …  
    321443                        'date_modified_from' => sanitize_text_field( $filters['date_modified_from'] ?? '' ),
    322444                        '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'] ?? '' ),
    323451
    324452                        // Dynamic filter bags — sanitized below.
    … …  
    326454                        'extension_filters' => [],
    327455                ];
    328 
    329                 // Normalize any pre-Phase-B saved-view shape (old acf_filters /
    330                 // custom_meta_filters / taxonomy_filters keys) into extension_filters
    331                 // before the dynamic-bag sanitization below reads it.
    332                 $filters = $this->migrate_filters_shape( $filters );
    333456
    334457                // Attribute filters: Record<string, number[]> — Free feature.
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/readme.txt

    r3549389 r3663491  
    33Tags: bulk edit, product editor, product bulk edit, woocommerce products, inventory
    44Requires at least: 6.0
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.0.0
     7Stable tag: 1.2.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3131* **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style
    3232* **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
    3536* **Column management** — show, hide, reorder, resize, and pin columns; each user's layout is saved separately
    3637* **Keyboard shortcuts** — navigate and edit without touching the mouse
    3738* **Product operations** — duplicate and change status right from the grid
    3839* **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
    4041* **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
    4244
    4345= Built on proper WooCommerce APIs =
    … …  
    5153= Pro version =
    5254
    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/).
     55If 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/).
    5456
    5557The 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.
    … …  
    9597= Can I undo changes? =
    9698
    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.)
     99Edits 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).
    98100
    99101= Is there a Pro version? =
    100102
    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.
     103Yes. 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
     107Operation 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.
    102108
    103109= Does it support custom post types? =
    … …  
    128134== Changelog ==
    129135
     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
    130194= 1.0.0 =
    131195* Initial release.
    … …  
    133197== Upgrade Notice ==
    134198
     199= 1.2.0 =
     200Major 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
    135202= 1.0.0 =
    136203Initial release.
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.2.0/wpmelon-advanced-bulk-edit-for-woocommerce.php

    r3549389 r3663491  
    44 * Plugin URI: https://wpmelon.com/bulk-edit/
    55 * Description: Excel-like bulk editing interface for WooCommerce products
    6  * Version: 1.0.0
     6 * Version: 1.2.0
    77 * Author: WPMelon
    88 * Author URI: https://wpmelon.com/
    … …  
    1010 * Domain Path: /languages
    1111 * Requires at least: 6.0
    12  * Tested up to: 7.0
     12 * Tested up to: 7.1
    1313 * Requires PHP: 8.0
    1414 * Requires Plugins: woocommerce
    1515 * WC requires at least: 10.0
    16  * WC tested up to: 10.7
     16 * WC tested up to: 11.0.1
    1717 * License: GPLv2 or later
    1818 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    126126define( 'WCABE_G2_LOADED', true );
    127127
    128 define( 'WCABE_G2_VERSION', '1.0.0' );
     128define( 'WCABE_G2_VERSION', '1.2.0' );
    129129define( 'WCABE_G2_PLUGIN_FILE', __FILE__ );
    130130define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/assets/dist/.vite/manifest.json

    r3549389 r3663491  
    55  },
    66  "assets/src/main.tsx": {
    7     "file": "assets/main-CPlqEen8.js",
     7    "file": "assets/main-V9rY9XMr.js",
    88    "name": "main",
    99    "src": "assets/src/main.tsx",
    1010    "isEntry": true,
    1111    "css": [
    12       "assets/main-DuaYQxSj.css"
     12      "assets/main-D5Lgb2qa.css"
    1313    ],
    1414    "assets": [
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Admin.php

    r3549389 r3663491  
    2020                        'wcabe-g2',
    2121                        [ $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                        ]
    2255                );
    2356        }
    … …  
    5184                        'dimensionUnit'     => get_option( 'woocommerce_dimension_unit', 'cm' ),
    5285                        '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() ),
    5393                        '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(),
    54103                        '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(),
    55109                        'pluginSettings'    => REST\Settings_Controller::get_all(),
    56110                        '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(),
    57114                ];
    58115
    … …  
    111168                        'wcabe-g2-admin',
    112169                        $dist_url . $entry['file'],
    113                         [ 'wp-element', 'wp-i18n' ],
     170                        [ 'wp-element', 'wp-i18n', 'wp-api-fetch' ],
    114171                        WCABE_G2_VERSION,
    115172                        true
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Handler.php

    r3549389 r3663491  
    4646
    4747        /**
    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.
    5053         */
    5154        private function ensure_term_maps(): void {
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Save_Handler.php

    r3549389 r3663491  
    100100         */
    101101        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
    102110                $lookup = self::get_field_lookup();
    103111
    … …  
    164172         */
    165173        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
    166180                $this->new_terms_created = false;
    167181                $lookup     = self::get_field_lookup();
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Helpers/Product_Helper.php

    r3549389 r3663491  
    1313
    1414        /**
     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        /**
    15167         * Format a WC_Product into a flat array for the grid.
    16168         *
    17169         * @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).
    18175         * @return array
    19176         */
    20         public static function format_product( \WC_Product $product ): array {
     177        public static function format_product( \WC_Product $product, ?array $ref_map = null ): array {
    21178                $image_id = $product->get_image_id();
    22179                $thumbnail_url = $image_id
    … …  
    24181                        : '';
    25182
     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.
    26186                $variation_count = 0;
    27                 if ( $product->is_type( 'variable' ) ) {
     187                if ( $product instanceof \WC_Product_Variable ) {
    28188                        $variation_count = count( $product->get_children() );
    29189                }
    … …  
    37197                $tags = self::resolve_term_names( $product->get_tag_ids(), 'product_tag' );
    38198
    39                 // Sale dates — not on variable parents.
     199                // Sale dates — not on variable parents (incl. variable subclasses).
    40200                $date_on_sale_from = '';
    41201                $date_on_sale_to   = '';
    42                 if ( ! $product->is_type( 'variable' ) ) {
     202                if ( ! $product instanceof \WC_Product_Variable ) {
    43203                        $from = $product->get_date_on_sale_from();
    44204                        $to   = $product->get_date_on_sale_to();
    … …  
    46206                        $date_on_sale_to   = $to ? $to->date( 'Y-m-d' ) : '';
    47207                }
     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();
    48215
    49216                $gallery_ids  = $product->get_gallery_image_ids();
    … …  
    54221                                $gallery_urls[] = $url ?: '';
    55222                        }
     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 ) );
    56233                }
    57234
    … …  
    77254                        'categories'             => $categories,
    78255                        '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 ),
    79261                        'description'            => $product->get_description(),
    80262                        'short_description'      => $product->get_short_description(),
    … …  
    89271                        'downloadable'           => $product->get_downloadable(),
    90272                        '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 ),
    91312                ];
    92313
    … …  
    96317        /**
    97318         * 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.
    98323         *
    99324         * @param int[]  $term_ids
    … …  
    101326         * @return array Array of [ 'id' => int, 'name' => string ].
    102327         */
    103         private static function resolve_term_names( array $term_ids, string $taxonomy ): array {
     328        public static function resolve_term_names( array $term_ids, string $taxonomy ): array {
    104329                if ( empty( $term_ids ) ) {
    105330                        return [];
    … …  
    126351
    127352        /**
     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        /**
    128642         * Format a WC_Product_Variation into a flat array for the grid.
    129643         *
    … …  
    141655                $to   = $variation->get_date_on_sale_to();
    142656
    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 );
    172658
    173659                $data = [
    … …  
    199685                        'downloadable'           => $variation->get_downloadable(),
    200686                        '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 ),
    201703                ];
    202704
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Main.php

    r3549389 r3663491  
    2929                $admin = new Admin();
    3030                add_action( 'admin_menu', [ $admin, 'register_menu' ] );
     31                add_action( 'admin_bar_menu', [ $admin, 'add_admin_bar_link' ], 99 );
    3132                add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
    3233
    … …  
    4041                // Debug tracing — appends server metrics to REST responses when enabled.
    4142                new Handlers\Debug_Trace_Handler();
     43
     44                // WordPress save-hook compatibility — Free feature, controlled by plugin setting.
     45                new Handlers\WP_Save_Hooks_Handler();
    4246        }
    4347
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Export_Controller.php

    r3549389 r3663491  
    1111use WCABE_G2\Helpers\Product_Helper;
    1212use WCABE_G2\Services\Export_Service;
     13use WCABE_G2\Services\Filter_Service;
    1314use WCABE_G2\Services\Product_Service;
     15use WCABE_G2\Services\View_Service;
    1416
    1517defined( 'ABSPATH' ) || exit;
    … …  
    4143                                                'default'           => [],
    4244                                                '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).
    4348                                                'sanitize_callback' => function ( $value ) {
    4449                                                        if ( ! is_array( $value ) ) {
    4550                                                                return [];
    4651                                                        }
    47                                                         return array_filter( array_map( 'absint', $value ) );
     52                                                        return Product_Helper::normalize_product_ids( $value );
    4853                                                },
    4954                                        ],
    … …  
    5964                                                'sanitize_callback' => 'sanitize_text_field',
    6065                                        ],
     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                                        ],
    6173                                        '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' ],
    6598                                        ],
    6699                                        'category'           => [
    … …  
    69102                                                'sanitize_callback' => 'sanitize_text_field',
    70103                                        ],
     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                                        ],
    71109                                        'type'               => [
    72110                                                'description'       => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    84122                                                'sanitize_callback' => 'sanitize_text_field',
    85123                                        ],
     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                                        ],
    86134                                        'price_min'          => [
    87135                                                'description'       => __( 'Minimum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    104152                                                'sanitize_callback' => 'sanitize_text_field',
    105153                                        ],
     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                                        ],
    106192                                        'manage_stock'       => [
    107193                                                'description'       => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    134220                                        'date_modified_before' => [
    135221                                                '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' ),
    136258                                                'type'              => 'string',
    137259                                                'sanitize_callback' => 'sanitize_text_field',
    … …  
    183305         * Export products as CSV.
    184306         *
    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.
    186312         * 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.
    187326         *
    188327         * @param \WP_REST_Request $request
    … …  
    190329         */
    191330        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
    236355                        return new \WP_REST_Response(
    237356                                [
    … …  
    243362                }
    244363
    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                }
    253378
    254379                header( 'Content-Type: text/csv; charset=UTF-8' );
    … …  
    260385                header( 'Cache-Control: no-cache, no-store, must-revalidate' );
    261386
    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 );
    264391                exit;
    265392        }
    266393
    267394        /**
     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        /**
    268479         * 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.
    269484         *
    270485         * @param \WP_REST_Request $request
    271486         * @return array
    272487         */
    273         private function extract_filters( \WP_REST_Request $request ): array {
     488        protected function extract_filters( \WP_REST_Request $request ): array {
    274489                $filters = [];
    275490
    … …  
    278493                        $filters['search_name'] = sanitize_text_field( $search_name );
    279494                }
    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.
    285525                $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;
    288535                }
    289536
    … …  
    303550                }
    304551
     552                $filters = Filter_Service::apply_shipping_class_param(
     553                        $request->get_param( 'shipping_class' ),
     554                        $filters
     555                );
     556
    305557                $price_min = $request->get_param( 'price_min' );
    306558                if ( null !== $price_min && '' !== $price_min ) {
    … …  
    313565                }
    314566
     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
    315581                $stock_qty_min = $request->get_param( 'stock_qty_min' );
    316582                if ( null !== $stock_qty_min && '' !== $stock_qty_min ) {
    … …  
    323589                }
    324590
     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
    325601                $manage_stock = $request->get_param( 'manage_stock' );
    326602                if ( ! empty( $manage_stock ) ) {
    … …  
    333609                }
    334610
    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                ];
    336623                foreach ( $date_params as $param ) {
    337624                        $val = $request->get_param( $param );
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Products_Controller.php

    r3549389 r3663491  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Page_Size_Helper;
    1011use WCABE_G2\Helpers\Permission_Helper;
     12use WCABE_G2\Helpers\Product_Helper;
     13use WCABE_G2\Services\Filter_Service;
    1114use WCABE_G2\Services\Product_Service;
     15use WCABE_G2\Services\View_Service;
    1216
    1317defined( 'ABSPATH' ) || exit;
    … …  
    3034                                'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ],
    3135                                '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                                ],
    3289                        ],
    3390                ] );
    … …  
    61118                                                ],
    62119                                        ],
     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                                        ],
    63127                                ],
    64128                        ],
    65129                ] );
     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 );
    66151        }
    67152
    … …  
    72157        public function get_items( $request ): \WP_REST_Response {
    73158                $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' ) );
    75163                $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 memory
    81                  * exhaustion on shared hosting when variations are expanded. Both Free
    82                  * and Pro honor this default — Pro does not raise it. Site admins on
    83                  * 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 );
    89164
    90165                // Build filters array from request params.
    … …  
    95170                        $filters['search_name'] = sanitize_text_field( $search_name );
    96171                }
    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;
    100184                }
    101185                $search_description = $request->get_param( 'search_description' );
    … …  
    117201                }
    118202
     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".
    119219                $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;
    122229                }
    123230
    … …  
    137244                }
    138245
     246                $filters = Filter_Service::apply_shipping_class_param(
     247                        $request->get_param( 'shipping_class' ),
     248                        $filters
     249                );
     250
    139251                $price_min = $request->get_param( 'price_min' );
    140252                if ( null !== $price_min && '' !== $price_min ) {
    … …  
    147259                }
    148260
     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
    149275                $stock_qty_min = $request->get_param( 'stock_qty_min' );
    150276                if ( null !== $stock_qty_min && '' !== $stock_qty_min ) {
    … …  
    157283                }
    158284
     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
    159295                $manage_stock = $request->get_param( 'manage_stock' );
    160296                if ( ! empty( $manage_stock ) ) {
    … …  
    167303                }
    168304
    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                ];
    170317                foreach ( $date_params as $param ) {
    171318                        $val = $request->get_param( $param );
    … …  
    202349                $filters = apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request );
    203350
     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
    204356                $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 );
    206358
    207359                $response = rest_ensure_response( $data );
    … …  
    240392                }
    241393
     394                $batch_id = $request->get_param( 'batch_id' );
     395
    242396                $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                );
    244401
    245402                return rest_ensure_response( [ 'results' => $results ] );
    … …  
    281438                                'description'       => __( 'Maximum number of items per page.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    282439                                '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' ],
    288449                        ],
    289450                        'include_variations' => [
    … …  
    293454                                'sanitize_callback' => 'rest_sanitize_boolean',
    294455                        ],
     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                        ],
    295468                        'search_name' => [
    296469                                'description'       => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    298471                                'sanitize_callback' => 'sanitize_text_field',
    299472                        ],
     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                        ],
    300480                        '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 ),
    303491                                'sanitize_callback' => 'sanitize_text_field',
    304492                        ],
    … …  
    330518                                'sanitize_callback' => 'sanitize_text_field',
    331519                        ],
     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                        ],
    332534                        'category' => [
    333535                                'description'       => __( 'Comma-separated category term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    335537                                'sanitize_callback' => 'sanitize_text_field',
    336538                        ],
     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                        ],
    337544                        'type' => [
    338545                                'description'       => __( 'Comma-separated product types.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    349556                                'type'              => 'string',
    350557                                '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',
    351568                        ],
    352569                        'price_min' => [
    … …  
    378595                                },
    379596                        ],
     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                        ],
    380625                        'stock_qty_min' => [
    381626                                'description'       => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    406651                                },
    407652                        ],
     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                        ],
    408683                        'manage_stock' => [
    409684                                'description'       => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    436711                        'date_modified_before' => [
    437712                                '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' ),
    438749                                'type'              => 'string',
    439750                                'sanitize_callback' => 'sanitize_text_field',
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Settings_Controller.php

    r3549389 r3663491  
    2626                ],
    2727                'debug_tracing' => [
     28                        'type'    => 'boolean',
     29                        'default' => false,
     30                ],
     31                'trigger_wp_save_hooks' => [
    2832                        'type'    => 'boolean',
    2933                        'default' => false,
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/User_Settings_Controller.php

    r3549389 r3663491  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Page_Size_Helper;
    1011use WCABE_G2\Helpers\Permission_Helper;
    1112use WCABE_G2\Services\View_Service;
    … …  
    2728         */
    2829        private const SCHEMA = [
    29                 'pageSize'            => [ 'type' => 'integer', 'default' => 100 ],
     30                'pageSize'            => [ 'type' => 'integer', 'default' => Page_Size_Helper::DEFAULT_PAGE_SIZE ],
    3031                'includeVariations'   => [ 'type' => 'boolean', 'default' => false ],
    3132                'darkMode'            => [ 'type' => 'boolean', 'default' => false ],
    … …  
    3435                'imagePreviewEnabled' => [ 'type' => 'boolean', 'default' => true ],
    3536                '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 ],
    3651        ];
    3752
    … …  
    5873
    5974        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
    60110                $current = self::get_all();
    61                 $body    = $request->get_json_params();
    62111
    63112                foreach ( self::SCHEMA as $key => $schema ) {
    … …  
    75124                                case 'integer':
    76125                                        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 );
    80127                                        }
    81128                                        // else: no other integer fields defined. Future integer fields
    … …  
    102149                                        }
    103150                                        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;
    104171                        }
    105172                }
    106173
     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
    107180                update_user_meta( get_current_user_id(), self::META_KEY, $current );
    108181
    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;
    110201        }
    111202
    … …  
    126217                }
    127218
    128                 // Defensive clamp on read. The schema and update_settings() already
    129                 // enforce the bound on writes, but values written to user meta outside
    130                 // this controller (legacy installs, future schema changes, direct DB
    131                 // 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.
    132223                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'] );
    136225                }
    137226
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Views_Controller.php

    r3549389 r3663491  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Page_Size_Helper;
    1011use WCABE_G2\Helpers\Permission_Helper;
    1112use WCABE_G2\Services\View_Service;
    … …  
    3839                        );
    3940                }
     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                }
    4051                return true;
    4152        }
    … …  
    6778                                                'type'     => 'array',
    6879                                        ],
     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                                        ],
    6988                                        'is_default' => [
    7089                                                'required' => false,
    … …  
    100119                                                'type'     => 'array',
    101120                                        ],
     121                                        'page_size' => [
     122                                                'required'          => false,
     123                                                'type'              => 'integer',
     124                                                'sanitize_callback' => [ Page_Size_Helper::class, 'clamp' ],
     125                                        ],
    102126                                        'is_default' => [
    103127                                                'required' => false,
    … …  
    144168                        'filters'      => $request->get_param( 'filters' ),
    145169                        'column_state' => $request->get_param( 'column_state' ),
     170                        'page_size'    => $request->get_param( 'page_size' ),
    146171                        'is_default'   => $request->get_param( 'is_default' ),
    147172                ];
    … …  
    181206                        $data['column_state'] = $request->get_param( 'column_state' );
    182207                }
     208                if ( null !== $request->get_param( 'page_size' ) ) {
     209                        $data['page_size'] = $request->get_param( 'page_size' );
     210                }
    183211                if ( null !== $request->get_param( 'is_default' ) ) {
    184212                        $data['is_default'] = $request->get_param( 'is_default' );
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Export_Service.php

    r3549389 r3663491  
    88namespace WCABE_G2\Services;
    99
     10use WCABE_G2\Helpers\Product_Helper;
     11
    1012defined( 'ABSPATH' ) || exit;
    1113
    … …  
    1820         */
    1921        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;
    2031
    2132        /**
    … …  
    4455                'regular_price'      => 'Regular Price',
    4556                'sale_price'         => 'Sale Price',
     57                'cogs_value'         => 'Cost of Goods',
    4658                'stock_quantity'     => 'Stock Qty',
    4759                'stock_status'       => 'Stock Status',
    … …  
    5870                'date_on_sale_from'  => 'Sale From',
    5971                'date_on_sale_to'    => 'Sale To',
     72                'date_created'       => 'Published on',
     73                'date_modified'      => 'Modified on',
     74                'image_date'         => 'Image uploaded',
    6075                'featured'           => 'Featured',
    6176                'virtual'            => 'Virtual',
    6277                'downloadable'       => 'Downloadable',
    6378                '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',
    6485        ];
    6586
    6687        /**
    6788         * 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.
    6894         *
    6995         * @param array  $products    Array of formatted product arrays.
    … …  
    7298         * @param array  $units       Optional [ 'weight_unit' => 'kg', 'dimension_unit' => 'cm' ].
    7399         * @return string CSV content.
     100         * @throws \RuntimeException When the temporary stream cannot be opened, written, rewound or read.
    74101         */
    75102        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 {
    76150                /**
    77151                 * Allow extensions (Pro) to declare additional export columns.
    … …  
    81155                $this->pro_columns = apply_filters( 'wcabe_g2_export_extra_columns', [] );
    82156
    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 {
    84207                if ( ! empty( $columns ) ) {
    85208                        $pro_columns = $this->pro_columns;
    … …  
    94217                }
    95218
    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 {
    97230                $weight_unit    = $units['weight_unit'] ?? '';
    98231                $dimension_unit = $units['dimension_unit'] ?? '';
    … …  
    121254                }
    122255
    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                }
    147278        }
    148279
    … …  
    191322                                return '';
    192323
     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
    193361                        case 'manage_stock':
    194362                        case 'featured':
    … …  
    203371                                return (string) $value;
    204372
     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
    205378                        case 'description':
    206379                        case 'short_description':
    207380                                return wp_strip_all_tags( (string) $value );
    208381
     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
    209400                        default:
    210401                                return (string) $value;
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Filter_Service.php

    r3549389 r3663491  
    88namespace WCABE_G2\Services;
    99
     10use WCABE_G2\Helpers\Product_Helper;
     11
    1012defined( 'ABSPATH' ) || exit;
    1113
    1214class 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        }
    1358
    1459        /**
    … …  
    2166                        'categories'     => $this->get_category_tree(),
    2267                        'tags'           => $this->get_tags(),
    23                         'types'          => [ 'simple', 'variable', 'grouped', 'external' ],
     68                        'types'          => $this->get_product_type_options(),
    2469                        'statuses'       => [ 'publish', 'draft', 'pending', 'private' ],
    2570                        '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(),
    2675                ];
    2776
    2877                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;
    29109        }
    30110
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Operations_Service.php

    r3549389 r3663491  
    6969         * Duplicate one or more products.
    7070         *
     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         *
    7176         * @param int[] $product_ids Source product IDs.
    7277         * @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[] }
    7479         */
    7580        public function duplicate_products( array $product_ids, int $count = 1 ): array {
    … …  
    7984
    8085                $products           = [];
     86                $fresh_products     = [];
    8187                $failed             = [];
    8288                $total_var_failures = 0;
    … …  
    8490                if ( empty( $product_ids ) ) {
    8591                        return [
    86                                 'success'  => true,
    87                                 'products' => [],
    88                                 'failed'   => [],
     92                                'success'     => true,
     93                                'created_ids' => [],
     94                                'products'    => [],
     95                                'failed'      => [],
    8996                        ];
    9097                }
    … …  
    126133                                $duplicate->set_tag_ids( $source->get_tag_ids() );
    127134                                $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' ) );
    130141
    131142                                if ( $source->is_type( 'variable' ) ) {
    … …  
    144155                                        }
    145156
    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.
    147161                                        $fresh = wc_get_product( $duplicate->get_id() );
    148162                                        if ( $fresh ) {
    149                                                 $products[] = Product_Helper::format_product( $fresh );
     163                                                $fresh_products[] = $fresh;
    150164                                        }
    151165                                } catch ( \Exception $e ) {
    … …  
    164178                }
    165179
     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
    166225                $result = [
    167                         'success'  => true,
    168                         'products' => $products,
    169                         'failed'   => $failed,
     226                        'success'     => true,
     227                        'created_ids' => $created_ids,
     228                        'products'    => $products,
     229                        'failed'      => $failed,
    170230                ];
    171231
     232                $warnings = [];
     233
    172234                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;
    180254                }
    181255
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Product_Service.php

    r3549389 r3663491  
    88namespace WCABE_G2\Services;
    99
     10use WCABE_G2\Handlers\WP_Save_Hooks_Handler;
    1011use WCABE_G2\Helpers\Product_Helper;
    1112
    … …  
    1314
    1415class 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';
    15131
    16132        /**
    … …  
    20136                'simple'   => [
    21137                        'name', 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility',
     138                        'cogs_value',
     139                        'date_created',
    22140                        'categories', 'tags', 'description', 'short_description',
    23                         'weight', 'length', 'width', 'height',
     141                        'weight', 'length', 'width', 'height', 'shipping_class',
    24142                        'date_on_sale_from', 'date_on_sale_to',
    25143                        'featured', 'virtual', 'downloadable',
    26144                        'manage_stock', 'backorders',
    27145                        'image_id', 'gallery_image_ids',
     146                        'upsell_ids', 'cross_sell_ids',
     147                        'menu_order',
    28148                ],
    29149                'variable' => [
    30150                        'name', 'sku', 'status', 'catalog_visibility',
     151                        'cogs_value',
     152                        'date_created',
    31153                        'categories', 'tags', 'description', 'short_description',
    32                         'weight', 'length', 'width', 'height',
     154                        'weight', 'length', 'width', 'height', 'shipping_class',
    33155                        'featured',
    34156                        'manage_stock', 'stock_quantity', 'stock_status', 'backorders',
    35157                        'image_id', 'gallery_image_ids',
     158                        'upsell_ids', 'cross_sell_ids',
     159                        'menu_order',
    36160                ],
    37161                'variation' => [
    38162                        'sku', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status',
     163                        'cogs_value',
    39164                        'description',
    40                         'weight', 'length', 'width', 'height',
     165                        'weight', 'length', 'width', 'height', 'shipping_class',
    41166                        'date_on_sale_from', 'date_on_sale_to',
    42167                        'virtual', 'downloadable',
    43168                        'manage_stock', 'backorders',
    44169                        'image_id',
     170                        'menu_order',
    45171                ],
    46172                'grouped'  => [
    47173                        'name', 'sku', 'status', 'catalog_visibility',
     174                        'date_created',
    48175                        'categories', 'tags', 'description', 'short_description',
    49176                        'featured',
    50177                        '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',
    51184                ],
    52185                'external' => [
    53186                        'name', 'sku', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility',
     187                        'cogs_value',
     188                        'date_created',
    54189                        'categories', 'tags', 'description', 'short_description',
    55190                        'weight', 'length', 'width', 'height',
    … …  
    57192                        'featured', 'virtual', 'downloadable',
    58193                        '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',
    59201                ],
     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',
    60240        ];
    61241
    … …  
    84264
    85265        /**
     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        /**
    86312         * Get paginated products.
    87313         *
    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).
    92320         * @return array
    93321         */
    … …  
    96324                int $per_page = 100,
    97325                bool $include_variations = false,
    98                 array $filters = []
     326                array $filters = [],
     327                string $orderby = 'id',
     328                string $order = 'desc'
    99329        ): 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 );
    101331
    102332                // Allow Pro to prime caches before per-product formatting.
    … …  
    104334                do_action( 'wcabe_g2_before_format_products', $product_ids );
    105335
    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 = [];
    110342                foreach ( $query_result['products'] as $product ) {
    111343                        $gids = $product->get_gallery_image_ids();
    112344                        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                );
    121375
    122376                // Interleave variations if requested.
    … …  
    128382                        $variation_ids_to_prime = [];
    129383                        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 ) {
    131387                                        $children = $product->get_children();
    132388                                        $children_map[ $product->get_id() ] = $children;
    … …  
    198454                }
    199455
    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 ) {
    201460                        return new \WP_Error(
    202461                                'not_variable',
    … …  
    236495
    237496        /**
     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        /**
    238875         * Batch update products.
    239876         *
    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.
    241881         * @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 {
    244891                $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                }
    245912
    246913                foreach ( $items as $item ) {
    … …  
    259926                        }
    260927
     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
    261933                        try {
    262934                                $product = wc_get_product( $id );
    … …  
    274946
    275947                                $type = $product->get_type();
    276                                 $allowed_fields = self::ALLOWED_FIELDS[ $type ] ?? [];
    277                                 $allowed_fields = apply_filters( 'wcabe_g2_allowed_fields', $allowed_fields, $product );
    278948
    279949                                // 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'];
    282953                                if ( ! empty( $validation_errors ) ) {
    283954                                        $results[] = [
    … …  
    305976                                }
    306977
    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 );
    308985
    309986                                $product->save();
    310987
    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                                }
    3151006
    3161007                                $result = [
    3171008                                        'id'      => $id,
    3181009                                        'success' => true,
    319                                         'data'    => $data,
     1010                                        'data'    => null, // Filled in phase 2 below.
    3201011                                ];
    3211012
    … …  
    3251016
    3261017                                $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;
    3271022
    3281023                        } catch ( \Exception $e ) {
    … …  
    3341029                                        ],
    3351030                                ];
     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                                }
    3361113                        }
    3371114                }
    3381115
    3391116                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                ];
    3401151        }
    3411152
    … …  
    3601171                        }
    3611172
    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 ) ) {
    3641175                                $sanitized = trim( (string) $value );
    3651176                                if ( '' !== $sanitized ) {
    … …  
    3831194                                                'value'   => $value,
    3841195                                                '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' ),
    3851246                                        ];
    3861247                                }
    … …  
    4121273                                        }
    4131274                                }
     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                                }
    4141309                        } elseif ( in_array( $field, [ 'date_on_sale_from', 'date_on_sale_to' ], true ) ) {
    4151310                                // Date validation — must be empty or Y-m-d format.
    … …  
    4771372                                        // Empty array is valid — this is the "clear gallery" path.
    4781373                                        foreach ( $value as $gid ) {
    479                                                 if ( ! is_numeric( $gid ) || (int) $gid <= 0 ) {
     1374                                                if ( ! self::is_positive_int_id( $gid ) ) {
    4801375                                                        $errors[] = [
    4811376                                                                'field'   => $field,
    4821377                                                                'value'   => $value,
    4831378                                                                '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' ),
    4841403                                                        ];
    4851404                                                        break;
    … …  
    5561475                                        ];
    5571476                                }
     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                                }
    5581517                        }
    5591518                }
    … …  
    5791538                        case 'sku':
    5801539                                $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                                }
    5811573                                break;
    5821574
    … …  
    5941586                                break;
    5951587
     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
    5961600                        case 'stock_quantity':
    5971601                                if ( null === $value || '' === $value ) {
    … …  
    6001604                                        $product->set_stock_quantity( wc_stock_amount( $value ) );
    6011605                                }
     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 );
    6021613                                break;
    6031614
    … …  
    6781689                                break;
    6791690
     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
    6801703                        case 'date_on_sale_from':
    6811704                                $sanitized = sanitize_text_field( (string) $value );
    … …  
    7261749                                $product->set_gallery_image_ids( $ids );
    7271750                                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 ) );
    7291875        }
    7301876
    … …  
    7881934
    7891935        /**
     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        /**
    7901962         * Get products via WooCommerce CRUD API.
    7911963         *
    … …  
    7941966         * @param array $filters Optional filter criteria.
    7951967         * @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).
    7991973         * @return array{ products: \WC_Product[]|int[], total: int, total_pages: int }
    8001974         */
    … …  
    8041978                array $filters = [],
    8051979                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'
    8071983        ): 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
    8082004                $args = [
    8092005                        'limit'    => $per_page,
    8102006                        'page'     => $page,
    811                         'orderby'  => 'ID',
    812                         'order'    => 'DESC',
     2007                        'orderby'  => $wc_orderby,
     2008                        'order'    => $wc_order,
    8132009                        'paginate' => true,
    8142010                ];
    … …  
    8242020                        $args['return']   = 'ids';
    8252021
     2022                        $variable_types = self::get_variable_product_types();
    8262023                        if ( ! empty( $filters['type'] ) ) {
    8272024                                $intersection = array_values(
    828                                         array_intersect( (array) $filters['type'], [ 'variable' ] )
     2025                                        array_intersect( (array) $filters['type'], $variable_types )
    8292026                                );
    8302027                                if ( empty( $intersection ) ) {
    … …  
    8372034                                $filters['type'] = $intersection;
    8382035                        } else {
    839                                 $filters['type'] = [ 'variable' ];
     2036                                $filters['type'] = $variable_types;
    8402037                        }
    8412038                }
    … …  
    8442041                $args['status'] = ! empty( $filters['status'] )
    8452042                        ? (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).
    8492050                $args['type'] = ! empty( $filters['type'] )
    8502051                        ? (array) $filters['type']
    851                         : [ 'simple', 'variable', 'grouped', 'external' ];
     2052                        : self::get_known_product_types();
    8522053
    8532054                // ID filter — simple comparison or expression (CSV + ranges).
    … …  
    8922093                // Search filters — handled via posts_join/posts_where hooks below.
    8932094                $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'] ?? '' );
    8952104                $search_description_term       = ! empty( $filters['search_description'] )       ? sanitize_text_field( $filters['search_description'] )       : '';
    8962105                $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 );
    8972114
    8982115                // Category filter — exact matching (include_children => false).
    … …  
    9012118                }
    9022119
     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
    9032127                // Stock status filter.
    9042128                if ( ! empty( $filters['stock_status'] ) ) {
    … …  
    9062130                }
    9072131
     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
    9082144                // Price range filters — passed as custom args, handled in handle_custom_query_args.
    9092145                if ( isset( $filters['price_min'] ) && '' !== $filters['price_min'] ) {
    … …  
    9122148                if ( isset( $filters['price_max'] ) && '' !== $filters['price_max'] ) {
    9132149                        $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                        }
    9142162                }
    9152163
    … …  
    9432191                if ( $modified_after || $modified_before ) {
    9442192                        $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;
    9452221                }
    9462222
    … …  
    9782254
    9792255                $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'] );
    9802257                $has_manage_stock_filter = ! empty( $args['wcabe_manage_stock'] );
    9812258                $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 );
    9852264
    9862265                $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations ): array {
    … …  
    10612340                        }
    10622341
     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
    10632475                        // Stock quantity range filtering via _stock meta.
    10642476                        // When include_variations is false, standard meta_query applies.
    … …  
    11442556                $search_where_callback = null;
    11452557
    1146                 if ( $search_name_term || $search_sku_term ) {
     2558                if ( $search_name_term || $has_search_sku ) {
    11472559                        global $wpdb;
    11482560
    11492561                        // JOIN is only needed when filtering by SKU.
    1150                         if ( $search_sku_term ) {
     2562                        if ( $has_search_sku ) {
    11512563                                $search_join_callback = function ( string $join ) use ( $wpdb ): string {
    11522564                                        if ( strpos( $join, 'wcabe_sku_lookup' ) === false ) {
    … …  
    11602572                        $name_clause = '';
    11612573                        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 );
    11642575                        }
    11652576
    11662577                        $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 );
    11862580                        }
    11872581
    … …  
    12072601                        $description_where_callback = function ( string $where ) use ( $wpdb, $desc_like, $include_variations ): string {
    12082602                                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 ("
    12112610                                                . " SELECT p.post_parent FROM {$wpdb->posts} p"
    12122611                                                . " INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id"
    … …  
    12142613                                                . " AND pm.meta_key = '_variation_description'"
    12152614                                                . " AND pm.meta_value LIKE %s"
    1216                                                 . ' ) )',
     2615                                                . ' ) ) )',
    12172616                                                $desc_like,
    12182617                                                $desc_like
    12192618                                        );
     2619                                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    12202620                                } else {
    12212621                                        $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like );
    … …  
    13262726                }
    13272727
     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
    13283043                if ( $needs_custom_query ) {
    13293044                        add_filter(
    … …  
    13383053                        add_filter( 'posts_join', $stock_qty_join_callback, 10, 1 );
    13393054                        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 );
    13403069                }
    13413070
    … …  
    13713100                                remove_filter( 'posts_where', $stock_qty_where_callback, 10 );
    13723101                        }
     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                        }
    13733113                        if ( $search_join_callback ) {
    13743114                                remove_filter( 'posts_join', $search_join_callback, 10 );
    … …  
    14053145
    14063146        /**
     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        /**
    14073323         * Build a WC date range string for date_created / date_modified args.
    14083324         *
    … …  
    14273343                }
    14283344                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' )";
    14293419        }
    14303420
    … …  
    15183508                        $result[] = $product;
    15193509
    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'] ) ) {
    15213515                                // Use pre-collected children IDs when available, falling back to
    15223516                                // loading the parent product (should only happen if called without
    … …  
    15283522                                }
    15293523
     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
    15303559                                foreach ( $children as $child_id ) {
    15313560                                        $variation = wc_get_product( $child_id );
    15323561                                        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 ) ) {
    15343563                                                        continue;
    15353564                                                }
    … …  
    15563585         * Returns an approximate flag because the raw COUNT(*) does not replicate
    15573586         * variation_matches_filters() — variation-level filters (price, stock_qty,
    1558          * manage_stock, backorders, search_description) cause an over-count at
    1559          * 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.
    15603589         *
    15613590         * @param int   $total_parents Total parents matching filters (from main query).
    … …  
    15813610                foreach ( array_chunk( $variable_parent_ids, 1000 ) as $chunk ) {
    15823611                        $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
    1583                         // post_status NOT IN ('trash', 'auto-draft') matches what the main
    1584                         // rendering path surfaces: get_children() returns all non-trashed
    1585                         // child IDs, and wc_get_product() returns any non-auto-draft
    1586                         // variation. Earlier version used IN ('publish', 'private'), which
    1587                         // under-counted drafts. See Product_Service::get_variations() for
    1588                         // the matching path. Parent status filtering is already applied
    1589                         // via the parent-ID query above; this clause is the independent
     3612                        // 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
    15903619                        // VARIATION-status axis in WC's data model.
    15913620                        // 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.
    … …  
    15933622                                "SELECT COUNT(*) FROM {$wpdb->posts}
    15943623                                 WHERE post_type = 'product_variation'
    1595                                    AND post_status NOT IN ('trash', 'auto-draft')
     3624                                   AND post_status IN ('publish', 'private')
    15963625                                   AND post_parent IN ($placeholders)",
    15973626                                ...$chunk
    … …  
    16483677                        return true;
    16493678                }
     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                }
    16503691                if ( ! empty( $filters['manage_stock'] ) ) {
    16513692                        return true;
    … …  
    16543695                        return true;
    16553696                }
     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                }
    16563707                if ( ! empty( $filters['search_description'] ) ) {
    16573708                        return true;
    16583709                }
     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                }
    16593714                // search_short_description is not checked — variations don't have short
    16603715                // 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.
    16613719                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;
    16623809        }
    16633810
    … …  
    17083855                }
    17093856
     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
    17103942                // Manage stock.
    17113943                if ( ! empty( $filters['manage_stock'] ) ) {
    … …  
    17373969                }
    17383970
     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
    17393990                // Short description — variations don't have one (no postmeta equivalent),
    17403991                // 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  
    77
    88namespace WCABE_G2\Services;
     9
     10use WCABE_G2\Helpers\Page_Size_Helper;
    911
    1012defined( 'ABSPATH' ) || exit;
    … …  
    9597                                $view['filters'] = $this->migrate_filters_shape( $view['filters'] );
    9698                        }
     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                        }
    97107                }
    98108                unset( $view );
    … …  
    118128                        'filters'      => $this->sanitize_filters( $data['filters'] ?? [] ),
    119129                        'column_state' => self::sanitize_column_state( $data['column_state'] ?? [] ),
     130                        'page_size'    => Page_Size_Helper::clamp( $data['page_size'] ?? null ),
    120131                        'is_default'   => ! empty( $data['is_default'] ),
    121132                        'created_at'   => gmdate( 'Y-m-d\TH:i:s\Z' ),
    … …  
    173184                }
    174185
     186                if ( isset( $data['page_size'] ) ) {
     187                        $views[ $index ]['page_size'] = Page_Size_Helper::clamp( $data['page_size'] );
     188                }
     189
    175190                if ( isset( $data['is_default'] ) ) {
    176191                        $views[ $index ]['is_default'] = (bool) $data['is_default'];
    … …  
    283298         * Sanitize filters to match the expected FilterValues shape.
    284299         *
     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         *
    285304         * @param array $filters
    286305         * @return array
    287306         */
    288307        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 {
    289350                $allowed_operators = [ '=', '>', '<', '>=', '<=', 'expression' ];
    290351                $id_operator       = $filters['id_operator'] ?? '=';
    … …  
    293354                }
    294355
     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
    295392                $sanitized = [
    296393                        // Original fields.
    297394                        '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,
    299398                        'category'                => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ),
     399                        'tag'                     => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ),
    300400                        'type'                    => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ),
    301401                        'status'                  => array_map( 'sanitize_text_field', (array) ( $filters['status'] ?? [] ) ),
    302402                        '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                        ),
    303415                        'price_min'               => sanitize_text_field( $filters['price_min'] ?? '' ),
    304416                        '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'] ?? '' ),
    305419                        'stock_qty_min'           => sanitize_text_field( $filters['stock_qty_min'] ?? '' ),
    306420                        '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'] ?? '' ),
    307423                        'manage_stock'            => array_map( 'sanitize_text_field', (array) ( $filters['manage_stock'] ?? [] ) ),
    308424                        'backorders'              => array_map( 'sanitize_text_field', (array) ( $filters['backorders'] ?? [] ) ),
    … …  
    315431                        'id_operator' => $id_operator,
    316432                        '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,
    317439
    318440                        // Date filters.
    … …  
    321443                        'date_modified_from' => sanitize_text_field( $filters['date_modified_from'] ?? '' ),
    322444                        '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'] ?? '' ),
    323451
    324452                        // Dynamic filter bags — sanitized below.
    … …  
    326454                        'extension_filters' => [],
    327455                ];
    328 
    329                 // Normalize any pre-Phase-B saved-view shape (old acf_filters /
    330                 // custom_meta_filters / taxonomy_filters keys) into extension_filters
    331                 // before the dynamic-bag sanitization below reads it.
    332                 $filters = $this->migrate_filters_shape( $filters );
    333456
    334457                // Attribute filters: Record<string, number[]> — Free feature.
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/readme.txt

    r3549389 r3663491  
    33Tags: bulk edit, product editor, product bulk edit, woocommerce products, inventory
    44Requires at least: 6.0
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.0.0
     7Stable tag: 1.2.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3131* **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style
    3232* **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
    3536* **Column management** — show, hide, reorder, resize, and pin columns; each user's layout is saved separately
    3637* **Keyboard shortcuts** — navigate and edit without touching the mouse
    3738* **Product operations** — duplicate and change status right from the grid
    3839* **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
    4041* **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
    4244
    4345= Built on proper WooCommerce APIs =
    … …  
    5153= Pro version =
    5254
    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/).
     55If 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/).
    5456
    5557The 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.
    … …  
    9597= Can I undo changes? =
    9698
    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.)
     99Edits 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).
    98100
    99101= Is there a Pro version? =
    100102
    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.
     103Yes. 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
     107Operation 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.
    102108
    103109= Does it support custom post types? =
    … …  
    128134== Changelog ==
    129135
     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
    130194= 1.0.0 =
    131195* Initial release.
    … …  
    133197== Upgrade Notice ==
    134198
     199= 1.2.0 =
     200Major 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
    135202= 1.0.0 =
    136203Initial release.
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/wpmelon-advanced-bulk-edit-for-woocommerce.php

    r3549389 r3663491  
    44 * Plugin URI: https://wpmelon.com/bulk-edit/
    55 * Description: Excel-like bulk editing interface for WooCommerce products
    6  * Version: 1.0.0
     6 * Version: 1.2.0
    77 * Author: WPMelon
    88 * Author URI: https://wpmelon.com/
    … …  
    1010 * Domain Path: /languages
    1111 * Requires at least: 6.0
    12  * Tested up to: 7.0
     12 * Tested up to: 7.1
    1313 * Requires PHP: 8.0
    1414 * Requires Plugins: woocommerce
    1515 * WC requires at least: 10.0
    16  * WC tested up to: 10.7
     16 * WC tested up to: 11.0.1
    1717 * License: GPLv2 or later
    1818 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    126126define( 'WCABE_G2_LOADED', true );
    127127
    128 define( 'WCABE_G2_VERSION', '1.0.0' );
     128define( 'WCABE_G2_VERSION', '1.2.0' );
    129129define( 'WCABE_G2_PLUGIN_FILE', __FILE__ );
    130130define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.