filter_excluded_item_ids( $item_ids, $optimize_type ); foreach ( $item_ids as $id ) { if ( 'product' === $optimize_type ) { $item = wc_get_product( $id ); if ( ! $item ) { Logger::write_log( 'info', "No product found for ID {$id}" ); wp_send_json_error( array( 'message' => "No product found for ID {$id}" ) ); } $results[] = $this->get_optimization_request_data_for_product( $item ); } else { // category. $item = get_term( $id, 'product_cat' ); if ( ! $item || is_wp_error( $item ) ) { Logger::write_log( 'info', "No term found for ID {$id}" ); wp_send_json_error( array( 'message' => "No term found for ID {$id}" ) ); } $results[] = $this->get_optimization_request_data_for_category( $item ); } } return $results; } /** * Filter item IDs by excluded settings for products or categories. * * @param array $item_ids IDs to optimize. * @param string $optimize_type Either 'product' or 'category'. * @return int[] */ private function filter_excluded_item_ids( $item_ids, $optimize_type ): array { $item_ids = is_array( $item_ids ) ? $item_ids : array(); $item_ids = array_values( array_filter( array_map( 'absint', $item_ids ) ) ); if ( empty( $item_ids ) ) { return array(); } if ( 'product' === $optimize_type ) { $excluded_ids = Settings::get_excluded_product_ids(); } elseif ( 'category' === $optimize_type ) { $excluded_ids = Settings::get_excluded_category_ids(); } else { return $item_ids; } $excluded_map = ! empty( $excluded_ids ) ? array_flip( array_map( 'intval', $excluded_ids ) ) : array(); // Prime the post meta cache to avoid N+1 queries when checking per-product exclusion. if ( 'product' === $optimize_type && ! empty( $item_ids ) ) { update_meta_cache( 'post', $item_ids ); } // Prime the term meta cache to avoid N+1 queries when checking per-category exclusion. if ( 'category' === $optimize_type && ! empty( $item_ids ) ) { update_meta_cache( 'term', $item_ids ); } return array_values( array_filter( $item_ids, function ( int $item_id ) use ( $excluded_map, $optimize_type ): bool { // Settings-based exclusion. if ( ! empty( $excluded_map ) && isset( $excluded_map[ $item_id ] ) ) { return false; } // Per-product meta-based exclusion (lock). if ( 'product' === $optimize_type && ProductListIntegration::is_product_excluded( $item_id ) ) { return false; } // Per-category term-meta exclusion (lock). if ( 'category' === $optimize_type && CategoryListIntegration::is_category_excluded( $item_id ) ) { return false; } return true; } ) ); } /** * Prepare category data for optimization API. * * @param \WP_Term $category Category term. * @return array */ private function get_optimization_request_data_for_category( $category ) { $original_name = $this->get_category_original_name( $category ); return array( 'term_id' => (int) $category->term_id, 'name' => (string) $category->name, 'original_name' => (string) $original_name, 'parentHierarchy' => $this->get_parent_hierarchy_terms( $category ), 'existingMetaTitle' => (string) get_term_meta( $category->term_id, '_parallax_meta_title', true ), 'existingMetaDescription' => (string) get_term_meta( $category->term_id, '_parallax_meta_desc', true ), 'existingShortDescription' => (string) $category->description, 'slug' => (string) $category->slug, 'count' => (int) $category->count, 'description' => (string) $category->description, 'optimized' => '' !== $original_name && $category->name !== $original_name ? 'Yes' : 'No', ); } /** * Prepare product data for optimization API. * * @param \WC_Product $product Product instance. * @return array */ private function get_optimization_request_data_for_product( $product ) { $product_id = $product->get_id(); $brand_terms = wp_get_object_terms( $product_id, 'product_brand', array( 'fields' => 'names' ) ); $product_brand = is_wp_error( $brand_terms ) ? '' : implode( ', ', $brand_terms ); $existing_meta_title = (string) get_post_meta( $product_id, '_parallax_meta_title', true ); $existing_meta_desc = (string) get_post_meta( $product_id, '_parallax_meta_desc', true ); $existing_short_desc = (string) $product->get_short_description(); $existing_long_desc = (string) $product->get_description(); $site_name = (string) get_bloginfo( 'name' ); $site_description = (string) get_bloginfo( 'description' ); if ( '' !== trim( $site_description ) ) { $fallback_description = sprintf( '%s - %s - %s', (string) $product->get_name(), $site_name, $site_description ); } else { $fallback_description = sprintf( '%s - %s', (string) $product->get_name(), $site_name ); } if ( '' === trim( $existing_short_desc ) && '' === trim( $existing_long_desc ) ) { $existing_short_desc = $fallback_description; $existing_long_desc = $fallback_description; Logger::write_log( 'info', "Product ID {$product_id} has no descriptions; using fallback description." ); } $category_terms = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'names' ) ); $category = is_wp_error( $category_terms ) ? '' : implode( ', ', $category_terms ); return array( 'sku' => (string) $product->get_sku(), 'name' => (string) $product->get_name(), 'upc' => (string) $product->get_global_unique_id(), 'mpn' => (string) $product->get_global_unique_id(), 'productBrand' => (string) $product_brand, 'productBrandDescription' => '', // Required by API schema but not yet populated; brand description is not stored in WooCommerce. 'category' => $category, 'description' => $existing_long_desc, 'existingMetaTitle' => $existing_meta_title, 'existingMetaDescription' => $existing_meta_desc, 'existingShortDescription' => $existing_short_desc, ); } /** * Get the original name of the category from metadata. * * @param \WP_Term $category Category term. * @return string */ private function get_category_original_name( $category ) { $raw_snapshot = get_term_meta( $category->term_id, 'parallax_original_snapshot', true ); $snapshot = maybe_unserialize( $raw_snapshot ); return is_array( $snapshot ) && isset( $snapshot['name'] ) ? (string) $snapshot['name'] : ''; } /** * Get parent hierarchy terms for a given term. * * @param \WP_Term $term Category term. * @return array */ protected function get_parent_hierarchy_terms( $term ) { $parent_names = array(); while ( $term->parent ) { $term = get_term( $term->parent ); if ( ! is_wp_error( $term ) ) { $parent_names[] = $term->name; } } return array_reverse( $parent_names ); } /** * Calculate total credits required for optimization. * * Single source of truth for credit pricing across the plugin. Callers * (AJAX gates, optimize-all chunk logging, payload builder) must all use * this method so the server-side gate and the payload's advertised total * cannot drift apart. * * @param array $meta_fields Fields selected for optimization. * @param int $item_count Number of items in the batch. * @return int */ public static function calculate_total_credits( array $meta_fields, int $item_count ): int { $costs = array( 'meta_title' => 1, 'meta_desc' => 2, 'short_desc' => 3, 'long_desc' => 5, 'product_title_original' => 1, 'product_description_original' => 1, ); $per_item = 0; foreach ( $meta_fields as $field ) { $per_item += $costs[ $field ] ?? 0; } return $per_item * $item_count; } /** * Convert internal field keys to API enum values. * * @param array $fields Internal field keys. * @param string $type Request type ('product'|'category'). * @return array API enum values. */ private static function map_fields_to_api( array $fields, string $type ): array { $map = 'category' === $type ? FieldMap::for_category() : FieldMap::for_product(); return array_values( array_filter( array_map( function ( $field ) use ( $map ) { return $map[ $field ] ?? null; }, $fields ) ) ); } /** * Prepare the data structure for the middleware batch request request. * * @param array $meta_fields_to_optimize Fields to optimize. * @param array $items_to_optimize Items data payload. * @param string $type Request type ('product'|'category'). * @return array */ private static function get_middleware_request_data( $meta_fields_to_optimize, $items_to_optimize, $type ) { $client = Client::load(); $optimize_type = 'category' === $type ? 'categories' : 'products'; $payload = array( 'fieldsToOptimize' => self::map_fields_to_api( $meta_fields_to_optimize, $type ), $optimize_type => $items_to_optimize, 'clientBrand' => $client->get_name(), 'clientBrandDescription' => $client->get_description(), ); if ( 'category' === $type ) { $payload['clientCategoryIdentifier'] = $client->get_client_identifier(); } else { $payload['clientProductIdentifier'] = $client->get_client_identifier(); } // Include total credits for internal tracking by the caller. $payload['total_credits_required'] = self::calculate_total_credits( $meta_fields_to_optimize, count( $items_to_optimize ) ); return $payload; } }