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
);
}
}