dependencies = $dependencies; $this->product_creator = $product_creator; $this->product_ai_generator = $product_ai_generator; } /** * Initialize action hooks. * * @return void */ public function init() { add_action( 'admin_post_apsw_process_batch_create', array( $this, 'handle_process_batch_create' ) ); } /** * Return the local batch limit. * * @return int */ public function get_batch_limit() { return self::BATCH_LIMIT; } /** * Handle Batch Create submission. * * @return void */ public function handle_process_batch_create() { if ( ! $this->current_user_can_submit() ) { wp_die( esc_html__( 'You are not allowed to perform this action.', 'ai-product-studio-for-woocommerce' ) ); } check_admin_referer( 'apsw_process_batch_create' ); if ( ! $this->dependencies->is_woocommerce_active() ) { $this->set_notice( array( 'status' => 'error', 'message' => __( 'WooCommerce must be active before creating products.', 'ai-product-studio-for-woocommerce' ), 'warnings' => array(), ) ); $this->redirect_to_batch_page(); } $input = $this->get_posted_batch_input(); $requested_count = $this->get_requested_product_count( $input ); if ( $requested_count < 1 ) { $this->set_notice( array( 'status' => 'error', 'message' => __( 'Choose how many products you want to create before running Batch Create.', 'ai-product-studio-for-woocommerce' ), 'warnings' => array(), ) ); $this->redirect_to_batch_page(); } if ( $requested_count > self::BATCH_LIMIT ) { $this->set_notice( array( 'status' => 'error', 'message' => sprintf( /* translators: %d: maximum number of batch items */ __( 'Batch Create supports up to %d products at a time.', 'ai-product-studio-for-woocommerce' ), self::BATCH_LIMIT ), 'warnings' => array(), ) ); $this->redirect_to_batch_page(); } $items = $this->parse_batch_items( isset( $input['items'] ) ? $input['items'] : array(), $requested_count ); $created = array(); $failures = array(); $warnings = array(); $processed = 0; foreach ( $items as $index => $item ) { $item_label = sprintf( /* translators: %d: batch item number */ __( 'Item %d', 'ai-product-studio-for-woocommerce' ), absint( $index ) + 1 ); $item_validation = $this->validate_batch_item( $item ); if ( is_wp_error( $item_validation ) ) { $failures[] = sprintf( /* translators: 1: batch item label, 2: error message */ __( '%1$s: %2$s', 'ai-product-studio-for-woocommerce' ), $item_label, $item_validation->get_error_message() ); continue; } $prepared = $this->build_prepared_item( $item, $index ); $processed++; $ai_result = $this->product_ai_generator->generate_from_prepared_input( $prepared, array() ); if ( empty( $ai_result['success'] ) || empty( $ai_result['data'] ) || ! is_array( $ai_result['data'] ) ) { $failures[] = sprintf( /* translators: 1: batch item label, 2: error message */ __( '%1$s: %2$s', 'ai-product-studio-for-woocommerce' ), $item_label, $this->get_ai_generation_failure_message( $ai_result ) ); continue; } $prepared['ai_generated_product_data'] = $ai_result['data']; $prepared['ai_generated_at'] = time(); $prepared['ai_generation_provider'] = sanitize_key( isset( $ai_result['provider'] ) ? $ai_result['provider'] : '' ); $prepared['ai_generation_model'] = sanitize_text_field( isset( $ai_result['model'] ) ? $ai_result['model'] : '' ); $local_image_id = isset( $item['image_id'] ) ? absint( $item['image_id'] ) : 0; $creation_result = $this->product_creator->create_ai_draft_from_prepared_input( $prepared, array() ); if ( empty( $creation_result['success'] ) || empty( $creation_result['product_id'] ) ) { $failures[] = sprintf( /* translators: 1: batch item label, 2: error message */ __( '%1$s: %2$s', 'ai-product-studio-for-woocommerce' ), $item_label, __( 'AI data was generated, but the WooCommerce product could not be created.', 'ai-product-studio-for-woocommerce' ) ); continue; } $product_id = absint( $creation_result['product_id'] ); $post = get_post( $product_id ); if ( ! $post || 'product' !== $post->post_type ) { $failures[] = sprintf( /* translators: 1: batch item label, 2: error message */ __( '%1$s: %2$s', 'ai-product-studio-for-woocommerce' ), $item_label, __( 'The product was saved, but it could not be verified in WooCommerce products.', 'ai-product-studio-for-woocommerce' ) ); continue; } if ( $local_image_id > 0 && ! $this->attach_local_image_to_product( $product_id, $local_image_id ) ) { $warnings[] = sprintf( /* translators: 1: batch item label */ __( '%1$s: Selected local image could not be attached to the created product.', 'ai-product-studio-for-woocommerce' ), $item_label ); } update_post_meta( $product_id, '_apsw_created_phase', 'ai_draft_phase_5_batch_create' ); update_post_meta( $product_id, '_apsw_batch_create_workflow', 'yes' ); update_post_meta( $product_id, '_apsw_batch_item_index', absint( $index ) + 1 ); update_post_meta( $product_id, '_apsw_batch_item_operation', $prepared['generation_mode'] ); if ( $local_image_id > 0 ) { update_post_meta( $product_id, '_apsw_batch_source_image_id', $local_image_id ); } $created[] = array( 'product_id' => $product_id, 'product_title' => $this->sanitize_product_title_for_display( get_the_title( $product_id ) ), 'product_status' => get_post_status( $product_id ), 'edit_url' => admin_url( 'post.php?post=' . $product_id . '&action=edit' ), 'operation_type' => $prepared['generation_mode'], ); if ( isset( $ai_result['warnings'] ) && is_array( $ai_result['warnings'] ) ) { $warnings = array_merge( $warnings, array_map( 'sanitize_text_field', $ai_result['warnings'] ) ); } if ( isset( $creation_result['warnings'] ) && is_array( $creation_result['warnings'] ) ) { $warnings = array_merge( $warnings, array_map( 'sanitize_text_field', $creation_result['warnings'] ) ); } } $created_count = count( $created ); $failed_count = count( $failures ); $status = $created_count > 0 && 0 === $failed_count ? 'success' : ( $created_count > 0 ? 'warning' : 'error' ); $message = sprintf( /* translators: 1: submitted items, 2: processed items, 3: created products, 4: failed items */ __( 'Batch Create finished: %1$d submitted, %2$d processed, %3$d products created, %4$d failed.', 'ai-product-studio-for-woocommerce' ), $requested_count, $processed, $created_count, $failed_count ); $this->set_notice( array( 'status' => $status, 'message' => $message, 'products_list_url' => admin_url( 'edit.php?post_type=product' ), 'submitted' => $requested_count, 'processed' => $processed, 'created' => $created_count, 'failed' => $failed_count, 'warnings' => array_values( array_unique( array_filter( array_merge( $warnings, $failures ) ) ) ), 'created_products' => $created, ) ); $this->redirect_to_batch_page(); } /** * Consume the latest notice for the user. * * @param int $user_id User ID. * @return array */ public function consume_notice( $user_id = 0 ) { $user_id = $user_id ? absint( $user_id ) : get_current_user_id(); $key = $this->get_notice_key( $user_id ); $notice = get_transient( $key ); if ( false !== $notice ) { delete_transient( $key ); } return is_array( $notice ) ? $notice : array(); } /** * Get raw posted batch input. * * @return array */ private function get_posted_batch_input() { // Nonce verification is performed in handle_process_batch_create() before this helper reads the submitted batch payload. // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( empty( $_POST['apsw_batch'] ) ) { return array(); } // Nonce verification is performed in handle_process_batch_create(); sanitized field-by-field below. // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $input = wp_unslash( $_POST['apsw_batch'] ); return is_array( $input ) ? $input : array(); } /** * Get requested product count. * * @param array $input Raw input. * @return int */ private function get_requested_product_count( array $input ) { return isset( $input['product_count'] ) ? absint( $input['product_count'] ) : 0; } /** * Parse wizard item panels. * * @param array $raw_items Raw item values. * @param int $requested_count Product count requested by the admin. * @return array */ private function parse_batch_items( $raw_items, $requested_count ) { $items = array(); $raw_items = is_array( $raw_items ) ? $raw_items : array(); for ( $index = 0; $index < absint( $requested_count ); $index++ ) { $item = isset( $raw_items[ $index ] ) && is_array( $raw_items[ $index ] ) ? $raw_items[ $index ] : array(); $items[] = array( 'rough_notes' => isset( $item['rough_notes'] ) ? $this->sanitize_item_notes( $item['rough_notes'] ) : '', 'image_id' => isset( $item['image_id'] ) ? absint( $item['image_id'] ) : 0, 'product_category_id' => isset( $item['product_category_id'] ) ? absint( $item['product_category_id'] ) : 0, 'product_category_label' => $this->get_category_label( isset( $item['product_category_id'] ) ? absint( $item['product_category_id'] ) : 0 ), 'product_kind' => $this->sanitize_product_kind( isset( $item['product_kind'] ) ? $item['product_kind'] : '' ), 'price' => $this->sanitize_decimal_value( isset( $item['price'] ) ? $item['price'] : '' ), 'sale_price' => $this->sanitize_decimal_value( isset( $item['sale_price'] ) ? $item['sale_price'] : '' ), 'stock_quantity' => isset( $item['stock_quantity'] ) && '' !== trim( (string) $item['stock_quantity'] ) ? absint( $item['stock_quantity'] ) : '', ); } return $items; } /** * Sanitize one item notes field. * * @param string $notes Raw notes. * @return string */ private function sanitize_item_notes( $notes ) { $notes = sanitize_textarea_field( (string) $notes ); $notes = trim( preg_replace( '/[ \t]+/', ' ', wp_strip_all_tags( $notes ) ) ); return sanitize_textarea_field( $notes ); } /** * Get a product category label. * * @param int $product_category_id Product category term ID. * @return string */ private function get_category_label( $product_category_id ) { $product_category_id = absint( $product_category_id ); if ( $product_category_id <= 0 ) { return ''; } $term = get_term( $product_category_id, 'product_cat' ); return $term && ! is_wp_error( $term ) ? sanitize_text_field( $term->name ) : ''; } /** * Sanitize one product kind value. * * @param string $product_kind Raw product kind. * @return string */ private function sanitize_product_kind( $product_kind ) { $product_kind = sanitize_key( $product_kind ); return in_array( $product_kind, array( 'physical', 'virtual' ), true ) ? $product_kind : 'physical'; } /** * Validate one batch item. * * @param array $item Batch item. * @return true|WP_Error */ private function validate_batch_item( array $item ) { $rough_notes = isset( $item['rough_notes'] ) ? trim( (string) $item['rough_notes'] ) : ''; $image_id = isset( $item['image_id'] ) ? absint( $item['image_id'] ) : 0; if ( '' === $rough_notes ) { return new WP_Error( 'apsw_empty_batch_item', __( 'Add product details for this product.', 'ai-product-studio-for-woocommerce' ) ); } if ( $image_id > 0 ) { return $this->validate_batch_image_attachment( $image_id ); } return true; } /** * Validate one selected WordPress media image. * * @param int $attachment_id Attachment ID. * @return true|WP_Error */ private function validate_batch_image_attachment( $attachment_id ) { $attachment_id = absint( $attachment_id ); if ( $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) || ! wp_attachment_is_image( $attachment_id ) ) { return new WP_Error( 'apsw_invalid_batch_image', __( 'Select a valid WordPress media image.', 'ai-product-studio-for-woocommerce' ) ); } return true; } /** * Build prepared input for one batch item. * * @param array $item Batch item. * @param int $index Zero-based item index. * @return array */ private function build_prepared_item( array $item, $index ) { return array( 'generation_mode' => 'text_to_product', 'batch_create_item' => 1, 'product_image_ids' => array(), 'product_main_image_id' => 0, 'product_gallery_image_ids' => array(), 'product_category_id' => isset( $item['product_category_id'] ) ? absint( $item['product_category_id'] ) : 0, 'product_category_label' => isset( $item['product_category_label'] ) ? sanitize_text_field( $item['product_category_label'] ) : '', 'product_kind' => isset( $item['product_kind'] ) ? $this->sanitize_product_kind( $item['product_kind'] ) : 'physical', 'is_downloadable' => 0, 'rough_notes' => isset( $item['rough_notes'] ) ? sanitize_textarea_field( $item['rough_notes'] ) : '', 'price' => isset( $item['price'] ) ? $this->sanitize_decimal_value( $item['price'] ) : '', 'sale_price' => isset( $item['sale_price'] ) ? $this->sanitize_decimal_value( $item['sale_price'] ) : '', 'sku' => '', 'stock_quantity' => isset( $item['stock_quantity'] ) && '' !== trim( (string) $item['stock_quantity'] ) ? absint( $item['stock_quantity'] ) : '', 'status' => 'draft', 'prepared_timestamp' => time() + absint( $index ), ); } /** * Attach a selected local media image to a created product. * * @param int $product_id Product ID. * @param int $attachment_id Attachment ID. * @return bool */ private function attach_local_image_to_product( $product_id, $attachment_id ) { $product_id = absint( $product_id ); $attachment_id = absint( $attachment_id ); if ( $product_id <= 0 || $attachment_id <= 0 || ! function_exists( 'wc_get_product' ) ) { return false; } $product = wc_get_product( $product_id ); if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { return false; } $product->set_image_id( $attachment_id ); try { return (bool) $product->save(); } catch ( Exception $exception ) { return false; } } /** * Save a notice for the current user. * * @param array $notice Notice payload. * @return void */ private function set_notice( array $notice ) { set_transient( $this->get_notice_key( get_current_user_id() ), $notice, 5 * MINUTE_IN_SECONDS ); } /** * Get user-specific notice key. * * @param int $user_id User ID. * @return string */ private function get_notice_key( $user_id ) { return self::NOTICE_TRANSIENT_PREFIX . absint( $user_id ); } /** * Check submission capability. * * @return bool */ private function current_user_can_submit() { if ( $this->dependencies->is_woocommerce_active() && current_user_can( 'manage_woocommerce' ) ) { return true; } return current_user_can( 'manage_options' ); } /** * Redirect to the Batch Create page. * * @return void */ private function redirect_to_batch_page() { wp_safe_redirect( add_query_arg( array( 'page' => 'apsw-batch-create', ), admin_url( 'admin.php' ) ) ); exit; } /** * Return a safe generation failure message. * * @param array $ai_result AI result payload. * @return string */ private function get_ai_generation_failure_message( array $ai_result ) { $error_code = isset( $ai_result['error_code'] ) ? sanitize_key( $ai_result['error_code'] ) : ''; switch ( $error_code ) { case 'missing_access_key': return __( 'Clugins Access Key is missing. Please add your Access Key in Settings.', 'ai-product-studio-for-woocommerce' ); case 'invalid_access_key': case 'unauthorized': case 'forbidden': case 'site_not_allowed': case 'site_key_mismatch': case 'access_key_site_required': return __( 'Your Clugins connection could not be verified. Please reconnect your site in Settings.', 'ai-product-studio-for-woocommerce' ); case 'insufficient_credits': case 'usage_limit_exceeded': return __( 'Your current Clugins plan limit has been reached for this request. Please review your plan or try again later.', 'ai-product-studio-for-woocommerce' ); case 'unsupported_operation': return __( 'This generation type is not available yet. Please update the Clugins service or contact support.', 'ai-product-studio-for-woocommerce' ); case 'invalid_request': case 'invalid_payload': case 'invalid_image_payload': case 'image_required': case 'image_too_large': case 'unsupported_image_type': return __( 'The request could not be processed. Please check your input and try again.', 'ai-product-studio-for-woocommerce' ); case 'service_unavailable': return __( 'Clugins is temporarily unavailable. Please try again shortly.', 'ai-product-studio-for-woocommerce' ); } return __( 'AI product data generation failed.', 'ai-product-studio-for-woocommerce' ); } /** * Sanitize decimal values using WooCommerce format when available. * * @param string $value Raw decimal value. * @return string */ private function sanitize_decimal_value( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return ''; } if ( function_exists( 'wc_format_decimal' ) ) { return wc_format_decimal( $value, wc_get_price_decimals(), false ); } return preg_replace( '/[^0-9.\-]/', '', $value ); } /** * Decode title entities and sanitize before showing result text. * * @param string $title Product title. * @return string */ private function sanitize_product_title_for_display( $title ) { $title = wp_specialchars_decode( (string) $title, ENT_QUOTES ); return sanitize_text_field( $title ); } }