$config ) { $method = $config['method']; $allow_nopriv = $config['allow_nopriv']; $capability = $config['capability']; $nonce_field = $config['nonce_field']; add_action( "wp_ajax_{$handle}_{$action}", array( self::class, $method ) ); if ( $allow_nopriv ) { add_action( "wp_ajax_nopriv_{$handle}_{$action}", array( self::class, $method ) ); } if ( 'import_all_items' === $action ) { // Legacy action alias for cached JS posting the pre-handle action name. add_action( 'wp_ajax_' . WCPAI_NAMESPACE . '_import_all_items', array( self::class, $method ) ); } if ( 'get_products' === $action ) { // Legacy action alias for cached admin JS. add_action( 'wp_ajax_' . WCPAI_NAMESPACE . '_get_products', array( self::class, $method ) ); } self::$config[ $action ] = array( 'cap' => $capability, 'nonce_field' => $nonce_field, ); } } /** * Single source of truth for AJAX nonce action strings. * Assets.php should always call AjaxRequestHandlers::get_nonce_action(). * * @return array> */ private static function get_action_map(): array { return array( 'optimize_all' => array( 'method' => 'optimize_all', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'optimize_all', 'nonce_field' => '_ajax_nonce', ), 'import_item' => array( 'method' => 'import_item_handler', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'import_item', 'nonce_field' => '_ajax_nonce', ), 'del_batreq' => array( 'method' => 'delete_batch_request_handler', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'del_batreq', 'nonce_field' => '_ajax_nonce', ), 'load_approval_table' => array( 'method' => 'load_approval_table', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'load_approval_table', 'nonce_field' => '_ajax_nonce', ), 'import_all_items' => array( 'method' => 'import_all_items_handler', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'import_all_items', 'nonce_field' => '_ajax_nonce', ), 'toggle_hide_optimized' => array( 'method' => 'toggle_hide_optimized', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'toggle_hide_optimized', 'nonce_field' => '_ajax_nonce', ), 'get_products' => array( 'method' => 'handle_item_optimization_request', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'get_products', 'nonce_field' => '_ajax_nonce', ), 'submit_batreq' => array( 'method' => 'handle_submit_batch_request_ajax', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'submit_batreq', 'nonce_field' => '_ajax_nonce', ), 'refresh_batreqs_prods' => array( 'method' => 'refresh_product_batch_requests', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'refresh_batreqs_prods', 'nonce_field' => '_ajax_nonce', ), 'refresh_batreqs_cats' => array( 'method' => 'refresh_category_batch_requests', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'refresh_batreqs_cats', 'nonce_field' => '_ajax_nonce', ), 'refresh_batreqdets_prod' => array( 'method' => 'refresh_product_batch_request_details', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'refresh_batreqdets_prod', 'nonce_field' => '_ajax_nonce', ), 'refresh_batreqdets_cat' => array( 'method' => 'refresh_category_batch_request_details', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'refresh_batreqdets_prod', 'nonce_field' => '_ajax_nonce', ), // intentionally sharing nonce with product details. 'get_batch_item_details' => array( 'method' => 'get_batch_item_details', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'refresh_batreqdets_prod', 'nonce_field' => '_ajax_nonce', ), // intentionally sharing nonce with product/category details. 'dismiss_reset_notice' => array( 'method' => 'dismiss_reset_notice', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'dismiss_reset_notice', 'nonce_field' => '_ajax_nonce', ), 'refresh_single_batch_status' => array( 'method' => 'refresh_single_batch_status', 'allow_nopriv' => false, 'capability' => 'manage_options', 'nonce_action' => 'refresh_single_batch_status', 'nonce_field' => '_ajax_nonce', ), ); } /** * Return the nonce action string for a given AJAX action key. * * IMPORTANT: * - This is the single source of truth for nonce action strings. * - Assets.php must call this to create nonces that will verify in guard(). * * @param string $action_key AJAX action key (same key used in get_action_map()). * @return string Nonce action string, or empty string if missing. */ public static function get_nonce_action( string $action_key ): string { $map = self::get_action_map(); if ( empty( $map[ $action_key ] ) ) { return ''; } $nonce_action = $map[ $action_key ]['nonce_action'] ?? $action_key; return self::AJAX_HANDLE . '_' . $nonce_action; } /** * Normalize an action key, stripping the handle prefix if provided. * * @param string $action_key Action key or prefixed action. * @return string Normalized action key. */ private static function normalize_action_key( string $action_key ): string { $prefix = self::AJAX_HANDLE . '_'; if ( 0 === strpos( $action_key, $prefix ) ) { return substr( $action_key, strlen( $prefix ) ); } return $action_key; } /** * Return the fully rendered HTML for approval/import tables via AJAX. * * @return void */ public static function load_approval_table(): void { self::prime_response_buffer_level(); self::guard( 'load_approval_table' ); $table_key_input = self::get_post_text( 'table_key' ); $table_key = $table_key_input ? sanitize_key( $table_key_input ) : ''; if ( '' === $table_key ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.missing_table_key' ) ), 400 ); } $map = array( 'product_batch_requests' => ProductBatchRequestTable::class, 'category_batch_requests' => CategoryBatchRequestTable::class, 'product_batch_request_details' => ProductBatchRequestDetailsTable::class, 'category_batch_request_details' => CategoryBatchRequestDetailsTable::class, ); if ( ! isset( $map[ $table_key ] ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.unsupported_table' ) ), 400 ); } self::copy_post_params_to_request( array( 'page', 'tab' ), array( 'paged' ) ); $table_class = $map[ $table_key ]; $original_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : null; $override_uri = self::build_admin_request_uri_from_request(); if ( null !== $override_uri ) { $_SERVER['REQUEST_URI'] = $override_uri; } $buffer_level = ob_get_level(); try { ob_start(); $table_class::output_table(); $html = trim( (string) ob_get_clean() ); } finally { while ( ob_get_level() > $buffer_level ) { ob_end_clean(); } if ( null !== $override_uri ) { if ( null !== $original_request_uri ) { $_SERVER['REQUEST_URI'] = $original_request_uri; } else { unset( $_SERVER['REQUEST_URI'] ); } } } self::send_json_success_response( array( 'html' => $html, 'table_key' => $table_key, 'is_empty' => ( '' === $html ), ) ); } /** * Refresh category batch request details table via AJAX. * * @return void */ public static function refresh_category_batch_request_details(): void { self::prime_response_buffer_level(); // Reuse the same nonce/cap guard as product details. self::guard( 'refresh_batreqdets_prod' ); $batch_request_id = self::get_post_int( 'batch_request_id' ) ?? 0; if ( ! $batch_request_id ) { self::send_json_error_response( array( 'message' => 'Missing batch_request_id' ), 400 ); } // Preserve admin context so pagination links are built against admin.php. self::set_request_value( 'page', 'ai-content-by-parallax' ); self::set_request_value( 'tab', 'approval_import' ); $paged = self::get_post_int( 'paged' ); if ( null !== $paged ) { self::set_request_value( 'paged', (string) max( 1, $paged ) ); } // Category details table expects this request param. self::set_request_value( 'category_batch_request_row_id', (string) $batch_request_id ); // Sync status from the API so the detail table sees the latest state. BatchRequest::pull_single( $batch_request_id ); $table = new CategoryBatchRequestDetailsTable( $batch_request_id ); $payload = self::render_list_table_payload( $table ); if ( self::is_debug_enabled() && '1' === ( self::get_post_text( 'debug_middleware_dump' ) ?? '0' ) ) { $payload['middleware_batch_request'] = self::get_full_middleware_batch_request_json( $batch_request_id ); } self::send_json_success_response( $payload ); } /** * Return the current and optimized field data for a single batch item. * * Accepts POST params: * - item_id (int) Product ID or term ID. * - batch_request_id (int) Local batch request row ID. * * The item_type is derived from the stored batch row rather than trusting * a client-supplied POST param, so malformed requests cannot route to the * wrong table implementation. A POST item_type hint, if present, must match * the derived type or the request is rejected. * * Reuses the refresh_batreqdets_prod nonce (details_refresh_nonce in JS). * * @return void */ public static function get_batch_item_details(): void { self::prime_response_buffer_level(); // Capability is resolved from this action's own config; nonce is // explicitly the shared refresh_batreqdets_prod one (details_refresh_nonce // in JS) so every detail-page admin call reuses a single client nonce. self::guard( 'get_batch_item_details', 'refresh_batreqdets_prod' ); $item_id = self::get_post_int( 'item_id' ) ?? 0; $batch_request_id = self::get_post_int( 'batch_request_id' ) ?? 0; if ( ! $item_id || ! $batch_request_id ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.missing_item_params' ) ), 400 ); } // Authoritative: derive item_type from the batch row, not from POST. $batch_row = BatchRequest::get( $batch_request_id ); if ( ! is_array( $batch_row ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.batch_not_found' ) ), 404 ); } $type_raw = strtolower( (string) ( $batch_row['type'] ?? '' ) ); if ( in_array( $type_raw, array( 'product', 'products' ), true ) ) { $item_type = 'product'; } elseif ( in_array( $type_raw, array( 'category', 'categories', 'term', 'terms' ), true ) ) { $item_type = 'category'; } else { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.unsupported_batch' ) ), 400 ); return; } // If the client sent an item_type hint, it must match the derived type. // Mismatch surfaces client bugs instead of silently coercing. $item_type_hint = strtolower( self::get_post_text( 'item_type' ) ?? '' ); if ( '' !== $item_type_hint && $item_type_hint !== $item_type ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.item_type_mismatch' ) ), 400 ); } if ( 'category' === $item_type ) { $table = new CategoryBatchRequestDetailsTable( $batch_request_id ); } else { $table = new ProductBatchRequestDetailsTable( $batch_request_id ); } $details = $table->get_single_item_details( $item_id ); if ( empty( $details ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.item_not_found' ) ), 404 ); } self::send_json_success_response( $details ); } /** * Delete a batch request and its related items via AJAX. * * @return void */ public static function delete_batch_request_handler(): void { self::prime_response_buffer_level(); self::guard( 'del_batreq' ); $batch_request_id = self::get_post_int( 'batch_request_id' ) ?? 0; if ( ! $batch_request_id ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.missing_batch_id' ) ), 400 ); } // Use the model to delete the batch request + its related items. $deleted = BatchRequest::delete_with_items( $batch_request_id ); if ( ! $deleted ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.delete_failed' ) ), 500 ); } self::send_json_success_response( array( 'deleted' => true, 'batch_request_id' => $batch_request_id, ) ); } /** * Helper: verify nonce using either the mapped guard OR a provided override pair. * This preserves existing custom nonce fields JS might already send. * * @param string $action_key Action key registered in self::$config. * @param array $fallback_pairs Array of [action, field] fallback pairs to try. */ protected static function verify_nonce_fallback( string $action_key, array $fallback_pairs = array() ): void { self::ensure_response_buffer_level(); $action_key = self::normalize_action_key( $action_key ); // Primary path: guard based on register() map. $cfg = self::$config[ $action_key ] ?? null; $nonce_field = $cfg['nonce_field'] ?? '_ajax_nonce'; $nonce_action = self::get_nonce_action( $action_key ); $valid = false; // Try the configured pair first. if ( '' !== $nonce_action ) { $nonce_value = self::get_request_value( $nonce_field ); if ( null !== $nonce_value ) { $valid = wp_verify_nonce( $nonce_value, $nonce_action ); } } // If not valid, try any provided fallbacks (e.g. legacy names). if ( ! $valid && $fallback_pairs ) { foreach ( $fallback_pairs as [$fa, $ff] ) { $fallback_value = self::get_request_value( $ff ); if ( null !== $fallback_value && wp_verify_nonce( $fallback_value, $fa ) ) { $valid = true; break; } } } if ( ! $valid ) { if ( self::is_debug_enabled() ) { Logger::write_log( 'info', 'Nonce verification failed in verify_nonce_fallback.', array( 'action_key' => $action_key, 'nonce_action' => $nonce_action, 'nonce_field' => $nonce_field, 'nonce_value_present' => null !== self::get_request_value( $nonce_field ), 'fallback_pairs' => $fallback_pairs, ) ); } self::send_json_error_response( array( 'message' => Messages::get( 'ajax.invalid_nonce' ) ), 403 ); } // Capability check. $cap = $cfg['cap'] ?? 'manage_options'; if ( ! current_user_can( $cap ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.insufficient_perms' ) ), 403 ); } } /** * Handle batch request submission. * * @return void */ public static function handle_submit_batch_request_ajax(): void { self::prime_response_buffer_level(); // Support BOTH mapped nonce and the existing custom pair used in JS. // - mapped: action=ai_content_by_parallax_ajax_submit_batreq, field=_ajax_nonce. // - legacy: action=ai_content_by_parallax_submit_batch_request, field=parallax_submit_batch_request_nonce. self::verify_nonce_fallback( 'submit_batreq', array( array( WCPAI_NAMESPACE . '_submit_batch_request', 'parallax_submit_batch_request_nonce' ) ) ); $sanitized = self::get_sanitized_batch_request_from_post(); if ( is_wp_error( $sanitized ) ) { $status = 400; $data = $sanitized->get_error_data(); if ( is_array( $data ) && isset( $data['status'] ) ) { $status = (int) $data['status']; } self::send_json_error_response( array( 'message' => $sanitized->get_error_message() ), $status ); } $batch_request_type = $sanitized['optimize_type']; $item_ids = self::filter_excluded_item_ids( $sanitized['item_ids'], $batch_request_type ); $skipped_drafts = 0; $item_ids = self::filter_disallowed_status_ids( $item_ids, $batch_request_type, $skipped_drafts ); if ( is_wp_error( $item_ids ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.filter_status_error' ) ), 500 ); } if ( empty( $item_ids ) ) { $message = $skipped_drafts > 0 ? Messages::get( 'ajax.all_excluded_drafts' ) : Messages::get( 'ajax.all_excluded' ); self::send_json_error_response( array( 'message' => $message, 'skipped_drafts' => $skipped_drafts, ), 400 ); } $meta_fields = $sanitized['meta_fields']; $amount = count( $item_ids ); $credits = BuildRequest::calculate_total_credits( $meta_fields, $amount ); // Fail fast on authentication / credit shortage BEFORE building the full middleware payload. // BuildRequest::get_optimization_request_data() also calls Client::load() internally for client // identifier + branding, but that subsequent call hits the warm transient populated here. $client = Client::load(); if ( ! $client->is_user_authenticated() ) { // Client::load() returns credits=0 on any credentials-endpoint failure (invalid API key, transient error, etc.). // Surface the real problem as 401/authentication_required instead of masking it as insufficient credits. Logger::write_log( 'warning', 'Batch request blocked: API client is not authenticated.', array( 'required' => $credits, 'amount' => $amount, ) ); self::send_json_error_response( array( 'message' => Messages::get( 'ajax.authentication_required' ), 'code' => 'authentication_required', ), 401 ); } $available = (int) $client->get_credits(); if ( $credits > $available ) { Logger::write_log( 'warning', 'Batch request blocked: insufficient credits.', array( 'required' => $credits, 'available' => $available, 'amount' => $amount, ) ); self::send_json_error_response( array( 'message' => Messages::format( 'ajax.insufficient_credits', $credits, $available, Endpoints::get_marketing_site_url() ), 'code' => 'insufficient_credits', ), 402 ); } $request_data = ( new BuildRequest() )->get_optimization_request_data( $meta_fields, $item_ids, $batch_request_type ); Logger::write_log( 'info', 'Submitting ' . count( $item_ids ) . ' items for optimization in batch request.', array( 'amount' => $amount, 'total_credits_required' => $credits, 'batch_request_type' => $batch_request_type, 'item_ids' => $item_ids, ) ); $batch_request = MiddlewareRequest::submit_batch_request( (array) $request_data, $batch_request_type ); $batch_id = $batch_request['batch_id'] ?? null; if ( ! $batch_id ) { Logger::write_log( 'error', 'Batch request submission failed.', $batch_request ); $debug_details = array(); if ( self::is_debug_enabled() ) { $debug_details = array( 'api_error' => $batch_request['error'] ?? '', 'api_body' => isset( $batch_request['body'] ) ? substr( wp_strip_all_tags( (string) $batch_request['body'] ), 0, 1024 ) : '', 'endpoint' => $batch_request['endpoint'] ?? '', 'status_code' => $batch_request['status_code'] ?? '', ); } self::send_json_error_response( array( 'message' => Messages::get( 'ajax.submit_failed' ), 'debug' => $debug_details, ), 500 ); } $batch_request_args = array( 'batch_request_id' => $batch_id, 'type' => (string) $batch_request_type, 'status' => 'In Progress', 'total_expected' => $amount, 'metadata' => array( 'user_id' => get_current_user_id(), 'total_credits_required' => $credits, 'item_ids' => $item_ids, ), ); $batch_request_id = BatchRequest::create( $batch_request_args ); if ( ! $batch_request_id ) { Logger::write_log( 'info', "Failed to create local batch request record for batch id {$batch_id}.", $batch_request_args ); self::send_json_error_response( array( 'message' => 'Failed to create local batch request record for batch id ' . $batch_id . ' Batch Request args: ' . wp_json_encode( $batch_request_args ) ), 500 ); } Logger::write_log( 'info', "Batch request {$batch_request_id} created with ID {$batch_id} for {$amount} items for {$credits} credits by user " . get_current_user_id() ); self::send_json_success_response( array( 'status' => 'queued', 'id' => $batch_request_id, 'batch_id' => $batch_id, 'total_credits_required' => $credits, 'skipped_drafts' => $skipped_drafts, ) ); } /** * Handle a product or category optimization AJAX request. */ public static function handle_item_optimization_request(): void { self::prime_response_buffer_level(); // Support BOTH mapped nonce and the existing custom pair. // - mapped: action=ai_content_by_parallax_ajax_get_products, field=_ajax_nonce. // - legacy: action=ai_content_by_parallax_get_products, field=parallax_get_products_nonce. self::verify_nonce_fallback( 'get_products', array( array( WCPAI_NAMESPACE . '_get_products', 'parallax_get_products_nonce' ), array( WCPAI_NAMESPACE . '_get_products', '_ajax_nonce' ), ) ); $item_ids = self::get_post_int_array( 'item_ids' ); if ( empty( $item_ids ) ) { Logger::write_log( 'info', 'No products or terms provided for optimization.' ); self::send_json_error_response( array( 'message' => 'No products or terms provided' ), 400 ); } $optimize_type = self::get_post_text( 'optimize_type' ) ?? 'product'; $item_ids = self::filter_excluded_item_ids( $item_ids, $optimize_type ); $skipped_drafts = 0; $item_ids = self::filter_disallowed_status_ids( $item_ids, $optimize_type, $skipped_drafts ); if ( is_wp_error( $item_ids ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.filter_status_error' ) ), 500 ); } if ( empty( $item_ids ) ) { Logger::write_log( 'info', 'All requested items are excluded from optimization.' ); $message = $skipped_drafts > 0 ? Messages::get( 'ajax.all_excluded_drafts' ) : Messages::get( 'ajax.all_excluded' ); self::send_json_error_response( array( 'message' => $message, 'skipped_drafts' => $skipped_drafts, ), 400 ); } $client = Client::load(); $args = array( 'fieldsToOptimize' => self::get_post_text_array( 'meta_fields' ), 'products' => ( new BuildRequest() )->get_items_to_optimize( $item_ids, $optimize_type ), 'clientIdentifier' => str_replace( '.', '_', (string) wp_parse_url( site_url(), PHP_URL_HOST ) ), 'clientBrand' => $client->get_name(), 'clientBrandDescription' => $client->get_description(), 'total_credits_required' => self::get_post_int( 'total_credits_required' ) ?? 0, 'optimize_type' => $optimize_type, 'item_ids' => $item_ids, 'skipped_drafts' => $skipped_drafts, ); Logger::write_log( 'info', 'Product optimization request received.', $args ); self::send_json_success_response( $args ); } /** * Filter item IDs by exclude lists for products or categories. * * @param int[] $item_ids Item IDs to filter. * @param string $optimize_type Optimize type ('product' or 'category'). * @return int[] */ private static function filter_excluded_item_ids( array $item_ids, string $optimize_type ): 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 = array_flip( array_map( 'intval', $excluded_ids ) ); // 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 ) { if ( ProductListIntegration::is_product_excluded( $item_id ) ) { return false; } } // Per-category term-meta-based exclusion (lock). if ( 'category' === $optimize_type ) { if ( CategoryListIntegration::is_category_excluded( $item_id ) ) { return false; } } return true; } ) ); } /** * Filter out product IDs whose post status is not allowed by the * "Include drafts" optimization setting. Categories pass through * unchanged because terms have no draft state. * * @param int[] $item_ids Item IDs to filter. * @param string $optimize_type Optimize type ('product' or 'category'). * @param int $skipped_count Receives the count of removed IDs (out param). * @return int[]|\WP_Error Filtered IDs preserving order, or WP_Error if the status query fails. */ private static function filter_disallowed_status_ids( array $item_ids, string $optimize_type, int &$skipped_count = 0 ): array|\WP_Error { $skipped_count = 0; $item_ids = array_values( array_filter( array_map( 'absint', $item_ids ) ) ); if ( empty( $item_ids ) || 'product' !== $optimize_type ) { return $item_ids; } if ( Settings::include_drafts() ) { return $item_ids; } $allowed = wc_get_products( array( 'include' => $item_ids, 'status' => Settings::get_product_statuses_for_optimize(), 'return' => 'ids', 'limit' => -1, ) ); if ( is_wp_error( $allowed ) ) { Logger::write_log( 'error', 'filter_disallowed_status_ids: wc_get_products returned WP_Error; cannot verify product statuses.', array( 'error_code' => $allowed->get_error_code(), 'error_message' => $allowed->get_error_message(), 'item_ids' => $item_ids, 'optimize_type' => $optimize_type, ) ); return $allowed; } $allowed_map = array_flip( array_map( 'intval', is_array( $allowed ) ? $allowed : array() ) ); $kept = array_values( array_filter( $item_ids, static function ( int $item_id ) use ( $allowed_map ): bool { return isset( $allowed_map[ $item_id ] ); } ) ); $skipped_count = count( $item_ids ) - count( $kept ); return $kept; } /** * Filter item IDs by optimized status when hide-optimized is enabled. * * @param int[] $item_ids Item IDs to filter. * @param string $optimize_type Optimize type ('product' or 'category'). * @param int $excluded_count Output count of optimized items removed. * @return int[] */ private static function filter_hide_optimized_item_ids( array $item_ids, string $optimize_type, int &$excluded_count ): array { $excluded_count = 0; $item_ids = array_values( array_filter( array_map( 'absint', $item_ids ) ) ); if ( empty( $item_ids ) ) { return array(); } $filtered = array(); foreach ( $item_ids as $item_id ) { $is_optimized = false; if ( 'product' === $optimize_type ) { $is_optimized = self::is_product_optimized_for_optimize_all( $item_id ); } elseif ( 'category' === $optimize_type ) { $is_optimized = self::is_term_optimized_for_optimize_all( $item_id ); } if ( $is_optimized ) { ++$excluded_count; continue; } $filtered[] = $item_id; } return $filtered; } /** * Determine if a product is already optimized for hide-optimized filtering. * * @param int $product_id Product ID to check. * @return bool */ private static function is_product_optimized_for_optimize_all( int $product_id ): bool { if ( $product_id <= 0 ) { return false; } $status = (string) get_post_meta( $product_id, '_parallax_import_status', true ); if ( 'imported' === $status ) { return true; } if ( '' === $status ) { $timestamp = (string) get_post_meta( $product_id, '_parallax_imported_at', true ); $has_backup = metadata_exists( 'post', $product_id, '_parallax_prev_payload' ); if ( '' !== $timestamp && $has_backup ) { return true; } } $product = get_post( $product_id ); if ( ! $product ) { return false; } $snapshot = get_post_meta( $product_id, \AIContentByParallax\Features\OriginalSnapshot::POST_META_KEY, true ); $snapshot = maybe_unserialize( $snapshot ); if ( is_array( $snapshot ) ) { $original_title = (string) ( $snapshot['title'] ?? '' ); if ( '' !== $original_title && (string) $product->post_title !== $original_title ) { return true; } } $legacy_original = (string) get_post_meta( $product_id, 'product_title_original', true ); if ( '' !== $legacy_original && (string) $product->post_title !== $legacy_original ) { return true; } return false; } /** * Determine if a term is already optimized for hide-optimized filtering. * * @param int $term_id Term ID to check. * @return bool */ public static function is_term_optimized_for_optimize_all( int $term_id ): bool { if ( $term_id <= 0 ) { return false; } $term = get_term( $term_id ); if ( ! $term || is_wp_error( $term ) ) { return false; } $snapshot_raw = get_term_meta( $term_id, \AIContentByParallax\Features\OriginalSnapshot::TERM_META_KEY, true ); $snapshot = maybe_unserialize( $snapshot_raw ); $snapshot = is_array( $snapshot ) ? $snapshot : array(); $original_name = (string) ( $snapshot['name'] ?? '' ); $original_desc = (string) ( $snapshot['description'] ?? '' ); $original_meta = isset( $snapshot['parallax_meta'] ) && is_array( $snapshot['parallax_meta'] ) ? $snapshot['parallax_meta'] : array(); $original_meta_title = (string) ( $original_meta['title'] ?? '' ); $original_meta_desc = (string) ( $original_meta['description'] ?? '' ); $current_name = (string) ( $term->name ?? '' ); $current_desc = (string) ( $term->description ?? '' ); $current_meta_title = (string) get_term_meta( $term_id, '_parallax_meta_title', true ); $current_meta_desc = (string) get_term_meta( $term_id, '_parallax_meta_desc', true ); if ( '' !== $original_name && $original_name !== $current_name ) { return true; } if ( '' !== $original_desc && $original_desc !== $current_desc ) { return true; } if ( array() !== $snapshot ) { if ( '' === $original_desc && '' !== $current_desc ) { return true; } if ( '' === $original_meta_title && '' !== $current_meta_title ) { return true; } if ( '' === $original_meta_desc && '' !== $current_meta_desc ) { return true; } } if ( '' !== $current_meta_title && $current_meta_title !== $original_meta_title ) { return true; } if ( '' !== $current_meta_desc && $current_meta_desc !== $original_meta_desc ) { return true; } if ( array() === $snapshot && ( '' !== $current_meta_title || '' !== $current_meta_desc ) ) { return true; } return false; } /** * Small helper to DRY up nonce + capability checks per action. * * Capability is always resolved from the passed action's own config, so * future cap changes to a handler's registered action key take effect * without hunting down guard() call sites. * * Nonce defaults to the action key's own canonical string but can be * overridden when an endpoint intentionally shares a nonce with another * action (e.g. refresh_batreqdets_cat and get_batch_item_details both * reuse the refresh_batreqdets_prod nonce shipped to the client). * * @param string $action Action key registered in config map. * @param string|null $nonce_action_override Optional action key whose nonce to verify instead. */ protected static function guard( string $action, ?string $nonce_action_override = null ): void { self::ensure_response_buffer_level(); $action_key = self::normalize_action_key( $action ); $cfg = self::$config[ $action_key ] ?? null; $nonce_field = $cfg['nonce_field'] ?? '_ajax_nonce'; $nonce_action_key = null === $nonce_action_override ? $action_key : self::normalize_action_key( $nonce_action_override ); $nonce_action = self::get_nonce_action( $nonce_action_key ); $nonce_value = self::get_request_value( $nonce_field ); if ( '' === $nonce_action || null === $nonce_value || ! wp_verify_nonce( $nonce_value, $nonce_action ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.invalid_nonce' ) ), 403 ); } $cap = $cfg['cap'] ?? 'manage_options'; if ( ! current_user_can( $cap ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.insufficient_perms' ) ), 403 ); } } /** * Send a JSON error response with optional debug payload. * * @param string $message Error message. * @param int $status HTTP status code. * @param array $debug Optional debug payload. */ private static function send_json_error( string $message, int $status = 400, array $debug = array() ): void { $response = array( 'message' => $message ); if ( ! empty( $debug ) ) { $response['debug'] = $debug; } self::send_json_error_response( $response, $status ); } /** * Close output buffers down to the target level. * * @param int $target_level Buffer level to end at. */ private static function close_output_buffers( int $target_level ): void { while ( ob_get_level() > $target_level ) { ob_end_clean(); } } /** * Persist a target buffer level for JSON responses. * * @param int $level Output buffer level to retain. */ private static function set_response_buffer_level( int $level ): void { self::$request_context['response_buffer_level'] = $level; } /** * Prime the response buffer level for this request. */ private static function prime_response_buffer_level(): void { self::set_response_buffer_level( ob_get_level() ); } /** * Ensure a response buffer level is tracked for this request. */ private static function ensure_response_buffer_level(): void { if ( ! isset( self::$request_context['response_buffer_level'] ) ) { self::$request_context['response_buffer_level'] = ob_get_level(); } } /** * Close output buffers back to the tracked response buffer level. */ private static function close_response_buffers(): void { self::ensure_response_buffer_level(); $target_level = self::$request_context['response_buffer_level']; $target_level = is_int( $target_level ) ? $target_level : 0; self::close_output_buffers( $target_level ); } /** * Send a JSON success response after clearing stray output. * * @param mixed $response Response payload. * @param int|null $status Optional HTTP status. */ private static function send_json_success_response( $response, ?int $status = null ): void { self::close_response_buffers(); if ( null === $status ) { wp_send_json_success( $response ); } else { wp_send_json_success( $response, $status ); } } /** * Send a JSON error response after clearing stray output. * * @param mixed $response Response payload. * @param int $status HTTP status. */ private static function send_json_error_response( $response, int $status ): void { self::close_response_buffers(); wp_send_json_error( $response, $status ); } /** * Log AJAX exceptions when debug support is enabled. * * @param \Throwable $e Exception thrown. * @param string $action AJAX action name. * @param array $context Context values. */ private static function log_ajax_exception( \Throwable $e, string $action, array $context = array() ): void { if ( ! self::is_debug_enabled() ) { return; } $context['action'] = $action; $context['error'] = $e->getMessage(); $context['file'] = $e->getFile(); $context['line'] = $e->getLine(); $context['trace'] = $e->getTraceAsString(); Logger::write_log( 'info', '[AjaxError] ' . $e->getMessage(), $context ); } /** * Handle the "Import All Items" button click for a given batch request ID. */ public static function import_all_items_handler(): void { $buffer_level = ob_get_level(); self::set_response_buffer_level( $buffer_level ); self::guard( 'import_all_items' ); $action = self::AJAX_HANDLE . '_import_all_items'; $batch_request_id = self::get_post_int( 'batch_request_id' ) ?? 0; $offset = self::get_post_int( 'offset' ) ?? 0; $type = ''; $meta = array(); $ids = array(); try { if ( ! $batch_request_id ) { self::log_import_all_failure( 'Missing batch_request_id.', array( 'action' => $action, 'batch_request_id' => $batch_request_id, 'type' => '', 'meta_keys' => array(), 'parsed_ids_count' => 0, ) ); self::send_json_error_response( array( 'message' => 'Missing batch_request_id.' ), 400 ); } if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'admin' ); } $batch_request = \AIContentByParallax\Models\BatchRequest::get( $batch_request_id ); if ( ! $batch_request ) { self::log_import_all_failure( 'Batch Request not found.', array( 'action' => $action, 'batch_request_id' => $batch_request_id, 'type' => '', 'meta_keys' => array(), 'parsed_ids_count' => 0, ) ); self::send_json_error_response( array( 'message' => 'Batch Request not found.' ), 404 ); } $type_raw = isset( $batch_request['type'] ) ? (string) $batch_request['type'] : ''; $type = self::normalize_batch_request_type( $type_raw ); $meta = self::normalize_metadata( $batch_request['metadata'] ?? array() ); $ids = self::extract_item_ids_from_metadata( $meta, $type ); if ( '' === $type ) { self::log_import_all_failure( 'Unsupported batch request type for bulk import.', array( 'action' => $action, 'batch_request_id' => $batch_request_id, 'type' => $type_raw, 'meta_keys' => array_keys( $meta ), 'parsed_ids_count' => count( $ids ), ) ); self::send_json_error_response( array( 'message' => 'Unsupported batch request type for bulk import.' ), 400 ); } if ( ! $ids ) { self::log_import_all_failure( 'No item IDs found in batch request metadata.', array( 'action' => $action, 'batch_request_id' => $batch_request_id, 'type' => $type, 'meta_keys' => array_keys( $meta ), 'parsed_ids_count' => 0, ) ); self::send_json_error_response( array( 'message' => 'No item IDs found in batch request metadata.' ), 400 ); } $total_ids = count( $ids ); // Chunk size reduced from 40 to 25 to prevent timeouts and reduce memory usage during import. $chunk_size = (int) apply_filters( WCPAI_NAMESPACE . '_import_all_chunk_size', 25, $type, $batch_request_id ); if ( $chunk_size < 1 ) { $chunk_size = 1; } if ( $offset >= $total_ids ) { $offset = max( 0, $total_ids - $chunk_size ); } $batch_ids = array_slice( $ids, $offset, $chunk_size ); if ( ! $batch_ids ) { update_option( WCPAI_NAMESPACE . '_import_notice', array( 'type' => 'info', /* translators: %d: batch request ID. */ 'message' => Messages::format( 'ajax.no_items_import', $batch_request_id ), ), false ); self::send_json_success_response( array( 'done' => true, 'processed' => 0, 'processed_total' => min( $total_ids, max( 0, $offset ) ), 'total' => $total_ids, 'next_offset' => null, 'batch_request_type' => $type, ) ); } $processed = count( $batch_ids ); $next_offset = $offset + $processed; $has_more = $next_offset < $total_ids; if ( 'product' === $type ) { $table = new ProductBatchRequestDetailsTable( $batch_request_id ); self::set_request_value( 'action', 'bulk_approve' ); self::set_request_array( 'product_batch_request_item_ids', $batch_ids ); self::set_request_value( '_wpnonce', wp_create_nonce( WCPAI_NAMESPACE . '_product_action' ) ); self::set_request_value( 'product_batch_request_row_id', (string) $batch_request_id, true ); try { $table->process_actions(); } catch ( \Throwable $e ) { self::handle_import_all_exception( $e, $action, $batch_request_id, $type, $meta, $ids ); } // Notify the API only for non-rejected products in this chunk. $approved_product_ids = array_values( array_filter( $batch_ids, static function ( $id ) { return get_post_meta( (int) $id, '_parallax_import_status', true ) !== 'rejected'; } ) ); self::notify_item_status( $batch_request_id, 'product', 'approve', $approved_product_ids ); if ( $has_more ) { self::send_json_success_response( array( 'done' => false, 'processed' => $processed, 'processed_total' => min( $total_ids, $next_offset ), 'total' => $total_ids, 'next_offset' => $next_offset, 'batch_request_type' => $type, ) ); } $payload = self::render_list_table_payload( $table ); $processed_total = min( $total_ids, $next_offset ); update_option( WCPAI_NAMESPACE . '_import_notice', array( 'type' => 'success', /* translators: %1$d: number of processed items, %2$d: total items, %3$d: batch request ID. */ 'message' => Messages::format( 'ajax.imported_products', $processed_total, $total_ids, $batch_request_id ), ), false ); self::send_json_success_response( array_merge( array( 'done' => true, 'processed' => $processed, 'processed_total' => $processed_total, 'total' => $total_ids, 'next_offset' => null, 'message' => "Imported all items for batch request {$batch_request_id}.", 'batch_request_id' => $batch_request_id, 'batch_request_type' => $type, ), $payload ) ); } if ( 'category' === $type ) { $table = new CategoryBatchRequestDetailsTable( $batch_request_id ); self::set_request_value( 'action', 'bulk_approve' ); self::set_request_array( 'category_batch_request_item_ids', $batch_ids ); self::set_request_value( '_wpnonce', wp_create_nonce( WCPAI_NAMESPACE . '_cocat_action' ) ); self::set_request_value( 'category_batch_request_row_id', (string) $batch_request_id, true ); try { $table->process_actions(); } catch ( \Throwable $e ) { self::handle_import_all_exception( $e, $action, $batch_request_id, $type, $meta, $ids ); } // Notify the API only for non-rejected categories in this chunk. $approved_category_ids = array_values( array_filter( $batch_ids, static function ( $id ) { return get_term_meta( (int) $id, '_parallax_import_status', true ) !== 'rejected'; } ) ); self::notify_item_status( $batch_request_id, 'category', 'approve', $approved_category_ids ); if ( $has_more ) { self::send_json_success_response( array( 'done' => false, 'processed' => $processed, 'processed_total' => min( $total_ids, $next_offset ), 'total' => $total_ids, 'next_offset' => $next_offset, 'batch_request_type' => $type, ) ); } $payload = self::render_list_table_payload( $table ); $processed_total = min( $total_ids, $next_offset ); update_option( WCPAI_NAMESPACE . '_import_notice', array( 'type' => 'success', /* translators: %1$d: number of processed items, %2$d: total items, %3$d: batch request ID. */ 'message' => Messages::format( 'ajax.imported_categories', $processed_total, $total_ids, $batch_request_id ), ), false ); self::send_json_success_response( array_merge( array( 'done' => true, 'processed' => $processed, 'processed_total' => $processed_total, 'total' => $total_ids, 'next_offset' => null, 'message' => "Imported all items for batch request {$batch_request_id}.", 'batch_request_id' => $batch_request_id, 'batch_request_type' => $type, ), $payload ) ); } self::send_json_error_response( array( 'message' => 'Unsupported batch request type for bulk import.' ), 400 ); } catch ( \Throwable $e ) { self::close_output_buffers( $buffer_level ); self::handle_import_all_exception( $e, $action, $batch_request_id, $type, $meta, $ids ); } } /** * Handle the "Import Item" action for the row specific "Import" button click. */ public static function import_item_handler(): void { $buffer_level = ob_get_level(); self::set_response_buffer_level( $buffer_level ); $action_name = self::AJAX_HANDLE . '_import_item'; try { self::guard( 'import_item' ); $item_id = self::get_post_int( 'item_id' ) ?? 0; $batch_request_id = self::get_post_int( 'batch_request_id' ) ?? 0; $item_type_raw = self::get_post_text( 'item_type' ) ?? 'product'; $action_raw = self::get_post_text( 'action_type' ) ?? 'approve'; // Use sanitize_textarea_field to preserve intentional line breaks. // Guard is_string() first: array input (rejection_comment[]=) would pass isset() // but cause sanitize_textarea_field / strip_tags to fatal on a non-string. // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified by guard() above. $comment = isset( $_POST['rejection_comment'] ) && is_string( $_POST['rejection_comment'] ) ? sanitize_textarea_field( wp_unslash( $_POST['rejection_comment'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! $item_id || ! $batch_request_id ) { self::send_json_error( 'Missing item_id or batch_request_id.', 400 ); } $item_type = in_array( strtolower( $item_type_raw ), array( 'product', 'category' ), true ) ? strtolower( $item_type_raw ) : 'product'; $action = in_array( strtolower( $action_raw ), array( 'approve', 'reject', 'revert', 'undo_reject' ), true ) ? strtolower( $action_raw ) : 'approve'; $payload = array(); if ( 'category' === $item_type ) { $table = new CategoryBatchRequestDetailsTable( $batch_request_id ); self::set_request_value( 'cocat_action', $action, true ); self::set_request_value( 'term_id', (string) $item_id, true ); self::set_request_value( 'category_batch_request_row_id', (string) $batch_request_id, true ); self::set_request_value( '_wpnonce', wp_create_nonce( WCPAI_NAMESPACE . '_cocat_action' ), true ); $table->process_actions(); $payload = self::render_list_table_payload( $table ); } else { $table = new ProductBatchRequestDetailsTable( $batch_request_id ); self::set_request_value( 'product_action', $action, true ); self::set_request_value( 'product_id', (string) $item_id, true ); self::set_request_value( 'product_batch_request_row_id', (string) $batch_request_id, true ); self::set_request_value( '_wpnonce', wp_create_nonce( WCPAI_NAMESPACE . '_product_action' ), true ); $table->process_actions(); $payload = self::render_list_table_payload( $table ); } Base_Table::clear_request_override(); // Notify the Parallax API of the status change (undo_reject is local-only). if ( 'undo_reject' !== $action ) { self::notify_item_status( $batch_request_id, $item_type, $action, array( $item_id ), $comment ); } if ( 'reject' === $action ) { /* translators: 1: item id, 2: batch request id. */ $message = Messages::format( 'ajax.rejected_item', $item_id, $batch_request_id ); } elseif ( 'revert' === $action ) { /* translators: 1: item id, 2: batch request id. */ $message = Messages::format( 'ajax.reverted_item', $item_id, $batch_request_id ); } elseif ( 'undo_reject' === $action ) { $message = ''; } else { /* translators: 1: item id, 2: batch request id. */ $message = Messages::format( 'ajax.imported_item', $item_id, $batch_request_id ); } self::send_json_success_response( array_merge( array( 'message' => $message, 'action' => $action, 'item_type' => $item_type, 'item_id' => $item_id, 'batch_request_id' => $batch_request_id, ), $payload ) ); } catch ( \Throwable $e ) { $context = array( 'user_id' => get_current_user_id(), 'item_id' => self::get_post_int( 'item_id' ) ?? 0, 'batch_request_id' => self::get_post_int( 'batch_request_id' ) ?? 0, 'item_type' => self::get_post_text( 'item_type' ) ?? '', 'action_type' => self::get_post_text( 'action_type' ) ?? '', ); self::close_output_buffers( $buffer_level ); self::log_ajax_exception( $e, $action_name, $context ); self::send_json_error( 'Internal error', 500, array( 'code' => 'server_error' ) ); } } /** * Refresh the product batch requests table. */ public static function refresh_product_batch_requests(): void { self::prime_response_buffer_level(); self::guard( 'refresh_batreqs_prods' ); $table = new ProductBatchRequestTable(); $batch_requests = new BatchRequest(); $batch_requests->pull_all(); // Let WP_List_Table read current state from $_REQUEST. self::copy_post_params_to_request( array( 'orderby', 'order' ), array( 'paged' ) ); $payload = self::render_list_table_payload( $table ); self::send_json_success_response( $payload ); } /** * Re-fetch a single batch request status from the middleware on demand. * * Expected POST fields: * - batch_request_id (string): The remote batch GUID. * * Returns JSON: * - status_value (string): The raw DB status after refresh. * - status_label (string): Human-readable label for the updated status. */ public static function refresh_single_batch_status(): void { self::prime_response_buffer_level(); self::guard( 'refresh_single_batch_status' ); $batch_request_id = self::get_request_value( 'batch_request_id' ); if ( null === $batch_request_id || '' === $batch_request_id ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.missing_batch_request_id' ) ), 400 ); } $result = BatchRequest::pull_single( $batch_request_id ); if ( ! empty( $result['error'] ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.batch_not_found' ) ), 404 ); } $row = $result['batch_request'] ?? null; $raw_status = is_array( $row ) ? (string) ( $row['status'] ?? '' ) : ''; $enum_case = BatchRequestStatus::from_api_status( $raw_status ); $status_label = $enum_case ? BatchRequestStatus::to_human_readable( $enum_case ) : ( '' !== $raw_status ? $raw_status : Messages::get( 'enums.status_unrecognized' ) ); self::send_json_success_response( array( 'status_value' => $raw_status, 'status_label' => $status_label, ) ); } /** * Refresh the category batch requests table. */ public static function refresh_category_batch_requests(): void { self::prime_response_buffer_level(); self::guard( 'refresh_batreqs_cats' ); $table = new CategoryBatchRequestTable(); $batch_requests = new BatchRequest(); $batch_requests->pull_all(); self::copy_post_params_to_request( array( 'orderby', 'order' ), array( 'paged' ) ); $payload = self::render_list_table_payload( $table ); self::send_json_success_response( $payload ); } /** * Toggle the "hide optimized" state for optimize tables via AJAX. * * Expected POST parameters: * - table_type (string): The type of table to update. One of 'product', 'category', or 'tag'. * - hide_optimized (string): '1' to hide optimized items, '0' to show them. * - paged (int, optional): The page number for pagination (default: 0). * - s (string, optional): Search term. * - product_cat (string, optional): Product category filter. * - orderby (string, optional): Column to order by. * - order (string, optional): Sort direction ('asc' or 'desc'). * * Security checks performed: * - Calls self::guard( 'toggle_hide_optimized' ) to verify nonce and permissions. * * JSON response structure (on success): * - toggle_label (string): The label for the toggle button ('Show Optimized' or 'Hide Optimized'). * - hide_value (string): '1' if optimized items are hidden, '0' otherwise. * - Additional keys as returned by self::render_list_table_payload($table), typically including: * - html (string): The rendered HTML for the table. * - pagination (array): Pagination data. * - other table-specific data. * * On error, returns: * - message (string): Error message. * - HTTP status code 400. */ public static function toggle_hide_optimized(): void { self::prime_response_buffer_level(); self::guard( 'toggle_hide_optimized' ); $table_type = self::get_post_text( 'table_type' ) ?? ''; $map = array( 'product' => Filtered_Products::class, 'category' => Filtered_Categories::class, 'tag' => Filtered_Tags::class, ); if ( ! isset( $map[ $table_type ] ) ) { self::send_json_error_response( array( 'message' => 'Unknown table type.' ), 400 ); } $hide_value_raw = self::get_post_text( 'hide_optimized' ); $hide_value = $hide_value_raw ?? '0'; $hide_value = '1' === $hide_value ? '1' : '0'; self::set_request_value( 'hide_optimized', $hide_value, true ); $int_params = array( 'paged' ); foreach ( $int_params as $param ) { $value = self::get_post_int( $param ); if ( null !== $value ) { self::set_request_value( $param, (string) $value, true ); } } $string_params = array( 's', 'product_cat', 'orderby', 'order', 'page', 'tab', 'post_type', 'section' ); foreach ( $string_params as $param ) { $value = self::get_post_text( $param ); if ( null !== $value ) { self::set_request_value( $param, $value, true ); } } $table_class = $map[ $table_type ]; $table = new $table_class(); $payload = self::render_list_table_payload( $table ); $payload['toggle_label'] = ( '1' === $hide_value ) ? esc_html( Messages::get( 'ajax.show_optimized' ) ) : esc_html( Messages::get( 'ajax.hide_optimized' ) ); $payload['hide_value'] = $hide_value; self::send_json_success_response( $payload ); } /** * Refresh the product batch request details table for a given batch request ID. */ public static function refresh_product_batch_request_details(): void { self::prime_response_buffer_level(); self::guard( 'refresh_batreqdets_prod' ); $batch_request_id = self::get_post_int( 'batch_request_id' ) ?? 0; if ( ! $batch_request_id ) { self::send_json_error_response( array( 'message' => 'Missing batch_request_id' ), 400 ); } // Preserve admin context so pagination links are built against admin.php. self::set_request_value( 'page', 'ai-content-by-parallax' ); self::set_request_value( 'tab', 'approval_import' ); $paged = self::get_post_int( 'paged' ); if ( null !== $paged ) { self::set_request_value( 'paged', (string) max( 1, $paged ) ); } self::set_request_value( 'product_batch_request_row_id', (string) $batch_request_id ); // Sync status from the API so the detail table sees the latest state. BatchRequest::pull_single( $batch_request_id ); $table = new ProductBatchRequestDetailsTable( $batch_request_id ); $payload = self::render_list_table_payload( $table ); if ( self::is_debug_enabled() && '1' === ( self::get_post_text( 'debug_middleware_dump' ) ?? '0' ) ) { $payload['middleware_batch_request'] = self::get_full_middleware_batch_request_json( $batch_request_id ); } self::send_json_success_response( $payload ); } /** * Handle the "Optimize All" button click for products or categories. * Keeps current behavior: fetch all published products (or drafts if enabled) or all product_cat terms. */ public static function optimize_all(): void { self::prime_response_buffer_level(); self::guard( 'optimize_all' ); $request_id = wp_generate_uuid4(); self::register_optimize_all_shutdown_handler( $request_id ); $action = self::AJAX_HANDLE . '_optimize_all'; $started_at = microtime( true ); try { $item_type_raw = self::get_post_text( 'item_type' ) ?? 'product'; $item_type = self::sanitize_optimize_type( $item_type_raw ); if ( null === $item_type ) { self::send_optimize_all_error( $request_id, 'Invalid item type. Must be "product" or "category".', 'invalid_item_type', 400, array( 'item_type' => $item_type_raw ) ); } $meta_fields_to_optimize = self::sanitize_fields_to_optimize( self::get_post_text_array( 'meta_fields' ) ); if ( empty( $meta_fields_to_optimize ) ) { self::send_optimize_all_error( $request_id, 'Select at least one field to optimize.', 'missing_fields', 400, array( 'item_type' => $item_type ) ); } if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'admin' ); } $offset = max( 0, self::get_post_int( 'offset' ) ?? 0 ); $default_chunk_size = Settings::get_max_batch_item_ids(); $chunk_size = (int) apply_filters( WCPAI_NAMESPACE . '_optimize_all_chunk_size', $default_chunk_size, $item_type, $offset ); if ( $chunk_size < 1 ) { if ( (bool) Settings::get( 'enable_debug_support', false ) ) { Logger::write_log( 'warning', '[Parallax][OptimizeAll] Invalid chunk size filtered; falling back to default.', array( 'filtered_value' => $chunk_size, 'default_value' => $default_chunk_size, 'item_type' => $item_type, 'offset' => $offset, ) ); } $chunk_size = $default_chunk_size; } $provided_item_ids = array_filter( self::get_post_int_array( 'item_ids' ) ); $max_item_ids = (int) apply_filters( WCPAI_NAMESPACE . '_optimize_all_max_item_ids', $chunk_size * 50, $item_type ); if ( $max_item_ids > 0 && count( $provided_item_ids ) > $max_item_ids ) { $provided_item_ids = array_slice( $provided_item_ids, 0, $max_item_ids ); } $hide_optimized_raw = self::get_post_text( 'hide_optimized' ); $hide_optimized = '1' === $hide_optimized_raw; $excluded_optimized_count = 0; self::log_optimize_all_event( $request_id, 'Optimize All request received.', array( 'action' => $action, 'user_id' => get_current_user_id(), 'item_type' => $item_type, 'offset' => $offset, 'chunk_size' => $chunk_size, 'fields' => $meta_fields_to_optimize, 'provided_ids_count' => count( $provided_item_ids ), 'hide_optimized' => $hide_optimized, ), 'info' ); $query_start = microtime( true ); $skipped_drafts_total = 0; if ( ! empty( $provided_item_ids ) ) { $provided_item_ids = self::filter_disallowed_status_ids( $provided_item_ids, $item_type, $skipped_drafts_total ); if ( is_wp_error( $provided_item_ids ) ) { self::send_json_error_response( array( 'message' => Messages::get( 'ajax.filter_status_error' ) ), 500 ); } if ( empty( $provided_item_ids ) ) { $message = $skipped_drafts_total > 0 ? Messages::get( 'ajax.all_excluded_drafts' ) : Messages::get( 'ajax.all_excluded' ); self::send_json_error_response( array( 'message' => $message, 'skipped_drafts' => $skipped_drafts_total, ), 400 ); } $raw_item_ids = array_slice( $provided_item_ids, $offset, $chunk_size ); $item_ids = $raw_item_ids; $total_items = count( $provided_item_ids ); $total_optimizable = $total_items; $raw_items_in_chunk = count( $raw_item_ids ); $excluded_count = 0; } elseif ( 'product' === $item_type ) { $excluded_ids = array_map( 'absint', Settings::get_excluded_product_ids() ); $page = (int) floor( $offset / $chunk_size ) + 1; $query_args = array( 'limit' => $chunk_size, 'page' => $page, 'status' => Settings::get_product_statuses_for_optimize(), 'return' => 'ids', 'paginate' => true, ); $items = wc_get_products( $query_args ); if ( is_wp_error( $items ) ) { self::log_optimize_all_event( $request_id, 'Failed to query products for optimize-all.', array( 'error' => $items->get_error_message(), 'code' => $items->get_error_code(), ), 'error' ); self::send_optimize_all_error( $request_id, 'Failed to load products for optimization.', 'product_query_failed', 500, array( 'offset' => $offset ) ); } if ( is_object( $items ) && isset( $items->products ) ) { $raw_item_ids = array_map( 'intval', (array) $items->products ); $total_items = isset( $items->total ) ? (int) $items->total : 0; } else { $raw_item_ids = is_array( $items ) ? array_map( 'intval', $items ) : array(); $total_items = count( $raw_item_ids ); } $excluded_count = 0; if ( ! empty( $excluded_ids ) ) { $excluded_present = wc_get_products( array( 'include' => $excluded_ids, 'status' => Settings::get_product_statuses_for_optimize(), 'return' => 'ids', ) ); $excluded_count = is_array( $excluded_present ) ? count( $excluded_present ) : 0; } $item_ids = self::filter_excluded_item_ids( $raw_item_ids, $item_type ); $total_optimizable = max( 0, $total_items - $excluded_count ); $raw_items_in_chunk = count( $raw_item_ids ); } else { $excluded_ids = array_map( 'absint', Settings::get_excluded_category_ids() ); $items = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'fields' => 'ids', 'number' => $chunk_size, 'offset' => $offset, ) ); if ( is_wp_error( $items ) ) { self::log_optimize_all_event( $request_id, 'Failed to query categories for optimize-all.', array( 'error' => $items->get_error_message(), 'code' => $items->get_error_code(), ), 'error' ); self::send_optimize_all_error( $request_id, 'Failed to load categories for optimization.', 'category_query_failed', 500, array( 'offset' => $offset ) ); } $raw_item_ids = is_array( $items ) ? array_map( 'intval', $items ) : array(); $total_items = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'fields' => 'count', ) ); if ( is_wp_error( $total_items ) ) { self::log_optimize_all_event( $request_id, 'Failed to count categories for optimize-all.', array( 'error' => $total_items->get_error_message(), 'code' => $total_items->get_error_code(), ), 'error' ); self::send_optimize_all_error( $request_id, 'Failed to count categories for optimization.', 'category_count_failed', 500, array( 'offset' => $offset ) ); } $total_items = (int) $total_items; $excluded_count = 0; if ( ! empty( $excluded_ids ) ) { $excluded_present = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'fields' => 'ids', 'include' => $excluded_ids, ) ); $excluded_count = is_array( $excluded_present ) ? count( $excluded_present ) : 0; } $item_ids = self::filter_excluded_item_ids( $raw_item_ids, $item_type ); $total_optimizable = max( 0, $total_items - $excluded_count ); $raw_items_in_chunk = count( $raw_item_ids ); } if ( $hide_optimized ) { $item_ids = self::filter_hide_optimized_item_ids( $item_ids, $item_type, $excluded_optimized_count ); } if ( $total_items < 1 ) { self::send_optimize_all_error( $request_id, 'No items found to optimize.', 'no_items', 400, array( 'item_type' => $item_type ) ); } if ( empty( $item_ids ) ) { $done = $offset >= $total_items; self::send_optimize_all_success( $request_id, array( 'message' => $done ? 'Optimize all already completed.' : 'No items found for this chunk.', 'item_type' => $item_type, 'offset' => $offset, 'next_offset' => $offset, 'processed' => 0, 'processed_raw' => $raw_items_in_chunk ?? 0, 'total_items' => $total_items, 'total_items_optimizable' => $total_optimizable ?? 0, 'chunk_size' => $chunk_size, 'chunk_index' => (int) floor( $offset / $chunk_size ), 'chunk_count' => (int) ceil( $total_items / $chunk_size ), 'total_credits_required' => BuildRequest::calculate_total_credits( $meta_fields_to_optimize, (int) ( $total_optimizable ?? $total_items ) ), 'eligible_count' => (int) ( $total_optimizable ?? $total_items ), 'credits_required' => BuildRequest::calculate_total_credits( $meta_fields_to_optimize, (int) ( $total_optimizable ?? $total_items ) ), 'excluded_optimized_count' => $excluded_optimized_count, 'skipped_drafts' => $skipped_drafts_total, 'done' => $done, ) ); } $total_required_credits = BuildRequest::calculate_total_credits( $meta_fields_to_optimize, (int) ( $total_optimizable ?? $total_items ) ); // Optimize-all runs as sequential chunked AJAX calls. The user's available credit // balance drops as earlier chunks are queued, so gating on the full-run required // total against the current balance on every chunk would spuriously fail later // chunks once enough credits have been spent. Only validate on the first chunk // — the server still enforces per-chunk sufficiency via the eParallax API. if ( 0 === $offset ) { $client = Client::load(); if ( ! $client->is_user_authenticated() ) { self::log_optimize_all_event( $request_id, 'Optimize-all blocked: API client is not authenticated.', array( 'item_type' => $item_type, 'eligible_count' => (int) ( $total_optimizable ?? $total_items ), 'credits_required' => $total_required_credits, 'offset' => $offset, 'provided_ids' => count( $provided_item_ids ), ), 'warning' ); self::send_optimize_all_error( $request_id, Messages::get( 'ajax.authentication_required' ), 'authentication_required', 401 ); } $available_credits = (int) $client->get_credits(); if ( $total_required_credits > $available_credits ) { self::log_optimize_all_event( $request_id, 'Optimize-all blocked: insufficient credits.', array( 'item_type' => $item_type, 'eligible_count' => (int) ( $total_optimizable ?? $total_items ), 'credits_required' => $total_required_credits, 'credits_available' => $available_credits, 'offset' => $offset, 'provided_ids' => count( $provided_item_ids ), ), 'warning' ); self::send_optimize_all_error( $request_id, Messages::format( 'ajax.insufficient_credits', $total_required_credits, $available_credits, Endpoints::get_marketing_site_url() ), 'insufficient_credits', 402, array( 'credits_required' => $total_required_credits, 'credits_available' => $available_credits, ) ); } } $request_data = ( new BuildRequest() )->get_optimization_request_data( $meta_fields_to_optimize, $item_ids, $item_type ); $credits = (int) ( $request_data['total_credits_required'] ?? 0 ); $payload_json = wp_json_encode( $request_data ); $payload_bytes = is_string( $payload_json ) ? strlen( $payload_json ) : 0; self::log_optimize_all_event( $request_id, 'Submitting optimize-all chunk.', array( 'offset' => $offset, 'chunk_size' => $chunk_size, 'item_count' => count( $item_ids ), 'raw_item_count' => $raw_items_in_chunk ?? count( $item_ids ), 'total_items' => $total_items, 'total_items_optimizable' => $total_optimizable ?? $total_items, 'excluded_count' => $excluded_count ?? 0, 'credits' => $credits, 'payload_bytes' => $payload_bytes, 'query_time_ms' => (int) round( ( microtime( true ) - $query_start ) * 1000 ), 'elapsed_time_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ), ), 'info' ); $middleware_batch_request = MiddlewareRequest::submit_batch_request( $request_data, $item_type ); if ( empty( $middleware_batch_request['batch_id'] ) || ! empty( $middleware_batch_request['error'] ) ) { self::log_optimize_all_event( $request_id, 'Optimize-all batch submission failed.', array( 'offset' => $offset, 'error' => $middleware_batch_request['error'] ?? 'unknown', 'payload' => isset( $middleware_batch_request['body'] ) ? wp_strip_all_tags( (string) $middleware_batch_request['body'] ) : '', ), 'error' ); $error_details = array( 'offset' => $offset, 'item_count' => count( $item_ids ), ); if ( self::is_debug_enabled() ) { $error_details['api_error'] = $middleware_batch_request['error'] ?? ''; $error_details['api_body'] = isset( $middleware_batch_request['body'] ) ? substr( wp_strip_all_tags( (string) $middleware_batch_request['body'] ), 0, 1024 ) : ''; $error_details['endpoint'] = $middleware_batch_request['endpoint'] ?? ''; $error_details['status_code'] = $middleware_batch_request['status_code'] ?? ''; } self::send_optimize_all_error( $request_id, Messages::get( 'ajax.optimize_all_failed' ), 'middleware_submit_failed', 500, $error_details ); } $chunk_index = (int) floor( $offset / $chunk_size ); $chunk_count = (int) ceil( $total_items / $chunk_size ); $batch_request_args = array( 'batch_request_id' => $middleware_batch_request['batch_id'], 'type' => $item_type, 'status' => 'In Progress', 'total_expected' => count( $item_ids ), 'metadata' => array( 'user_id' => get_current_user_id(), 'total_credits_required' => $credits, 'item_ids' => $item_ids, 'chunk_index' => $chunk_index, 'chunk_count' => $chunk_count, 'chunk_size' => $chunk_size, ), ); $batch_request_response = BatchRequest::create( $batch_request_args ); if ( ! $batch_request_response ) { self::log_optimize_all_event( $request_id, 'Failed to create local batch request record for optimize-all.', array( 'offset' => $offset, 'chunk' => $chunk_index, 'batch_id' => $middleware_batch_request['batch_id'], ), 'error' ); self::send_optimize_all_error( $request_id, 'Failed to create local batch request record for optimize-all chunk.', 'local_batch_create_failed', 500, array( 'offset' => $offset, 'chunk_index' => $chunk_index, ) ); } $raw_items_in_chunk = isset( $raw_items_in_chunk ) ? (int) $raw_items_in_chunk : count( $item_ids ); $next_offset = $offset + $raw_items_in_chunk; $done = $next_offset >= $total_items; $message = sprintf( 'Queued optimize-all batch %1$d of %2$d (%3$d/%4$d items).', min( $chunk_index + 1, $chunk_count ), $chunk_count, min( $next_offset, $total_items ), $total_items ); self::send_optimize_all_success( $request_id, array( 'message' => $message, 'batch_request_id' => $batch_request_response, 'batch_id' => $middleware_batch_request['batch_id'], 'item_type' => $item_type, 'offset' => $offset, 'next_offset' => $next_offset, 'processed' => count( $item_ids ), 'processed_raw' => $raw_items_in_chunk, 'total_items' => $total_items, 'total_items_optimizable' => $total_optimizable ?? $total_items, 'chunk_size' => $chunk_size, 'chunk_index' => $chunk_index, 'chunk_count' => $chunk_count, 'chunk_credits_required' => $credits, 'total_credits_required' => BuildRequest::calculate_total_credits( $meta_fields_to_optimize, (int) ( $total_optimizable ?? $total_items ) ), 'eligible_count' => (int) ( $total_optimizable ?? $total_items ), 'credits_required' => BuildRequest::calculate_total_credits( $meta_fields_to_optimize, (int) ( $total_optimizable ?? $total_items ) ), 'excluded_optimized_count' => $excluded_optimized_count, 'skipped_drafts' => $skipped_drafts_total, 'done' => $done, ) ); } catch ( \Throwable $e ) { self::log_optimize_all_event( $request_id, 'Optimize-all handler threw an exception.', array( 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ), 'error' ); self::log_ajax_exception( $e, $action, array( 'request_id' => $request_id, 'item_type' => self::get_post_text( 'item_type' ) ?? '', 'offset' => self::get_post_int( 'offset' ) ?? 0, ) ); self::send_optimize_all_error( $request_id, 'Unexpected error while processing optimize-all request.', 'optimize_all_exception', 500, array( 'offset' => self::get_post_int( 'offset' ) ?? 0, ) ); } } /** * Send a structured Optimize All error response. * * @param string $request_id Request correlation ID. * @param string $message Human-readable message. * @param string $code Stable error code. * @param int $status HTTP status. * @param array $details Safe diagnostic details. */ private static function send_optimize_all_error( string $request_id, string $message, string $code, int $status, array $details = array() ): void { self::send_json_error_response( array( 'request_id' => $request_id, 'message' => $message, 'code' => $code, 'details' => $details, ), $status ); } /** * Send a structured Optimize All success response. * * @param string $request_id Request correlation ID. * @param array $payload Response payload. */ private static function send_optimize_all_success( string $request_id, array $payload ): void { $payload['request_id'] = $request_id; self::send_json_success_response( $payload ); } /** * Register a shutdown handler to log fatal errors for optimize-all requests. * * @param string $request_id Request correlation ID. */ private static function register_optimize_all_shutdown_handler( string $request_id ): void { register_shutdown_function( static function () use ( $request_id ): void { $error = error_get_last(); if ( ! $error || ! isset( $error['type'] ) ) { return; } $fatal_types = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_RECOVERABLE_ERROR ); if ( ! in_array( $error['type'], $fatal_types, true ) ) { return; } $message = sprintf( 'Fatal error during optimize-all: %1$s in %2$s on line %3$d', (string) ( $error['message'] ?? 'unknown' ), (string) ( $error['file'] ?? 'unknown' ), (int) ( $error['line'] ?? 0 ) ); $prefix = sprintf( '[Parallax][OptimizeAll][request_id=%s] ', $request_id ); Logger::write_log( 'error', $prefix . $message ); if ( (bool) Settings::get( 'enable_debug_support', false ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Required for logging. error_log( $prefix . $message ); } } ); } /** * Log Optimize All events to error_log and plugin logger. * * @param string $request_id Request correlation ID. * @param string $message Log message. * @param array $context Context details. * @param string $level Log level for plugin logger. */ private static function log_optimize_all_event( string $request_id, string $message, array $context = array(), string $level = 'info' ): void { $prefix = sprintf( '[Parallax][OptimizeAll][request_id=%s] ', $request_id ); $payload = $context ? wp_json_encode( $context ) : ''; $payload = is_string( $payload ) ? $payload : ''; $logline = trim( $prefix . $message . ( '' !== $payload ? ' ' . $payload : '' ) ); Logger::write_log( $level, $prefix . $message, $context ); if ( (bool) Settings::get( 'enable_debug_support', false ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Required for logging. error_log( $logline ); } } /** * Normalize metadata values to a safe array. * * @param mixed $metadata_raw Raw metadata value. * @return array */ private static function normalize_metadata( $metadata_raw ): array { if ( is_array( $metadata_raw ) ) { return $metadata_raw; } if ( is_object( $metadata_raw ) ) { $metadata_raw = get_object_vars( $metadata_raw ); if ( is_array( $metadata_raw ) ) { return $metadata_raw; } } if ( is_string( $metadata_raw ) ) { $decoded = json_decode( $metadata_raw, true ); if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) { return $decoded; } } return array(); } /** * Normalize batch request type to product/category. * * @param string $type_raw Raw type value. * @return string */ private static function normalize_batch_request_type( string $type_raw ): string { $type = strtolower( trim( $type_raw ) ); if ( in_array( $type, array( 'product', 'products' ), true ) ) { return 'product'; } if ( in_array( $type, array( 'category', 'categories', 'term', 'terms' ), true ) ) { return 'category'; } return ''; } /** * Extract item IDs from batch request metadata. * * @param array $meta Metadata array. * @param string $type Batch request type. * @return int[] */ private static function extract_item_ids_from_metadata( array $meta, string $type ): array { $try_keys = array( 'item_ids', 'items', 'ids', 'product_ids', 'products', 'term_ids', 'category_ids', 'categories', 'targets', ); $ids = array(); $collect_ids = static function ( $value ) use ( &$collect_ids ): array { $found = array(); if ( is_numeric( $value ) ) { return array( (int) $value ); } if ( is_string( $value ) ) { if ( preg_match_all( '/\d+/', $value, $matches ) ) { foreach ( $matches[0] as $num ) { $found[] = (int) $num; } } return $found; } if ( is_object( $value ) ) { return $collect_ids( get_object_vars( $value ) ); } if ( is_array( $value ) ) { foreach ( $value as $item ) { if ( is_numeric( $item ) ) { $found[] = (int) $item; continue; } if ( is_object( $item ) ) { $item = get_object_vars( $item ); } if ( is_array( $item ) ) { foreach ( array( 'id', 'product_id', 'term_id', 'category_id' ) as $key ) { if ( isset( $item[ $key ] ) && is_numeric( $item[ $key ] ) ) { $found[] = (int) $item[ $key ]; continue 2; } } } $found = array_merge( $found, $collect_ids( $item ) ); } } return $found; }; foreach ( $try_keys as $key ) { if ( ! array_key_exists( $key, $meta ) ) { continue; } $cand = $meta[ $key ]; if ( 'targets' === $key ) { if ( is_object( $cand ) ) { $cand = get_object_vars( $cand ); } if ( is_array( $cand ) ) { if ( 'product' === $type && isset( $cand['products'] ) ) { $cand = $cand['products']; } elseif ( 'category' === $type && isset( $cand['categories'] ) ) { $cand = $cand['categories']; } } } $ids = array_merge( $ids, $collect_ids( $cand ) ); } $ids = array_values( array_unique( array_filter( $ids, static function ( $id ): bool { return is_int( $id ) && $id > 0; } ) ) ); return $ids; } /** * Log import-all failures when debug support is enabled. * * @param string $message Log message. * @param array $context Context values (no sensitive payloads). */ private static function log_import_all_failure( string $message, array $context ): void { if ( ! self::is_debug_enabled() ) { return; } Logger::write_log( 'info', '[ImportAll] ' . $message, $context ); } /** * Handle exceptions for import-all with a JSON error response. * * @param \Throwable $e Exception thrown during processing. * @param string $action AJAX action name. * @param int $batch_request_id Batch request row id. * @param string $type Normalized batch request type. * @param array $meta Metadata array. * @param array $ids Parsed item ids. */ private static function handle_import_all_exception( \Throwable $e, string $action, int $batch_request_id, string $type, array $meta, array $ids ): void { $is_debug = self::is_debug_enabled(); $context = array( 'action' => $action, 'user_id' => get_current_user_id(), 'batch_request_id' => $batch_request_id, 'offset' => self::get_post_int( 'offset' ) ?? 0, 'type' => $type, 'meta_keys' => array_keys( $meta ), 'parsed_ids_count' => count( $ids ), ); if ( $is_debug ) { $context['error'] = $e->getMessage(); $context['file'] = $e->getFile(); $context['line'] = $e->getLine(); $context['trace'] = $e->getTraceAsString(); } self::log_import_all_failure( 'Import failed.', $context ); $response = array( 'message' => 'Import failed.', ); if ( $is_debug ) { $response['error'] = $e->getMessage() . ' (' . basename( $e->getFile() ) . ':' . $e->getLine() . ')'; } self::send_json_error_response( $response, 500 ); } /** * Check if debug logging is enabled. * * @return bool */ private static function is_debug_enabled(): bool { return (bool) Settings::get( 'enable_debug_support', false ); } /** * Sanitize and validate batch request payload from POST (optional payload JSON). * * @return array|\WP_Error */ private static function get_sanitized_batch_request_from_post() { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'insufficient_permissions', Messages::get( 'ajax.insufficient_perms' ), array( 'status' => 403 ) ); } $payload = null; $raw = self::get_post_raw( 'payload' ); if ( null !== $raw ) { if ( strlen( $raw ) > self::MAX_BATCH_PAYLOAD_BYTES ) { return new \WP_Error( 'payload_too_large', Messages::get( 'ajax.payload_too_large' ), array( 'status' => 413 ) ); } $decoded = json_decode( (string) $raw, true ); // Check for JSON decode errors when raw payload is non-empty. if ( null === $decoded && strlen( $raw ) > 0 && JSON_ERROR_NONE !== json_last_error() ) { return new \WP_Error( 'invalid_payload', Messages::get( 'ajax.malformed_json' ), array( 'status' => 400 ) ); } if ( null !== $decoded && ! is_array( $decoded ) ) { return new \WP_Error( 'invalid_payload', Messages::get( 'ajax.invalid_payload' ), array( 'status' => 400 ) ); } $payload = is_array( $decoded ) ? $decoded : null; } $optimize_type = self::sanitize_optimize_type( self::get_post_text( 'optimize_type' ) ); if ( null === $optimize_type && is_array( $payload ) && isset( $payload['optimize_type'] ) && is_string( $payload['optimize_type'] ) ) { $optimize_type = self::sanitize_optimize_type( $payload['optimize_type'] ); } if ( null === $optimize_type ) { return new \WP_Error( 'invalid_optimize_type', Messages::get( 'ajax.invalid_type' ), array( 'status' => 400 ) ); } $item_ids = self::get_post_int_array( 'item_ids' ); if ( empty( $item_ids ) && is_array( $payload ) ) { $payload_ids = null; if ( isset( $payload['object_ids'] ) ) { $payload_ids = $payload['object_ids']; } elseif ( isset( $payload['item_ids'] ) ) { $payload_ids = $payload['item_ids']; } if ( null !== $payload_ids ) { $item_ids = self::sanitize_item_ids_list( $payload_ids ); } } $item_ids = array_values( array_unique( array_filter( array_map( 'absint', $item_ids ) ) ) ); if ( empty( $item_ids ) ) { return new \WP_Error( 'missing_item_ids', Messages::get( 'ajax.missing_item_ids' ), array( 'status' => 400 ) ); } $max_ids = Settings::get_max_batch_item_ids(); if ( count( $item_ids ) > $max_ids ) { return new \WP_Error( 'too_many_items', Messages::get( 'ajax.too_many_items' ), array( 'status' => 400 ) ); } $meta_fields = self::get_post_text_array( 'meta_fields' ); if ( empty( $meta_fields ) && is_array( $payload ) && isset( $payload['fieldsToOptimize'] ) ) { $meta_fields = self::sanitize_fields_to_optimize( $payload['fieldsToOptimize'] ); } else { $meta_fields = self::sanitize_fields_to_optimize( $meta_fields ); } if ( empty( $meta_fields ) ) { return new \WP_Error( 'missing_fields', Messages::get( 'ajax.select_field' ), array( 'status' => 400 ) ); } if ( count( $meta_fields ) > self::MAX_BATCH_FIELD_COUNT ) { return new \WP_Error( 'too_many_fields', Messages::get( 'ajax.too_many_fields' ), array( 'status' => 400 ) ); } return array( 'optimize_type' => $optimize_type, 'item_ids' => $item_ids, 'meta_fields' => $meta_fields, ); } /** * Validate optimize type from input. * * @param string|null $value Raw optimize type. * @return string|null */ private static function sanitize_optimize_type( ?string $value ): ?string { if ( null === $value || '' === $value ) { return null; } $value = sanitize_key( $value ); return in_array( $value, self::ALLOWED_OPTIMIZE_TYPES, true ) ? $value : null; } /** * Sanitize an array of item IDs from arbitrary input. * * @param mixed $value Incoming IDs list. * @return int[] */ private static function sanitize_item_ids_list( $value ): array { if ( ! is_array( $value ) ) { return array(); } $value = array_filter( $value, static fn( $item ) => is_scalar( $item ) ); return array_map( 'absint', $value ); } /** * Sanitize and allowlist optimization fields. * * @param mixed $value Incoming fields list. * @return string[] */ private static function sanitize_fields_to_optimize( $value ): array { if ( ! is_array( $value ) ) { return array(); } $allowed = array_fill_keys( self::ALLOWED_OPTIMIZE_FIELDS, true ); $fields = array(); foreach ( $value as $field ) { if ( ! is_scalar( $field ) ) { continue; } $key = sanitize_key( (string) $field ); if ( '' === $key || ! isset( $allowed[ $key ] ) ) { continue; } $fields[] = $key; } return array_values( array_unique( $fields ) ); } /** * Get a sanitized string from POST. * * @param string $key POST key. * @return string|null */ private static function get_post_text( string $key ): ?string { $allowed = array( 'table_key', 'optimize_type', 'item_type', 'action_type', 'table_type', 'hide_optimized', 'debug_middleware_dump', 'rejection_comment', 's', 'product_cat', 'orderby', 'order', 'page', 'tab', 'post_type', 'section', ); if ( ! in_array( $key, $allowed, true ) ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by caller. if ( ! isset( $_POST[ $key ] ) || ! is_string( $_POST[ $key ] ) ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by caller. return sanitize_text_field( wp_unslash( $_POST[ $key ] ) ); } /** * Build a full middleware batch-request payload by fetching all paged responses. * * @param int $batch_request_row_id Local batch request row id. * @return array */ private static function get_full_middleware_batch_request_json( int $batch_request_row_id ): array { $row_result = BatchRequest::get( $batch_request_row_id ); $row = is_array( $row_result ) ? $row_result : array(); if ( array() === $row ) { return array( 'error' => 'Local batch request row not found.', 'batch_request_row_id' => $batch_request_row_id, ); } $remote_batch_request_id = isset( $row['batch_request_id'] ) ? sanitize_text_field( (string) $row['batch_request_id'] ) : ''; $type = self::normalize_batch_request_type( (string) ( $row['type'] ?? '' ) ); if ( '' === $remote_batch_request_id || '' === $type ) { return array( 'error' => 'Missing remote batch request id or type.', 'batch_request_row_id' => $batch_request_row_id, 'remote_batch_request_id' => $remote_batch_request_id, 'type' => $type, ); } $pages = array(); $total = 0; $page_size = 0; // Cap at 10 pages to avoid excessive API calls in a single AJAX request. // Each iteration is a synchronous wp_remote_get; 10 × 30s timeout = 5 min worst case. for ( $page = 1; $page <= 10; $page++ ) { $raw = MiddlewareRequest::get_batch_request_paged( $remote_batch_request_id, $type, $page ); if ( ! is_string( $raw ) || '' === $raw ) { break; } $decoded = json_decode( $raw, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { return array( 'error' => 'Failed to decode middleware response JSON.', 'batch_request_row_id' => $batch_request_row_id, 'remote_batch_request_id' => $remote_batch_request_id, 'type' => $type, 'page' => $page, 'json_error' => json_last_error_msg(), 'raw' => substr( (string) $raw, 0, 1000 ), ); } $pages[] = $decoded; if ( 0 === $page_size ) { $page_size = (int) ( $decoded['pageSize'] ?? 0 ); } if ( 0 === $total ) { $total = (int) ( $decoded['total'] ?? 0 ); } if ( $page_size > 0 && $page * $page_size >= $total ) { break; } } return array( 'batch_request_row_id' => $batch_request_row_id, 'remote_batch_request_id' => $remote_batch_request_id, 'type' => $type, 'total' => $total, 'page_size' => $page_size, 'pages_fetched' => count( $pages ), 'pages' => $pages, ); } /** * Get an integer from POST. * * @param string $key POST key. * @return int|null */ private static function get_post_int( string $key ): ?int { $allowed = array( 'batch_request_id', 'paged', 'amount', 'total_credits_required', 'offset', 'item_id', ); if ( ! in_array( $key, $allowed, true ) ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by caller. if ( ! isset( $_POST[ $key ] ) || is_array( $_POST[ $key ] ) ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by caller. $value = preg_replace( '/[^0-9+-]/', '', (string) wp_unslash( $_POST[ $key ] ) ); if ( '' === $value ) { return null; } return max( 0, (int) $value ); } /** * Get an array of integers from POST. * * @param string $key POST key. * @return int[] */ private static function get_post_int_array( string $key ): array { if ( 'item_ids' !== $key ) { return array(); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by caller. if ( ! isset( $_POST[ $key ] ) ) { return array(); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by caller. $value = wp_unslash( $_POST[ $key ] ); $value = is_array( $value ) ? $value : array(); $value = array_filter( $value, static fn( $item ) => is_scalar( $item ) ); return array_map( 'absint', $value ); } /** * Get an array of strings from POST. * * @param string $key POST key. * @return string[] */ private static function get_post_text_array( string $key ): array { if ( 'meta_fields' !== $key ) { return array(); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by caller. if ( ! isset( $_POST[ $key ] ) ) { return array(); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by caller. $value = wp_unslash( $_POST[ $key ] ); $value = is_array( $value ) ? $value : array(); $value = array_filter( $value, static fn( $item ) => is_scalar( $item ) ); return array_map( static fn( $item ) => sanitize_text_field( (string) $item ), $value ); } /** * Get raw POST value without sanitization. * * @param string $key POST key. * @return string|null */ private static function get_post_raw( string $key ): ?string { if ( 'payload' !== $key ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by caller. if ( ! isset( $_POST[ $key ] ) || ! is_string( $_POST[ $key ] ) ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by caller. return wp_unslash( $_POST[ $key ] ); } /** * Set a value on superglobals for downstream table code. * * @param string $key Request key. * @param string $value Value to set. * @param bool $include_get Whether to also mirror into $_GET. */ private static function set_request_value( string $key, string $value, bool $include_get = false ): void { $_REQUEST[ $key ] = $value; if ( $include_get ) { $_GET[ $key ] = $value; } self::$request_context[ $key ] = $value; Base_Table::set_request_override( $key, $value ); } /** * Set an array value on superglobals for downstream table code. * * @param string $key Request key. * @param array $value Array value. */ private static function set_request_array( string $key, array $value ): void { $_REQUEST[ $key ] = $value; $sanitized = array(); foreach ( $value as $item ) { if ( is_scalar( $item ) ) { $sanitized[] = sanitize_text_field( (string) $item ); } } self::$request_context[ $key ] = $sanitized; Base_Table::set_request_override_array( $key, $sanitized ); } /** * Copy POST params into request globals for list tables. * * @param array $string_keys String keys to copy. * @param array $int_keys Integer keys to copy. */ private static function copy_post_params_to_request( array $string_keys, array $int_keys = array() ): void { foreach ( $string_keys as $key ) { $value = self::get_post_text( $key ); if ( null !== $value ) { self::set_request_value( $key, $value, true ); } } foreach ( $int_keys as $key ) { $value = self::get_post_int( $key ); if ( null !== $value ) { self::set_request_value( $key, (string) $value, true ); } } } /** * Retrieve a request value from POST/GET or override map. * * @param string $key Request key. * @return string|null */ private static function get_request_value( string $key ): ?string { $allowed = array( '_ajax_nonce' => 'nonce', 'parallax_submit_batch_request_nonce' => 'nonce', 'parallax_get_products_nonce' => 'nonce', '_wpnonce' => 'nonce', 'page' => 'key', 'tab' => 'key', 'post_type' => 'key', 'section' => 'key', 's' => 'text', 'product_cat' => 'key', 'orderby' => 'key', 'order' => 'key', 'hide_optimized' => 'bool', 'product_batch_request_row_id' => 'int', 'category_batch_request_row_id' => 'int', 'batch_request_row_id' => 'int', 'batch_request_id' => 'int', 'paged' => 'int', ); if ( ! isset( $allowed[ $key ] ) ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by caller. if ( isset( $_POST[ $key ] ) && is_string( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by caller. $value = wp_unslash( $_POST[ $key ] ); return self::sanitize_request_value( $allowed[ $key ], $value ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter parameters do not modify data. if ( isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Filter parameters sanitized below. $value = wp_unslash( $_GET[ $key ] ); return self::sanitize_request_value( $allowed[ $key ], $value ); } $context_value = self::$request_context[ $key ] ?? null; return is_string( $context_value ) ? $context_value : null; } /** * Sanitize a request value based on expected type. * * @param string $type Expected type (text|key|int|bool|nonce). * @param string $value Raw value. * @return string|null */ private static function sanitize_request_value( string $type, string $value ): ?string { if ( '' === $value ) { return null; } switch ( $type ) { case 'int': return (string) absint( $value ); case 'bool': return ( '1' === $value || '0' === $value ) ? $value : null; case 'key': return sanitize_key( $value ); case 'nonce': case 'text': default: return sanitize_text_field( $value ); } } /** * Retrieve an integer request value from POST/GET or overrides. * * @param string $key Request key. * @return int|null */ private static function get_request_int( string $key ): ?int { $value = self::get_request_value( $key ); if ( null === $value || '' === $value ) { return null; } return max( 0, (int) $value ); } /** * DRY renderer for WP_List_Table HTML + labels. * * @param Base_Table $table * @return array{rows:string,pagination_top:string,pagination_bottom:string,displaying:string,current_page:int} */ /** * Render a WP_List_Table to HTML and return with pagination data. * * @param \WP_List_Table $table Table instance. * @return array */ protected static function render_list_table_payload( $table ): array { $original_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : null; $override_uri = self::build_admin_request_uri_from_request(); $rows_html = ''; $pagination_top = ''; $pagination_bottom = ''; $displaying = ''; if ( null !== $override_uri ) { $_SERVER['REQUEST_URI'] = $override_uri; } try { $table->prepare_items(); $buffer_level = ob_get_level(); ob_start(); try { $table->display_rows_or_placeholder(); $rows_html = ob_get_clean(); } finally { while ( ob_get_level() > $buffer_level ) { ob_end_clean(); } } // Capture pagination using the public wrapper method. $buffer_level = ob_get_level(); ob_start(); try { $table->render_pagination( 'top' ); $pagination_top = self::add_pagination_data_attributes( ob_get_clean() ); } finally { while ( ob_get_level() > $buffer_level ) { ob_end_clean(); } } $buffer_level = ob_get_level(); ob_start(); try { $table->render_pagination( 'bottom' ); $pagination_bottom = self::add_pagination_data_attributes( ob_get_clean() ); } finally { while ( ob_get_level() > $buffer_level ) { ob_end_clean(); } } $displaying = sprintf( /* translators: 1: number of items, 2: total items. */ _n( '%s item', '%s items', $table->get_pagination_arg( 'total_items' ), 'ai-content-by-parallax' ), number_format_i18n( $table->get_pagination_arg( 'total_items' ) ) ); } finally { if ( null !== $override_uri ) { if ( null !== $original_request_uri ) { $_SERVER['REQUEST_URI'] = $original_request_uri; } else { unset( $_SERVER['REQUEST_URI'] ); } } } return array( 'rows' => $rows_html, 'pagination_top' => $pagination_top, 'pagination_bottom' => $pagination_bottom, 'displaying' => $displaying, 'current_page' => max( 1, (int) $table->get_pagenum() ), ); } /** * Build a normalized admin.php request URI from the current request context. * * Ensures pagination links generated inside admin-ajax callbacks retain the correct * base URL (admin.php?page=...) instead of pointing back to admin-ajax.php. * * @return string|null Relative request URI (e.g. /wp-admin/admin.php?foo=bar) or null if insufficient context. */ /** * Reconstruct an admin request URI from current request context. * * @return string|null */ private static function build_admin_request_uri_from_request(): ?string { $base_keys = array( 'page', 'tab', 'post_type', 'section' ); $args = array(); foreach ( $base_keys as $key ) { $value = self::get_request_value( $key ); if ( null === $value || '' === $value ) { continue; } $args[ $key ] = $value; } if ( array() === $args ) { $referer = wp_get_referer(); if ( ! $referer ) { if ( isset( $_SERVER['HTTP_REFERER'] ) && is_string( $_SERVER['HTTP_REFERER'] ) ) { $referer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ); } } if ( $referer ) { $referer_args = self::extract_base_args_from_url( $referer, $base_keys ); if ( ! empty( $referer_args ) ) { $args = $referer_args; } } } if ( array() === $args ) { return null; } $extra_keys = array( 's', 'product_cat', 'orderby', 'order', 'hide_optimized', 'product_batch_request_row_id', 'category_batch_request_row_id', 'batch_request_row_id', 'batch_request_id', ); foreach ( $extra_keys as $key ) { $value = self::get_request_value( $key ); if ( null === $value || '' === $value ) { continue; } if ( 'hide_optimized' === $key ) { $args[ $key ] = ( '1' === $value ) ? '1' : '0'; continue; } if ( 'order' === $key ) { $upper = strtoupper( $value ); $args[ $key ] = in_array( $upper, array( 'ASC', 'DESC' ), true ) ? $upper : 'ASC'; continue; } $args[ $key ] = $value; } $paged = self::get_request_int( 'paged' ); if ( null !== $paged && 1 < $paged ) { $args['paged'] = (string) $paged; } $path = wp_parse_url( admin_url( 'admin.php' ), PHP_URL_PATH ); if ( ! is_string( $path ) || '' === $path ) { $path = '/wp-admin/admin.php'; } $query = http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ); return '' === $query ? $path : $path . '?' . $query; } /** * Extract whitelisted query args from a URL. * * @param string $url URL to parse. * @param string[] $allowed_keys Keys to extract. * @return array */ private static function extract_base_args_from_url( string $url, array $allowed_keys ): array { $parsed = wp_parse_url( $url ); if ( ! is_array( $parsed ) || empty( $parsed['query'] ) ) { return array(); } $query_args = array(); parse_str( (string) $parsed['query'], $query_args ); if ( ! is_array( $query_args ) || array() === $query_args ) { return array(); } $allowed = array_fill_keys( $allowed_keys, true ); $result = array(); foreach ( $query_args as $key => $value ) { if ( ! isset( $allowed[ $key ] ) ) { continue; } if ( is_array( $value ) ) { $value = reset( $value ); } if ( ! is_scalar( $value ) ) { continue; } $sanitized = sanitize_text_field( (string) $value ); if ( '' === $sanitized ) { continue; } $result[ $key ] = $sanitized; } return $result; } /** * Add data-paged attributes to pagination links for easier JS handling. * * @param string $html Pagination HTML fragment. * @return string */ private static function add_pagination_data_attributes( string $html ): string { if ( '' === trim( $html ) ) { return $html; } $internal_errors = libxml_use_internal_errors( true ); $dom = new \DOMDocument( '1.0', 'UTF-8' ); $options = 0; if ( defined( 'LIBXML_HTML_NOIMPLIED' ) ) { $options |= LIBXML_HTML_NOIMPLIED; } if ( defined( 'LIBXML_HTML_NODEFDTD' ) ) { $options |= LIBXML_HTML_NODEFDTD; } $loaded = $dom->loadHTML( '' . $html, $options ); if ( false === $loaded ) { libxml_clear_errors(); libxml_use_internal_errors( $internal_errors ); return $html; } foreach ( $dom->getElementsByTagName( 'a' ) as $link ) { $href = $link->getAttribute( 'href' ); if ( '' === $href ) { continue; } $parsed = wp_parse_url( $href ); if ( ! is_array( $parsed ) || empty( $parsed['query'] ) ) { continue; } $query_args = array(); parse_str( (string) $parsed['query'], $query_args ); if ( empty( $query_args['paged'] ) ) { continue; } $paged = (int) $query_args['paged']; if ( $paged < 1 ) { continue; } if ( ! $link->hasAttribute( 'data-paged' ) ) { $link->setAttribute( 'data-paged', (string) $paged ); } } $output = $dom->saveHTML(); libxml_use_internal_errors( $internal_errors ); return is_string( $output ) ? $output : $html; } /** * Notify the Parallax API of a batch item status change. * * Resolves the remote batch identifier from the local DB row ID, maps the * internal action name to the API status string, resolves WooCommerce item * IDs to Parallax UUIDs stored at hydration time, and fires the request. * Failures are logged but do not interrupt the user-facing flow. * * @param int $local_batch_id Local batch request row ID. * @param string $item_type Item type: 'product' or 'category'. * @param string $action Internal action: 'approve', 'reject', or 'revert'. * @param array $item_ids WooCommerce item IDs affected; resolved to Parallax UUIDs internally. * @param string $comment Optional rejection/revert comment. */ private static function notify_item_status( int $local_batch_id, string $item_type, string $action, array $item_ids, string $comment = '' ): void { $batch_request = BatchRequest::get( $local_batch_id ); if ( ! is_array( $batch_request ) ) { Logger::write_log( 'warning', 'notify_item_status: batch request not found for local ID ' . $local_batch_id ); return; } $remote_id = (string) ( $batch_request['batch_request_id'] ?? '' ); if ( '' === $remote_id ) { Logger::write_log( 'warning', 'notify_item_status: could not resolve remote batch ID for local ID ' . $local_batch_id ); return; } $status_map = array( 'approve' => 'approved', 'reject' => 'rejected', 'revert' => 'reverted', ); if ( ! isset( $status_map[ $action ] ) ) { Logger::write_log( 'warning', 'notify_item_status: unknown action "' . $action . '"', array( 'batch_id' => $remote_id, 'item_type' => $item_type, 'item_ids' => $item_ids, ) ); return; } $api_status = $status_map[ $action ]; // Resolve WooCommerce item IDs to Parallax UUIDs stored at hydration time. $uuid_ids = array(); foreach ( $item_ids as $wc_id ) { if ( 'category' === $item_type ) { $uuid = (string) get_term_meta( (int) $wc_id, '_parallax_id', true ); } else { $uuid = (string) get_post_meta( (int) $wc_id, '_parallax_id', true ); } if ( '' !== $uuid ) { $uuid_ids[] = $uuid; } else { Logger::write_log( 'warning', 'notify_item_status: no parallax_id stored for ' . $item_type . ' ' . $wc_id . ' — skipping item', array( 'batch_id' => $remote_id, 'item_type' => $item_type, 'wc_id' => $wc_id, ) ); } } if ( empty( $uuid_ids ) ) { Logger::write_log( 'warning', 'notify_item_status: no UUID-mapped items for batch ' . $remote_id . ' — API call skipped' ); return; } $result = MiddlewareRequest::send_item_status( $remote_id, $item_type, $api_status, $uuid_ids, $comment ); if ( isset( $result['error'] ) ) { Logger::write_log( 'warning', 'notify_item_status: API call failed — ' . $result['error'], array( 'batch_id' => $remote_id, 'item_type' => $item_type, 'status' => $api_status, 'item_ids' => $uuid_ids, ) ); } } /** * Delete the reset notice option when the user dismisses the notice. * * Called via AJAX (action: ai_content_by_parallax_ajax_dismiss_reset_notice). * Fires and forgets — no meaningful response body needed. * * @return void */ public static function dismiss_reset_notice(): void { self::guard( 'dismiss_reset_notice' ); delete_option( 'ai_content_by_parallax_show_reset_notice' ); self::send_json_success_response( null ); } }