product_generator = $product_generator; add_action( 'init', array( $this, 'register_product_meta' ) ); add_action( 'admin_menu', array( $this, 'add_admin_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( self::ACTION_HOOK, array( $this, 'process_job_action' ), 10, 1 ); } public function register_product_meta() { register_post_meta( 'product', self::PREVIEW_META_KEY, array( 'type' => 'string', 'single' => true, 'show_in_rest' => false, 'auth_callback' => function() { return current_user_can( 'edit_products' ) || current_user_can( 'edit_posts' ); }, ) ); } public function add_admin_menu() { add_submenu_page( 'wpthemoon-ai-generator', __( 'Bulk Product Generator', 'wpthemoon' ), __( 'Bulk Product Generator', 'wpthemoon' ), 'edit_products', 'wpthemoon-bulk-product-generator', array( $this, 'render_admin_page' ) ); } public function enqueue_admin_assets( $hook ) { if ( false === strpos( $hook, 'wpthemoon-bulk-product-generator' ) ) { return; } $asset_path = WPTHEMOON_PATH . 'build/bulk-product-generator.asset.php'; $asset_file = file_exists( $asset_path ) ? include $asset_path : array( 'dependencies' => array( 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-i18n' ), 'version' => WPTHEMOON_VERSION, ); wp_enqueue_script( 'wpthemoon-bulk-product-generator', WPTHEMOON_URL . 'build/bulk-product-generator.js', $asset_file['dependencies'], $asset_file['version'], true ); wp_enqueue_style( 'wpthemoon-bulk-product-generator-style', WPTHEMOON_URL . 'build/bulk-product-generator.css', array(), $asset_file['version'] ); wp_localize_script( 'wpthemoon-bulk-product-generator', 'wpthemoonBulkProductGenerator', array( 'root' => esc_url_raw( rest_url() ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'maxProducts' => self::MAX_PRODUCTS, 'defaultAudience' => get_option( 'wpthemoon_target_audience', 'online shoppers' ), 'defaultLanguage' => substr( get_locale(), 0, 2 ), 'categories' => $this->get_product_category_options(), ) ); wp_set_script_translations( 'wpthemoon-bulk-product-generator', 'wpthemoon' ); } public function render_admin_page() { ?>

WP_REST_Server::READABLE, 'callback' => array( $this, 'get_products' ), 'permission_callback' => array( $this, 'check_bulk_permission' ), ) ); register_rest_route( $namespace, '/woocommerce/bulk/jobs', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_job' ), 'permission_callback' => array( $this, 'check_bulk_permission' ), ) ); register_rest_route( $namespace, '/woocommerce/bulk/jobs/(?P[a-zA-Z0-9_-]+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_job_status' ), 'permission_callback' => array( $this, 'check_bulk_permission' ), ) ); register_rest_route( $namespace, '/woocommerce/bulk/jobs/(?P[a-zA-Z0-9_-]+)/cancel', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'cancel_job' ), 'permission_callback' => array( $this, 'check_bulk_permission' ), ) ); register_rest_route( $namespace, '/woocommerce/bulk/jobs/(?P[a-zA-Z0-9_-]+)/retry', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'retry_job' ), 'permission_callback' => array( $this, 'check_bulk_permission' ), ) ); register_rest_route( $namespace, '/woocommerce/bulk/jobs/(?P[a-zA-Z0-9_-]+)/apply', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'apply_job_results' ), 'permission_callback' => array( $this, 'check_bulk_permission' ), ) ); } public function check_bulk_permission() { return current_user_can( 'edit_products' ); } public function get_products( WP_REST_Request $request ) { if ( ! class_exists( 'WooCommerce' ) ) { return new WP_Error( 'woocommerce_inactive', __( 'WooCommerce is not active.', 'wpthemoon' ), array( 'status' => 400 ) ); } $page = max( 1, absint( $request->get_param( 'page' ) ) ); $per_page = min( 50, max( 1, absint( $request->get_param( 'perPage' ) ?: 20 ) ) ); $status = sanitize_key( $request->get_param( 'status' ) ?: 'any' ); $category = sanitize_text_field( $request->get_param( 'category' ) ); $args = array( 'post_type' => 'product', 'post_status' => $this->normalize_product_status( $status ), 'posts_per_page' => $per_page, 'paged' => $page, 's' => sanitize_text_field( $request->get_param( 'search' ) ), 'orderby' => 'date', 'order' => 'DESC', ); if ( $category ) { $args['tax_query'] = array( array( 'taxonomy' => 'product_cat', 'field' => is_numeric( $category ) ? 'term_id' : 'slug', 'terms' => is_numeric( $category ) ? absint( $category ) : $category, ), ); } $query = new WP_Query( $args ); $products = array(); foreach ( $query->posts as $post ) { $product = wc_get_product( $post->ID ); if ( ! $product || ! current_user_can( 'edit_post', $post->ID ) ) { continue; } $products[] = $this->format_product_row( $product ); } return rest_ensure_response( array( 'products' => $products, 'total' => intval( $query->found_posts ), 'totalPages' => intval( $query->max_num_pages ), 'page' => $page, 'perPage' => $per_page, ) ); } public function create_job( WP_REST_Request $request ) { $product_ids = $this->sanitize_product_ids( $request->get_param( 'productIds' ) ); if ( empty( $product_ids ) ) { return new WP_Error( 'invalid_products', __( 'Select at least one product.', 'wpthemoon' ), array( 'status' => 400 ) ); } if ( count( $product_ids ) > self::MAX_PRODUCTS ) { return new WP_Error( 'too_many_products', sprintf( __( 'Select no more than %d products.', 'wpthemoon' ), self::MAX_PRODUCTS ), array( 'status' => 400 ) ); } foreach ( $product_ids as $product_id ) { if ( 'product' !== get_post_type( $product_id ) || ! current_user_can( 'edit_post', $product_id ) ) { return new WP_Error( 'forbidden_product', __( 'You do not have permission to edit one or more selected products.', 'wpthemoon' ), array( 'status' => 403 ) ); } } $options = $this->product_generator->sanitize_generation_options( $request->get_param( 'options' ) ); if ( empty( $options['fields'] ) ) { return new WP_Error( 'invalid_fields', __( 'Select at least one field to generate.', 'wpthemoon' ), array( 'status' => 400 ) ); } $job_id = str_replace( '-', '', wp_generate_uuid4() ); $now = current_time( 'mysql' ); $items = array(); foreach ( $product_ids as $product_id ) { $product = wc_get_product( $product_id ); $items[ $product_id ] = array( 'productId' => $product_id, 'title' => $product ? $product->get_name() : get_the_title( $product_id ), 'sku' => $product ? $product->get_sku() : '', 'status' => 'queued', 'generated' => null, 'error' => '', 'appliedFields' => array(), 'updatedAt' => $now, ); } $job = array( 'id' => $job_id, 'status' => 'queued', 'userId' => get_current_user_id(), 'productIds' => $product_ids, 'options' => $options, 'items' => $items, 'pointsRemaining' => null, 'createdAt' => $now, 'updatedAt' => $now, 'cancelled' => false, ); $this->save_job( $job ); $enqueue_result = $this->enqueue_job( $job_id ); if ( is_wp_error( $enqueue_result ) ) { $job['status'] = 'failed'; $job['updatedAt'] = current_time( 'mysql' ); $this->save_job( $job ); return $enqueue_result; } return rest_ensure_response( $this->normalize_job_for_response( $job ) ); } public function get_job_status( WP_REST_Request $request ) { $job = $this->get_job_or_error( sanitize_text_field( $request->get_param( 'jobId' ) ) ); if ( is_wp_error( $job ) ) { return $job; } return rest_ensure_response( $this->normalize_job_for_response( $job ) ); } public function cancel_job( WP_REST_Request $request ) { $job = $this->get_job_or_error( sanitize_text_field( $request->get_param( 'jobId' ) ) ); if ( is_wp_error( $job ) ) { return $job; } $job['cancelled'] = true; $job['status'] = 'cancelled'; $job['updatedAt'] = current_time( 'mysql' ); foreach ( $job['items'] as &$item ) { if ( 'queued' === $item['status'] ) { $item['status'] = 'skipped'; $item['error'] = __( 'Job cancelled.', 'wpthemoon' ); $item['updatedAt'] = $job['updatedAt']; } } unset( $item ); $this->save_job( $job ); return rest_ensure_response( $this->normalize_job_for_response( $job ) ); } public function retry_job( WP_REST_Request $request ) { $job = $this->get_job_or_error( sanitize_text_field( $request->get_param( 'jobId' ) ) ); if ( is_wp_error( $job ) ) { return $job; } $has_retry = false; $now = current_time( 'mysql' ); foreach ( $job['items'] as &$item ) { if ( in_array( $item['status'], array( 'failed', 'skipped' ), true ) ) { $item['status'] = 'queued'; $item['error'] = ''; $item['updatedAt'] = $now; $has_retry = true; } } unset( $item ); if ( ! $has_retry ) { return new WP_Error( 'nothing_to_retry', __( 'There are no failed or skipped products to retry.', 'wpthemoon' ), array( 'status' => 400 ) ); } $job['status'] = 'queued'; $job['cancelled'] = false; $job['updatedAt'] = $now; $this->save_job( $job ); $enqueue_result = $this->enqueue_job( $job['id'] ); if ( is_wp_error( $enqueue_result ) ) { return $enqueue_result; } return rest_ensure_response( $this->normalize_job_for_response( $job ) ); } public function apply_job_results( WP_REST_Request $request ) { $job = $this->get_job_or_error( sanitize_text_field( $request->get_param( 'jobId' ) ) ); if ( is_wp_error( $job ) ) { return $job; } $product_ids = $this->sanitize_product_ids( $request->get_param( 'productIds' ) ); $fields = $this->product_generator->sanitize_fields( $request->get_param( 'fields' ) ); if ( empty( $product_ids ) ) { return new WP_Error( 'invalid_products', __( 'Select at least one generated product to apply.', 'wpthemoon' ), array( 'status' => 400 ) ); } if ( empty( $fields ) ) { return new WP_Error( 'invalid_fields', __( 'Select at least one field to apply.', 'wpthemoon' ), array( 'status' => 400 ) ); } $applied = array(); $failed = array(); $now = current_time( 'mysql' ); foreach ( $product_ids as $product_id ) { if ( ! isset( $job['items'][ $product_id ] ) || empty( $job['items'][ $product_id ]['generated'] ) ) { $failed[] = array( 'productId' => $product_id, 'error' => __( 'No generated preview is available for this product.', 'wpthemoon' ), ); continue; } if ( ! current_user_can( 'edit_post', $product_id ) ) { $failed[] = array( 'productId' => $product_id, 'error' => __( 'You do not have permission to edit this product.', 'wpthemoon' ), ); continue; } $result = $this->product_generator->apply_generated_content( $product_id, $job['items'][ $product_id ]['generated'], $fields, 'bulk', array( 'bulk_job_id' => $job['id'] ) ); if ( is_wp_error( $result ) ) { $failed[] = array( 'productId' => $product_id, 'error' => $result->get_error_message(), ); continue; } $job['items'][ $product_id ]['appliedFields'] = $result['applied']; $job['items'][ $product_id ]['appliedAt'] = $now; $job['items'][ $product_id ]['updatedAt'] = $now; $applied[] = array( 'productId' => $product_id, 'applied' => $result['applied'], 'product' => $result['product'], ); } $job['updatedAt'] = $now; $this->save_job( $job ); return rest_ensure_response( array( 'applied' => $applied, 'failed' => $failed, 'job' => $this->normalize_job_for_response( $job ), ) ); } public function process_job_action( $job_id ) { $job_id = sanitize_text_field( $job_id ); $lock_key = 'wpthemoon_wc_bulk_product_ai_lock_' . md5( $job_id ); if ( get_transient( $lock_key ) ) { return; } set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS ); try { $job = $this->get_job( $job_id ); if ( ! $job || ! empty( $job['cancelled'] ) || 'cancelled' === $job['status'] ) { return; } $product_id = $this->get_next_queued_product_id( $job ); if ( ! $product_id ) { $this->finalize_job( $job ); return; } $now = current_time( 'mysql' ); $job['status'] = 'running'; $job['items'][ $product_id ]['status'] = 'running'; $job['items'][ $product_id ]['updatedAt'] = $now; $job['updatedAt'] = $now; $this->save_job( $job ); $product = wc_get_product( $product_id ); if ( ! $product ) { $this->mark_item_failed( $job, $product_id, __( 'Product no longer exists.', 'wpthemoon' ) ); $this->save_job( $job ); $this->enqueue_job( $job_id ); return; } $client = new WPTheMoon_API_Client(); $result = $client->post( '/woocommerce/product', array( 'product' => $this->product_generator->build_product_context( $product ), 'options' => $job['options'], ) ); if ( is_wp_error( $result ) ) { $message = $result->get_error_message(); $this->mark_item_failed( $job, $product_id, $message ); if ( $this->is_stop_error( $result ) ) { $this->mark_remaining_skipped( $job, $message ); $job['status'] = 'failed'; $job['updatedAt'] = current_time( 'mysql' ); $this->save_job( $job ); return; } $this->save_job( $job ); $this->enqueue_job( $job_id ); return; } $job['items'][ $product_id ]['status'] = 'completed'; $job['items'][ $product_id ]['generated'] = $result; $job['items'][ $product_id ]['error'] = ''; $job['items'][ $product_id ]['updatedAt'] = current_time( 'mysql' ); $job['updatedAt'] = $job['items'][ $product_id ]['updatedAt']; if ( isset( $result['usage']['pointsRemaining'] ) ) { $job['pointsRemaining'] = intval( $result['usage']['pointsRemaining'] ); } update_post_meta( $product_id, self::PREVIEW_META_KEY, wp_json_encode( array( 'jobId' => $job_id, 'generated' => $result, 'options' => $job['options'], 'generatedAt' => $job['items'][ $product_id ]['updatedAt'], ) ) ); delete_transient( 'wpthemoon_credits_cache' ); $this->save_job( $job ); if ( $this->get_next_queued_product_id( $job ) ) { $this->enqueue_job( $job_id ); } else { $this->finalize_job( $job ); } } finally { delete_transient( $lock_key ); } } private function enqueue_job( $job_id ) { if ( function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::ACTION_HOOK, array( $job_id ), self::ACTION_GROUP ); return true; } if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + 1, self::ACTION_HOOK, array( $job_id ), self::ACTION_GROUP ); return true; } return new WP_Error( 'action_scheduler_unavailable', __( 'WooCommerce Action Scheduler is not available.', 'wpthemoon' ), array( 'status' => 500 ) ); } private function get_job_option_name( $job_id ) { return self::JOB_OPTION_PREFIX . sanitize_key( $job_id ); } private function get_job( $job_id ) { $job = get_option( $this->get_job_option_name( $job_id ) ); return is_array( $job ) ? $job : null; } private function get_job_or_error( $job_id ) { $job = $this->get_job( $job_id ); if ( ! $job ) { return new WP_Error( 'job_not_found', __( 'Bulk product generation job was not found.', 'wpthemoon' ), array( 'status' => 404 ) ); } return $job; } private function save_job( $job ) { update_option( $this->get_job_option_name( $job['id'] ), $job, false ); } private function get_next_queued_product_id( $job ) { foreach ( $job['productIds'] as $product_id ) { if ( isset( $job['items'][ $product_id ] ) && 'queued' === $job['items'][ $product_id ]['status'] ) { return $product_id; } } return 0; } private function mark_item_failed( &$job, $product_id, $message ) { $job['items'][ $product_id ]['status'] = 'failed'; $job['items'][ $product_id ]['error'] = sanitize_text_field( $message ); $job['items'][ $product_id ]['updatedAt'] = current_time( 'mysql' ); $job['updatedAt'] = $job['items'][ $product_id ]['updatedAt']; } private function mark_remaining_skipped( &$job, $message ) { $now = current_time( 'mysql' ); foreach ( $job['items'] as &$item ) { if ( 'queued' === $item['status'] ) { $item['status'] = 'skipped'; $item['error'] = sanitize_text_field( $message ); $item['updatedAt'] = $now; } } unset( $item ); } private function finalize_job( $job ) { $counts = $this->count_job_items( $job ); $job['status'] = ( $counts['failed'] > 0 || $counts['skipped'] > 0 ) ? 'failed' : 'completed'; $job['updatedAt'] = current_time( 'mysql' ); $this->save_job( $job ); } private function is_stop_error( WP_Error $error ) { $code = strtolower( $error->get_error_code() ); $message = strtolower( $error->get_error_message() ); if ( in_array( $code, array( 'missing_api_key', 'insufficient_credits', 'insufficient_points', 'not_enough_credits' ), true ) ) { return true; } return false !== strpos( $message, 'api key' ) || false !== strpos( $message, 'insufficient credit' ) || false !== strpos( $message, 'not enough credit' ) || false !== strpos( $message, 'not enough points' ); } private function normalize_job_for_response( $job ) { return array( 'id' => $job['id'], 'status' => $job['status'], 'options' => $job['options'], 'progress' => $this->count_job_items( $job ), 'items' => array_values( $job['items'] ), 'pointsRemaining' => isset( $job['pointsRemaining'] ) ? $job['pointsRemaining'] : null, 'createdAt' => $job['createdAt'], 'updatedAt' => $job['updatedAt'], ); } private function count_job_items( $job ) { $counts = array( 'total' => count( $job['items'] ), 'queued' => 0, 'running' => 0, 'completed' => 0, 'failed' => 0, 'skipped' => 0, ); foreach ( $job['items'] as $item ) { if ( isset( $counts[ $item['status'] ] ) ) { $counts[ $item['status'] ]++; } } $counts['processed'] = $counts['completed'] + $counts['failed'] + $counts['skipped']; return $counts; } private function sanitize_product_ids( $product_ids ) { if ( ! is_array( $product_ids ) ) { return array(); } return array_values( array_unique( array_filter( array_map( 'absint', $product_ids ) ) ) ); } private function normalize_product_status( $status ) { $allowed = array( 'publish', 'draft', 'pending', 'private' ); if ( in_array( $status, $allowed, true ) ) { return $status; } return $allowed; } private function format_product_row( WC_Product $product ) { $product_id = $product->get_id(); return array( 'id' => $product_id, 'title' => $product->get_name(), 'sku' => $product->get_sku(), 'status' => get_post_status( $product_id ), 'categories' => $this->get_term_names( $product_id, 'product_cat' ), 'tags' => $this->get_term_names( $product_id, 'product_tag' ), 'hasDescription' => '' !== trim( wp_strip_all_tags( $product->get_description() ) ), 'hasShortDescription' => '' !== trim( wp_strip_all_tags( $product->get_short_description() ) ), 'editUrl' => get_edit_post_link( $product_id, 'raw' ), ); } private function get_term_names( $product_id, $taxonomy ) { $terms = wp_get_post_terms( $product_id, $taxonomy, array( 'fields' => 'names' ) ); return is_wp_error( $terms ) ? array() : array_values( $terms ); } private function get_product_category_options() { $terms = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'number' => 100, ) ); if ( is_wp_error( $terms ) ) { return array(); } return array_map( function( $term ) { return array( 'label' => $term->name, 'value' => (string) $term->term_id, ); }, $terms ); } }