settings = $settings; $this->dependencies = $dependencies; } /** * Generate structured product data from prepared input. * * @param array $prepared_input Prepared payload. * @param array $contexts Allowed context labels. * @return array */ public function generate_from_prepared_input( array $prepared_input, array $contexts ) { $result = array( 'success' => false, 'data' => array(), 'warnings' => array(), 'message' => __( 'AI product data generation failed. Please check your Clugins connection and try again.', 'ai-product-studio-for-woocommerce' ), 'provider' => '', 'model' => '', 'error_code' => '', ); if ( ! $this->dependencies->is_woocommerce_active() ) { $result['warnings'][] = __( 'WooCommerce is not active.', 'ai-product-studio-for-woocommerce' ); $result['error_code'] = 'woocommerce_inactive'; return $result; } $settings = $this->settings->get_settings(); $provider = $this->settings->normalize_provider_key( $settings[ APSW_Settings::FIELD_PROVIDER ] ); if ( 'clugins_managed' === $provider ) { return $this->generate_with_clugins_managed_service( $prepared_input ); } $result['message'] = __( 'Clugins Managed AI is required. Connect with Clugins before generating products.', 'ai-product-studio-for-woocommerce' ); $result['provider'] = 'clugins_managed'; $result['error_code'] = 'provider_not_configured'; return $result; } /** * Generate product data using Clugins Managed Service. * * @param array $prepared_input Prepared data. * @return array */ private function generate_with_clugins_managed_service( array $prepared_input ) { $result = array( 'success' => false, 'data' => array(), 'warnings' => array(), 'message' => __( 'Product generation failed. Please try again.', 'ai-product-studio-for-woocommerce' ), 'provider' => 'clugins_managed', 'model' => '', 'error_code' => 'generation_failed', ); $settings = $this->settings->get_settings(); $access_key = trim( (string) $settings[ APSW_Settings::FIELD_CLUGINS_ACCESS_KEY ] ); if ( '' === $access_key ) { $result['message'] = __( 'Clugins Access Key is missing. Please add your Access Key in Settings.', 'ai-product-studio-for-woocommerce' ); $result['error_code'] = 'missing_access_key'; return $result; } $operation_type = $this->get_operation_type( $prepared_input ); $payload = $this->build_clugins_payload( $prepared_input, $operation_type ); if ( is_wp_error( $payload ) ) { $result['message'] = $payload->get_error_message(); $result['error_code'] = sanitize_key( $payload->get_error_code() ); return $result; } $response = wp_remote_post( self::CLUGINS_TEXT_TO_PRODUCT_ENDPOINT, array( 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'access_key' => $access_key, 'site_url' => home_url(), 'operation_type' => $operation_type, 'plugin_slug' => 'ai-product-studio-for-woocommerce', 'plugin_version' => defined( 'APSW_VERSION' ) ? APSW_VERSION : '', 'payload' => $payload, ) ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { $result['message'] = $this->get_clugins_error_message( 'service_unavailable' ); $result['error_code'] = 'service_unavailable'; return $result; } $status_code = (int) wp_remote_retrieve_response_code( $response ); $body_text = (string) wp_remote_retrieve_body( $response ); $decoded = json_decode( $body_text, true ); if ( ! is_array( $decoded ) ) { $result['message'] = $this->get_clugins_error_message( 'generation_failed' ); $result['error_code'] = 'generation_failed'; return $result; } if ( $status_code < 200 || $status_code >= 300 || empty( $decoded['success'] ) ) { $error_code = $this->normalize_clugins_error_code( $decoded, $status_code ); $result['message'] = $this->get_clugins_error_message( $error_code ); $result['error_code'] = $error_code; return $result; } if ( empty( $decoded['data'] ) || ! is_array( $decoded['data'] ) ) { $result['message'] = $this->get_clugins_error_message( 'generation_failed' ); $result['error_code'] = 'generation_failed'; return $result; } $sanitized = $this->sanitize_generated_data( $decoded['data'], $prepared_input ); $result['success'] = true; $result['data'] = $sanitized['data']; $result['warnings'] = $sanitized['warnings']; $result['message'] = __( 'AI product data generated successfully. Review the preview before creating a product.', 'ai-product-studio-for-woocommerce' ); $result['error_code'] = ''; return $result; } /** * Build the plugin generation payload sent to Clugins. * * @param array $prepared_input Prepared data. * @param string $operation_type Operation type. * @return array|WP_Error */ private function build_clugins_payload( array $prepared_input, $operation_type ) { $settings = $this->settings->get_settings(); $product_kind = isset( $prepared_input['product_kind'] ) ? sanitize_key( $prepared_input['product_kind'] ) : 'physical'; if ( 'digital' === $product_kind || 'downloadable' === $product_kind ) { $product_kind = 'virtual'; } if ( ! in_array( $product_kind, array( 'physical', 'virtual' ), true ) ) { $product_kind = 'physical'; } $payload = array( 'product_category_label' => isset( $prepared_input['product_category_label'] ) ? sanitize_text_field( $prepared_input['product_category_label'] ) : '', 'product_kind' => $product_kind, 'rough_notes' => isset( $prepared_input['rough_notes'] ) ? sanitize_textarea_field( $prepared_input['rough_notes'] ) : '', 'price' => isset( $prepared_input['price'] ) ? sanitize_text_field( $prepared_input['price'] ) : '', 'sale_price' => isset( $prepared_input['sale_price'] ) ? sanitize_text_field( $prepared_input['sale_price'] ) : '', 'sku' => isset( $prepared_input['sku'] ) ? sanitize_text_field( $prepared_input['sku'] ) : '', 'stock_quantity' => isset( $prepared_input['stock_quantity'] ) ? sanitize_text_field( (string) $prepared_input['stock_quantity'] ) : '', 'description_length' => $settings[ APSW_Settings::FIELD_DESCRIPTION_LENGTH ], 'generation_preferences' => array( 'apply_tags' => $this->settings->is_generation_option_enabled( APSW_Settings::FIELD_APPLY_TAGS ), 'apply_attributes' => $this->settings->is_generation_option_enabled( APSW_Settings::FIELD_APPLY_ATTRIBUTES ), 'apply_image_alt_text' => $this->settings->is_generation_option_enabled( APSW_Settings::FIELD_APPLY_IMAGE_ALT_TEXT ), 'apply_shipping_specs' => $this->settings->is_generation_option_enabled( APSW_Settings::FIELD_APPLY_SHIPPING_SPECS ), 'store_seo_meta' => $this->settings->is_generation_option_enabled( APSW_Settings::FIELD_STORE_SEO_META ), ), ); if ( ! $this->is_image_payload_operation( $operation_type ) ) { return $payload; } $image_payload = $this->build_image_payload( isset( $prepared_input['product_main_image_id'] ) ? absint( $prepared_input['product_main_image_id'] ) : 0 ); if ( is_wp_error( $image_payload ) ) { return $image_payload; } $payload['image'] = $image_payload; $payload['generation_request_id'] = $this->build_generation_request_id( $prepared_input ); return $payload; } /** * Resolve supported plugin operation type. * * @param array $prepared_input Prepared data. * @return string */ private function get_operation_type( array $prepared_input ) { $mode = isset( $prepared_input['generation_mode'] ) ? sanitize_key( $prepared_input['generation_mode'] ) : self::GENERATION_MODE_TEXT_TO_PRODUCT; if ( self::GENERATION_MODE_IMAGE_TO_PRODUCT === $mode ) { return self::GENERATION_MODE_IMAGE_TO_PRODUCT; } if ( self::GENERATION_MODE_ADVANCED_PRODUCT_GENERATION === $mode ) { return self::GENERATION_MODE_ADVANCED_PRODUCT_GENERATION; } return self::GENERATION_MODE_TEXT_TO_PRODUCT; } /** * Determine whether an operation sends a selected image payload. * * @param string $operation_type Operation type. * @return bool */ private function is_image_payload_operation( $operation_type ) { return in_array( $operation_type, array( self::GENERATION_MODE_IMAGE_TO_PRODUCT, self::GENERATION_MODE_ADVANCED_PRODUCT_GENERATION, ), true ); } /** * Build the server-side image payload from a selected attachment. * * @param int $attachment_id Attachment ID. * @return array|WP_Error */ private function build_image_payload( $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( 'invalid_image', __( 'Please select a valid product image before generating from an image.', 'ai-product-studio-for-woocommerce' ) ); } $mime_type = strtolower( (string) get_post_mime_type( $attachment_id ) ); if ( ! in_array( $mime_type, self::ALLOWED_IMAGE_MIME_TYPES, true ) ) { return new WP_Error( 'unsupported_image_type', __( 'Please select a JPG, PNG, or WebP image.', 'ai-product-studio-for-woocommerce' ) ); } $file_path = get_attached_file( $attachment_id, true ); if ( ! is_string( $file_path ) || '' === $file_path || ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { return new WP_Error( 'invalid_image', __( 'The selected image could not be read. Please choose another image.', 'ai-product-studio-for-woocommerce' ) ); } if ( ! $this->is_file_in_uploads_directory( $file_path ) ) { return new WP_Error( 'invalid_image', __( 'The selected image could not be read. Please choose another image.', 'ai-product-studio-for-woocommerce' ) ); } $file_size = filesize( $file_path ); if ( false === $file_size || $file_size <= 0 ) { return new WP_Error( 'invalid_image', __( 'The selected image could not be read. Please choose another image.', 'ai-product-studio-for-woocommerce' ) ); } if ( $file_size > self::MAX_IMAGE_BYTES ) { return new WP_Error( 'image_too_large', __( 'Please select an image that is 8 MB or smaller.', 'ai-product-studio-for-woocommerce' ) ); } $image_bytes = file_get_contents( $file_path ); if ( false === $image_bytes || '' === $image_bytes ) { return new WP_Error( 'invalid_image', __( 'The selected image could not be read. Please choose another image.', 'ai-product-studio-for-woocommerce' ) ); } $metadata = wp_get_attachment_metadata( $attachment_id ); return array( 'base64' => base64_encode( $image_bytes ), 'mime_type' => $mime_type, 'filename' => sanitize_file_name( wp_basename( $file_path ) ), 'width' => isset( $metadata['width'] ) ? absint( $metadata['width'] ) : 0, 'height' => isset( $metadata['height'] ) ? absint( $metadata['height'] ) : 0, ); } /** * Check whether a local attachment file is inside the uploads directory. * * @param string $file_path Local file path. * @return bool */ private function is_file_in_uploads_directory( $file_path ) { $uploads = wp_get_upload_dir(); if ( empty( $uploads['basedir'] ) || ! is_string( $uploads['basedir'] ) ) { return false; } $real_file_path = realpath( $file_path ); $real_uploads = realpath( $uploads['basedir'] ); if ( false === $real_file_path || false === $real_uploads ) { return false; } $real_file_path = wp_normalize_path( $real_file_path ); $real_uploads = trailingslashit( wp_normalize_path( $real_uploads ) ); return 0 === strpos( $real_file_path, $real_uploads ); } /** * Build a safe idempotency id for image generation. * * @param array $prepared_input Prepared data. * @return string */ private function build_generation_request_id( array $prepared_input ) { $hash_input = array( 'user_id' => get_current_user_id(), 'product_main_image_id' => isset( $prepared_input['product_main_image_id'] ) ? absint( $prepared_input['product_main_image_id'] ) : 0, 'rough_notes' => isset( $prepared_input['rough_notes'] ) ? sanitize_textarea_field( $prepared_input['rough_notes'] ) : '', 'price' => isset( $prepared_input['price'] ) ? sanitize_text_field( $prepared_input['price'] ) : '', 'sale_price' => isset( $prepared_input['sale_price'] ) ? sanitize_text_field( $prepared_input['sale_price'] ) : '', 'sku' => isset( $prepared_input['sku'] ) ? sanitize_text_field( $prepared_input['sku'] ) : '', 'prepared_timestamp' => isset( $prepared_input['prepared_timestamp'] ) ? absint( $prepared_input['prepared_timestamp'] ) : 0, ); return 'apsw_' . md5( wp_json_encode( $hash_input ) ); } /** * Normalize Clugins error response into a safe public code. * * @param array $decoded Decoded response body. * @param int $status_code HTTP status code. * @return string */ private function normalize_clugins_error_code( array $decoded, $status_code ) { $candidates = array( isset( $decoded['error_code'] ) ? $decoded['error_code'] : '', isset( $decoded['code'] ) ? $decoded['code'] : '', isset( $decoded['error'] ) ? $decoded['error'] : '', ); if ( isset( $decoded['data'] ) && is_array( $decoded['data'] ) ) { $candidates[] = isset( $decoded['data']['error_code'] ) ? $decoded['data']['error_code'] : ''; $candidates[] = isset( $decoded['data']['code'] ) ? $decoded['data']['code'] : ''; $candidates[] = isset( $decoded['data']['error'] ) ? $decoded['data']['error'] : ''; } if ( 402 === (int) $status_code || $this->clugins_response_indicates_plan_limit( $decoded ) ) { return 'usage_limit_exceeded'; } foreach ( $candidates as $candidate ) { $code = sanitize_key( (string) $candidate ); if ( in_array( $code, array( 'invalid_access_key', 'unauthorized', 'forbidden', 'site_not_allowed', 'site_key_mismatch', 'access_key_site_required', 'insufficient_credits', 'usage_limit_exceeded', 'unsupported_operation', 'invalid_request', 'invalid_payload', 'invalid_image_payload', 'image_required', 'image_too_large', 'unsupported_image_type', 'service_unavailable', 'generation_failed' ), true ) ) { return $code; } } if ( 401 === (int) $status_code || 403 === (int) $status_code ) { return 'invalid_access_key'; } if ( (int) $status_code >= 500 ) { return 'service_unavailable'; } return 'generation_failed'; } /** * Extract a sanitized response field from decoded Clugins JSON. * * @param array $decoded Decoded response body. * @param array $paths Candidate key paths. * @return string */ private function extract_clugins_response_field( array $decoded, array $paths ) { foreach ( $paths as $path ) { $value = $decoded; foreach ( $path as $key ) { if ( ! is_array( $value ) || ! array_key_exists( $key, $value ) ) { $value = null; break; } $value = $value[ $key ]; } if ( is_scalar( $value ) ) { $sanitized = $this->sanitize_clugins_response_value( $value ); if ( '' !== $sanitized ) { return $sanitized; } } } return ''; } /** * Sanitize a response value without exposing payloads or encoded image data. * * @param mixed $value Response value. * @return string */ private function sanitize_clugins_response_value( $value ) { if ( ! is_scalar( $value ) ) { return ''; } $value = wp_strip_all_tags( (string) $value ); $value = preg_replace( '/data:image\/[a-z0-9.+-]+;base64,[a-z0-9+\/=]+/i', '[redacted image data]', $value ); $value = preg_replace( '/[a-z0-9+\/]{120,}={0,2}/i', '[redacted encoded data]', $value ); $value = sanitize_text_field( $value ); if ( function_exists( 'mb_substr' ) ) { return mb_substr( $value, 0, 240 ); } return substr( $value, 0, 240 ); } /** * Detect plan-limit responses whose service code was not normalized. * * @param array $decoded Decoded response body. * @return bool */ private function clugins_response_indicates_plan_limit( array $decoded ) { $message = $this->extract_clugins_response_field( $decoded, array( array( 'message' ), array( 'error_description' ), array( 'data', 'message' ), array( 'data', 'error_description' ), ) ); if ( '' === $message ) { return false; } $message = strtolower( $message ); return false !== strpos( $message, 'not enough available usage' ) || false !== strpos( $message, 'usage limit' ) || false !== strpos( $message, 'plan limit' ); } /** * Return safe admin-facing message for a Clugins error code. * * @param string $error_code Error code. * @return string */ private function get_clugins_error_message( $error_code ) { switch ( sanitize_key( $error_code ) ) { 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' ); case 'generation_failed': default: return __( 'Product generation failed. Please try again.', 'ai-product-studio-for-woocommerce' ); } } /** * Sanitize generated schema fields. * * @param array $raw_data Raw decoded JSON. * @param array $prepared_input Prepared input payload. * @return array */ private function sanitize_generated_data( array $raw_data, array $prepared_input ) { $raw_data = $this->normalize_generated_data_shape( $raw_data ); $allowed_html = array( 'p' => array(), 'br' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'strong' => array(), 'em' => array(), 'b' => array(), 'i' => array(), ); $data = array( 'product_title' => sanitize_text_field( isset( $raw_data['product_title'] ) ? (string) $raw_data['product_title'] : '' ), 'short_description' => wp_kses( isset( $raw_data['short_description'] ) ? (string) $raw_data['short_description'] : '', $allowed_html ), 'long_description' => wp_kses( isset( $raw_data['long_description'] ) ? (string) $raw_data['long_description'] : '', $allowed_html ), 'missing_info_notes' => array(), ); $warnings = array(); if ( isset( $raw_data['missing_info_notes'] ) && is_array( $raw_data['missing_info_notes'] ) ) { foreach ( array_slice( $raw_data['missing_info_notes'], 0, 20 ) as $note ) { $note = sanitize_text_field( (string) $note ); if ( '' !== $note ) { $data['missing_info_notes'][] = $note; } } } $rich_fields = array( 'tags' => array( 'tags', 'product_tags' ), 'attributes' => array( 'attributes', 'product_attributes' ), 'shipping' => array( 'shipping', 'shipping_specs' ), 'image_alt_text' => array( 'image_alt_text', 'alt_text', 'images_alt_text', 'image_alt_texts' ), 'seo' => array( 'seo', 'seo_meta' ), 'suggested_category' => array( 'suggested_category', 'category_suggestion' ), 'generation_mode' => array( 'generation_mode', 'mode', 'source_mode', 'operation_type' ), ); foreach ( $rich_fields as $field_key => $aliases ) { $value = $this->get_first_present_generated_value( $raw_data, $aliases ); if ( null !== $value ) { $data[ $field_key ] = $this->sanitize_generated_rich_value( $value ); } } if ( '' === $data['product_title'] ) { $warnings[] = __( 'The AI response did not include a strong product title.', 'ai-product-studio-for-woocommerce' ); } return array( 'data' => $data, 'warnings' => $warnings, ); } /** * Normalize generated data shapes returned by Clugins product operations. * * @param array $raw_data Raw generated data. * @return array */ private function normalize_generated_data_shape( array $raw_data ) { if ( empty( $raw_data['products'] ) || ! is_array( $raw_data['products'] ) || empty( $raw_data['products'][0] ) || ! is_array( $raw_data['products'][0] ) ) { return $raw_data; } $product_data = $raw_data['products'][0]; $normalized = $raw_data; $mapped_fields = array( 'product_title' => array( 'product_title', 'title', 'name' ), 'short_description' => array( 'short_description', 'summary' ), 'long_description' => array( 'long_description', 'description' ), 'missing_info_notes' => array( 'missing_info_notes', 'missing_information', 'notes' ), 'tags' => array( 'tags', 'product_tags' ), 'attributes' => array( 'attributes', 'product_attributes' ), 'shipping' => array( 'shipping', 'shipping_specs' ), 'image_alt_text' => array( 'image_alt_text', 'alt_text', 'images_alt_text', 'image_alt_texts' ), 'seo' => array( 'seo', 'seo_meta' ), 'suggested_category' => array( 'suggested_category', 'category_suggestion' ), 'generation_mode' => array( 'generation_mode', 'mode', 'source_mode', 'operation_type' ), ); foreach ( $mapped_fields as $target_key => $aliases ) { if ( array_key_exists( $target_key, $normalized ) && '' !== $normalized[ $target_key ] && array() !== $normalized[ $target_key ] ) { continue; } $value = $this->get_first_present_generated_value( $product_data, $aliases ); if ( null !== $value ) { $normalized[ $target_key ] = $value; } } if ( ( empty( $normalized['suggested_category'] ) ) && ! empty( $product_data['categories'] ) && is_array( $product_data['categories'] ) ) { $normalized['suggested_category'] = reset( $product_data['categories'] ); } if ( empty( $normalized['generation_mode'] ) && ! empty( $raw_data['operation_type'] ) ) { $normalized['generation_mode'] = $raw_data['operation_type']; } return $normalized; } /** * Return the first present generated value from aliases. * * @param array $raw_data Raw generated data. * @param array $aliases Candidate keys. * @return mixed|null */ private function get_first_present_generated_value( array $raw_data, array $aliases ) { foreach ( $aliases as $alias ) { if ( ! array_key_exists( $alias, $raw_data ) ) { continue; } if ( '' === $raw_data[ $alias ] || array() === $raw_data[ $alias ] ) { continue; } return $raw_data[ $alias ]; } return null; } /** * Recursively sanitize optional rich generated fields for local storage. * * @param mixed $value Raw value. * @return mixed */ private function sanitize_generated_rich_value( $value ) { if ( is_array( $value ) ) { $sanitized = array(); foreach ( $value as $key => $item ) { $sanitized_key = is_int( $key ) ? $key : sanitize_text_field( (string) $key ); $sanitized[ $sanitized_key ] = $this->sanitize_generated_rich_value( $item ); } return $sanitized; } if ( is_scalar( $value ) || null === $value ) { return sanitize_text_field( (string) $value ); } return ''; } }