get_status() !== 'publish') { return false; } $parent = $product->is_type('variation') ? wc_get_product($product->get_parent_id()) : $product; return $parent instanceof WC_Product && $parent->get_status() === 'publish' && $parent->get_catalog_visibility() === 'visible' && !post_password_required($parent->get_id()) && Chat4U_Content::public_post(get_post($parent->get_id())) && (bool) apply_filters('chat4u_is_public_product', true, $product, $parent); } private static function bounded(string $text, int $limit = 100000): string { $plain=wp_strip_all_tags($text); if (mb_strlen($plain)>$limit) { throw new LengthException("woocommerce_record_oversized"); } return $plain; } public static function record($product, string $locale): array { $variation = $product->is_type('variation'); $parent = $variation ? wc_get_product($product->get_parent_id()) : $product; $attributes = []; foreach ($product->get_attributes() as $name => $attribute) { if ($attribute instanceof WC_Product_Attribute) { if (!$attribute->get_visible()) { continue; } $attributes[wc_attribute_label($name,$parent)] = $attribute->is_taxonomy() ? array_values(wc_get_product_terms($product->get_id(), $name, ['fields' => 'names'])) : array_map('strval', $attribute->get_options()); } else { $attributes[wc_attribute_label($name,$parent)] = (string) $attribute; } } $record = ['id' => $product->get_id(), 'type' => $variation ? 'variant' : 'product', 'locale' => $locale, 'parent_id' => $variation ? $parent->get_id() : null, 'translation_group' => self::translation_group($parent->get_id()), 'title' => self::bounded($product->get_name(), 500), 'description' => self::bounded($product->get_description() ?: $parent->get_description()), 'short_description' => self::bounded($product->get_short_description() ?: $parent->get_short_description(), 10000), 'sku' => self::bounded($product->get_sku(), 200), 'product_type' => $parent->get_type(), 'attributes' => $attributes, 'url' => $product->get_permalink(), 'image_url' => wp_get_attachment_image_url($product->get_image_id() ?: $parent->get_image_id(), 'large') ?: null, 'updated_at' => $product->get_date_modified() ? $product->get_date_modified()->date('c') : null]; $record['version'] = hash('sha256', wp_json_encode($record)); return $record; } public static function supported_store(): bool { $store = WC_Data_Store::load('product'); return $store->get_current_class_name() === 'WC_Product_Data_Store_CPT'; } public static function prime_products(array $ids): void { // Woo's WC_Data metadata cache includes row IDs and differs from WP's // post-meta cache. Prime both for this bounded CPT page; native CRUD // still applies its normal internal-field and extension filters. $ids=array_values(array_unique(array_filter(array_map('absint',$ids)))); if (!$ids || count($ids)>101 || !self::supported_store()) { return; } _prime_post_caches($ids,true,true); global $wpdb;$groups=array_fill_keys($ids,[]); $sql='SELECT post_id,meta_id,meta_key,meta_value FROM '.$wpdb->postmeta.' WHERE post_id IN ('.implode(',',array_fill(0,count($ids),'%d')).') ORDER BY meta_id'; foreach($wpdb->get_results($wpdb->prepare($sql,...$ids)) as $row) { $id=(int)$row->post_id;unset($row->post_id);$groups[$id][]=$row; } WC_Data::prime_raw_meta_data_cache($groups,'products'); } private static function translation_group(int $id): string { if (function_exists('pll_get_post_translations')) { $translations=pll_get_post_translations($id);return 'pll:'.($translations ? min($translations) : $id); } if (defined('ICL_SITEPRESS_VERSION')) { $group=apply_filters('wpml_element_trid',null,$id,'post_product'); if ($group) { return 'wpml:'.$group; } } return 'product:'.$id; } public static function page(WP_REST_Request $request): array { if (!self::supported_store()) { return ['supported' => false, 'items' => [], 'complete' => false]; } global $wpdb; $after = max(0, (int) $request->get_param('after')); $limit = min(100, max(1, (int) ($request->get_param('limit') ?: 25))); $locale = (string) ($request->get_param('locale') ?: get_locale()); $where = static fn($sql) => $sql . $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", $after); add_filter('posts_where', $where); try { $query = new WP_Query(['post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'suppress_filters' => false]); } finally { remove_filter('posts_where', $where); } $items = []; foreach ($query->posts as $post) { $after = $post->ID; $product = wc_get_product($post->ID); if (Chat4U_Content::locale($post->ID) !== $locale || !self::public_product($product)) { continue; } $items[] = ['id' => $product->get_id(), 'locale' => $locale, 'version' => self::family_version($product->get_id())]; } return ['supported' => true, 'items' => $items, 'cursor' => $after, 'complete' => count($query->posts) < $limit]; } public static function family_version(int $parent): string { global $wpdb; // Fixed-size SQL aggregates include every CPT row, without GROUP_CONCAT // truncation or one query per option. Direct imports need not fire hooks // or update post_modified. Prices and stock stay live and are excluded. $where=$wpdb->prepare('(p.ID=%d OR p.post_parent=%d)',$parent,$parent); $posts=self::fingerprint("{$wpdb->posts} p",$where, 'p.ID,p.post_type,p.post_parent,p.post_status,p.post_password,p.post_name,p.post_title,p.post_content,p.post_excerpt,p.post_modified_gmt'); $meta_where=$where.' AND (m.meta_key IN (\'_product_attributes\',\'_sku\',\'_variation_description\',\'_visibility\') OR m.meta_key LIKE \''.$wpdb->esc_like('attribute_').'%\')'; $meta=self::fingerprint("{$wpdb->postmeta} m JOIN {$wpdb->posts} p ON p.ID=m.post_id",$meta_where,'m.meta_id,m.post_id,m.meta_key,m.meta_value'); $terms=self::fingerprint("{$wpdb->term_relationships} r JOIN {$wpdb->posts} p ON p.ID=r.object_id JOIN {$wpdb->term_taxonomy} x ON x.term_taxonomy_id=r.term_taxonomy_id JOIN {$wpdb->terms} t ON t.term_id=x.term_id",$where,'r.object_id,x.term_taxonomy_id,x.taxonomy,t.name,t.slug'); return hash('sha256',wp_json_encode(['catalog-v2',$posts,$meta,$terms,get_post_meta($parent,'_chat4u_commerce_revision',true),Chat4U_Commerce::settings()['revision']])); } private static function fingerprint(string $from,string $where,string $columns): array { global $wpdb;$parts=[];$hash="SHA2(CONCAT_WS(CHAR(31),$columns),256)"; foreach ([1,17,33,49] as $offset) { $parts[]="BIT_XOR(CAST(CONV(SUBSTRING($hash,$offset,16),16,10) AS UNSIGNED)) AS h$offset"; } // SQL fragments above are fixed plugin-owned expressions. Only parent // IDs enter through wpdb::prepare; no shopper value supplies SQL. return (array)$wpdb->get_row('SELECT COUNT(*) AS n,'.implode(',',$parts)." FROM $from WHERE $where",ARRAY_A); } public static function family(WP_REST_Request $request): array { $id = (int) $request->get_param('id'); $product = wc_get_product($id); $locale = (string) ($request->get_param('locale') ?: get_locale()); if (!$product || $product->is_type('variation') || Chat4U_Content::locale($id) !== $locale || !self::public_product($product)) { return ['removed' => true, 'id' => $id]; } if (!self::supported_store()) { return ['supported' => false]; } $after = max(0, (int) $request->get_param('after')); $limit = 25; global $wpdb; $ids = $wpdb->get_col($wpdb->prepare("SELECT ID FROM {$wpdb->posts} WHERE post_type='product_variation' AND post_parent=%d AND post_status='publish' AND ID > %d ORDER BY ID ASC LIMIT %d", $id, $after, $limit)); self::prime_products(array_merge([$id],$ids)); $records = $after === 0 ? [self::record($product, $locale)] : []; foreach ($ids as $child_id) { $child = wc_get_product((int) $child_id); if (self::public_product($child)) { $records[] = self::record($child, $locale); } } return ['supported' => true, 'id' => $id, 'locale' => $locale, 'version' => self::family_version($id), 'records' => $records, 'cursor' => $ids ? (int) end($ids) : $after, 'complete' => count($ids) < $limit]; } }