From 05aea45d506dd00319ffb71e9171d3c4d4b731d5 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 18 Jun 2026 11:02:16 -0600 Subject: [PATCH 1/5] Add an async mode for image processing actions --- .../Features/AsyncImageProcessing.php | 254 ++++++++++++++++++ .../Features/DescriptiveTextGenerator.php | 17 +- includes/Classifai/Features/ImageCropping.php | 17 +- .../Classifai/Features/ImageTagsGenerator.php | 17 +- .../Features/ImageTextExtraction.php | 17 +- includes/Classifai/Plugin.php | 22 ++ 6 files changed, 324 insertions(+), 20 deletions(-) create mode 100644 includes/Classifai/Features/AsyncImageProcessing.php diff --git a/includes/Classifai/Features/AsyncImageProcessing.php b/includes/Classifai/Features/AsyncImageProcessing.php new file mode 100644 index 000000000..98d8ad120 --- /dev/null +++ b/includes/Classifai/Features/AsyncImageProcessing.php @@ -0,0 +1,254 @@ + $attachment_id, + 'feature' => static::ID, + 'type' => $type, + 'calling_user_id' => get_current_user_id(), + ); + } + + /** + * Enqueue an async image processing job. + * + * @param int $attachment_id Attachment ID being processed. + * @param string $type Run type passed through to `run()`. + * @return bool True if a job was scheduled (or already pending); false if + * Action Scheduler is unavailable so the caller should fall + * back to synchronous processing. + */ + public function enqueue_async_image_job( int $attachment_id, string $type ): bool { + if ( ! function_exists( 'as_enqueue_async_action' ) ) { + return false; + } + + $args = $this->get_async_job_args( $attachment_id, $type ); + + if ( function_exists( 'as_has_scheduled_action' ) && \as_has_scheduled_action( ImageProcessing::ASYNC_JOB_HOOK, $args, 'classifai' ) ) { + return true; + } + + \as_enqueue_async_action( ImageProcessing::ASYNC_JOB_HOOK, $args, 'classifai' ); + + $this->set_async_status( + $attachment_id, + array( + 'status' => 'scheduled', + 'type' => $type, + ) + ); + + return true; + } + + /** + * Handle an async image processing job. + * + * Registered against the shared job hook by every image Feature, so it self + * filters on the `feature` arg. Reuses the Feature's existing `run()` and + * `save()` methods so behavior matches the synchronous and REST paths. + * + * @param int $attachment_id Attachment ID being processed. + * @param string $feature Feature ID the job was scheduled for. + * @param string $type Run type passed through to `run()`. + * @param int $calling_user_id User who triggered the upload, for context. + */ + public function handle_async_image_job( $attachment_id = 0, $feature = '', $type = '', $calling_user_id = 0 ) { + // Ignore jobs scheduled for a different feature (the hook is shared). + if ( static::ID !== $feature ) { + return; + } + + $attachment_id = (int) $attachment_id; + + // Attachment was deleted before the job ran. + if ( ! $attachment_id || 'attachment' !== get_post_type( $attachment_id ) ) { + return; + } + + // Restore the uploading user's context before any access checks, since + // Action Scheduler runs jobs without a logged-in user. + $original_user_id = get_current_user_id(); + + if ( $calling_user_id ) { + wp_set_current_user( (int) $calling_user_id ); + } + + // Feature was disabled between enqueue and run. + if ( ! $this->is_feature_enabled() ) { + $this->clear_async_status( $attachment_id ); + + if ( $calling_user_id ) { + wp_set_current_user( $original_user_id ); + } + + return; + } + + $this->set_async_status( + $attachment_id, + array( + 'status' => 'running', + 'type' => $type, + ) + ); + + // Smart cropping needs the current attachment metadata and persists an + // updated copy; the other features only read the attachment. + if ( 'crop' === $type ) { + $result = $this->run( $attachment_id, $type, wp_get_attachment_metadata( $attachment_id ) ); + } else { + $result = $this->run( $attachment_id, $type ); + } + + if ( ! is_wp_error( $result ) && ! empty( $result ) ) { + if ( 'crop' === $type ) { + $meta = $this->save( $result, $attachment_id ); + wp_update_attachment_metadata( $attachment_id, $meta ); + } else { + $this->save( $result, $attachment_id ); + } + + $this->clear_async_status( $attachment_id ); + } else { + $this->set_async_status( + $attachment_id, + array( + 'status' => 'error', + 'type' => $type, + 'message' => is_wp_error( $result ) + ? $result->get_error_message() + : __( 'No result was returned.', 'classifai' ), + ) + ); + } + + if ( $calling_user_id ) { + wp_set_current_user( $original_user_id ); + } + } + + /** + * Store the async processing status for this feature on an attachment. + * + * Status is kept in a single object meta keyed by Feature ID so several + * image features can report progress on the same attachment at once. + * + * @param int $attachment_id Attachment ID. + * @param array $data Status data (`status`, `type`, optional `message`). + */ + public function set_async_status( int $attachment_id, array $data ) { + $statuses = get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ); + + if ( ! is_array( $statuses ) ) { + $statuses = array(); + } + + $statuses[ static::ID ] = $data; + + update_post_meta( $attachment_id, ImageProcessing::STATUS_META, $statuses ); + } + + /** + * Clear this feature's async processing status on an attachment. + * + * @param int $attachment_id Attachment ID. + */ + public function clear_async_status( int $attachment_id ) { + $statuses = get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ); + + if ( ! is_array( $statuses ) || ! isset( $statuses[ static::ID ] ) ) { + return; + } + + unset( $statuses[ static::ID ] ); + + if ( empty( $statuses ) ) { + delete_post_meta( $attachment_id, ImageProcessing::STATUS_META ); + } else { + update_post_meta( $attachment_id, ImageProcessing::STATUS_META, $statuses ); + } + } + + /** + * Register the async status meta for attachments. + * + * Exposed in the REST API for parity with other async features (the media + * modal itself polls a dedicated AJAX endpoint). + */ + public function register_async_status_meta() { + register_meta( + 'post', + ImageProcessing::STATUS_META, + array( + 'object_subtype' => 'attachment', + 'type' => 'object', + 'single' => true, + 'auth_callback' => '__return_true', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'status' => array( 'type' => 'string' ), + 'type' => array( 'type' => 'string' ), + 'message' => array( 'type' => 'string' ), + ), + ), + ), + ), + ) + ); + } + + /** + * Sanitize the processing mode setting against the allowed values. + * + * @param mixed $value Submitted value. + * @param string $current Current stored value to fall back to. + * @return string + */ + public function sanitize_processing_mode( $value, string $current ): string { + $value = sanitize_text_field( $value ?? $current ); + + if ( ! in_array( $value, array( 'automatic', 'automatic_async', 'manual' ), true ) ) { + return $current; + } + + return $value; + } +} diff --git a/includes/Classifai/Features/DescriptiveTextGenerator.php b/includes/Classifai/Features/DescriptiveTextGenerator.php index ff8ac1d1e..84d42842d 100644 --- a/includes/Classifai/Features/DescriptiveTextGenerator.php +++ b/includes/Classifai/Features/DescriptiveTextGenerator.php @@ -21,6 +21,8 @@ * Class DescriptiveTextGenerator */ class DescriptiveTextGenerator extends Feature { + use AsyncImageProcessing; + /** * ID of the current feature. * @@ -54,6 +56,8 @@ public function __construct() { public function setup() { parent::setup(); add_action( 'rest_api_init', array( $this, 'register_endpoints' ) ); + add_action( ImageProcessing::ASYNC_JOB_HOOK, array( $this, 'handle_async_image_job' ), 10, 4 ); + $this->register_async_status_meta(); } /** @@ -147,10 +151,13 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { * @return array */ public function generate_image_alt_tags( array $metadata, int $attachment_id ): array { - if ( - ! $this->is_feature_enabled() || - 'automatic' !== $this->get_processing_mode() - ) { + $mode = $this->get_processing_mode(); + + if ( ! $this->is_feature_enabled() || 'manual' === $mode ) { + return $metadata; + } + + if ( 'automatic_async' === $mode && $this->enqueue_async_image_job( $attachment_id, 'descriptive_text' ) ) { return $metadata; } @@ -459,7 +466,7 @@ public function sanitize_default_feature_settings( array $new_settings ): array $new_settings['descriptive_text_fields'] = array_map( 'sanitize_text_field', $new_settings['descriptive_text_fields'] ?? $settings['descriptive_text_fields'] ); - $new_settings['processing_mode'] = sanitize_text_field( $new_settings['processing_mode'] ?? $settings['processing_mode'] ); + $new_settings['processing_mode'] = $this->sanitize_processing_mode( $new_settings['processing_mode'] ?? null, $settings['processing_mode'] ); return $new_settings; } diff --git a/includes/Classifai/Features/ImageCropping.php b/includes/Classifai/Features/ImageCropping.php index 9c65518f5..754bd8d33 100644 --- a/includes/Classifai/Features/ImageCropping.php +++ b/includes/Classifai/Features/ImageCropping.php @@ -18,6 +18,8 @@ * Class ImageCropping */ class ImageCropping extends Feature { + use AsyncImageProcessing; + /** * ID of the current feature. * @@ -57,6 +59,8 @@ public function __construct() { public function setup() { parent::setup(); add_action( 'rest_api_init', array( $this, 'register_endpoints' ) ); + add_action( ImageProcessing::ASYNC_JOB_HOOK, array( $this, 'handle_async_image_job' ), 10, 4 ); + $this->register_async_status_meta(); } /** @@ -152,10 +156,13 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { * @return array */ public function generate_smart_crops( array $metadata, int $attachment_id ): array { - if ( - ! $this->is_feature_enabled() || - 'automatic' !== $this->get_processing_mode() - ) { + $mode = $this->get_processing_mode(); + + if ( ! $this->is_feature_enabled() || 'manual' === $mode ) { + return $metadata; + } + + if ( 'automatic_async' === $mode && $this->enqueue_async_image_job( $attachment_id, 'crop' ) ) { return $metadata; } @@ -391,7 +398,7 @@ public function get_feature_default_settings(): array { public function sanitize_default_feature_settings( array $new_settings ): array { $settings = $this->get_settings(); - $new_settings['processing_mode'] = sanitize_text_field( $new_settings['processing_mode'] ?? $settings['processing_mode'] ); + $new_settings['processing_mode'] = $this->sanitize_processing_mode( $new_settings['processing_mode'] ?? null, $settings['processing_mode'] ); return $new_settings; } diff --git a/includes/Classifai/Features/ImageTagsGenerator.php b/includes/Classifai/Features/ImageTagsGenerator.php index 190ae511c..c05901461 100644 --- a/includes/Classifai/Features/ImageTagsGenerator.php +++ b/includes/Classifai/Features/ImageTagsGenerator.php @@ -21,6 +21,8 @@ * Class ImageTagsGenerator */ class ImageTagsGenerator extends Feature { + use AsyncImageProcessing; + /** * ID of the current feature. * @@ -53,6 +55,8 @@ public function __construct() { public function setup() { parent::setup(); add_action( 'rest_api_init', array( $this, 'register_endpoints' ) ); + add_action( ImageProcessing::ASYNC_JOB_HOOK, array( $this, 'handle_async_image_job' ), 10, 4 ); + $this->register_async_status_meta(); } /** @@ -188,10 +192,13 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { * @return array */ public function generate_image_tags( array $metadata, int $attachment_id ): array { - if ( - ! $this->is_feature_enabled() || - 'automatic' !== $this->get_processing_mode() - ) { + $mode = $this->get_processing_mode(); + + if ( ! $this->is_feature_enabled() || 'manual' === $mode ) { + return $metadata; + } + + if ( 'automatic_async' === $mode && $this->enqueue_async_image_job( $attachment_id, 'tags' ) ) { return $metadata; } @@ -395,7 +402,7 @@ public function sanitize_default_feature_settings( array $new_settings ): array $new_settings['tag_taxonomy'] = $new_settings['tag_taxonomy'] ?? $settings['tag_taxonomy']; - $new_settings['processing_mode'] = sanitize_text_field( $new_settings['processing_mode'] ?? $settings['processing_mode'] ); + $new_settings['processing_mode'] = $this->sanitize_processing_mode( $new_settings['processing_mode'] ?? null, $settings['processing_mode'] ); return $new_settings; } diff --git a/includes/Classifai/Features/ImageTextExtraction.php b/includes/Classifai/Features/ImageTextExtraction.php index 6ac848a4c..d738d472c 100644 --- a/includes/Classifai/Features/ImageTextExtraction.php +++ b/includes/Classifai/Features/ImageTextExtraction.php @@ -22,6 +22,8 @@ * Class ImageTextExtraction */ class ImageTextExtraction extends Feature { + use AsyncImageProcessing; + /** * ID of the current feature. * @@ -54,6 +56,8 @@ public function __construct() { public function setup() { parent::setup(); add_action( 'rest_api_init', array( $this, 'register_endpoints' ) ); + add_action( ImageProcessing::ASYNC_JOB_HOOK, array( $this, 'handle_async_image_job' ), 10, 4 ); + $this->register_async_status_meta(); } /** @@ -181,10 +185,13 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { * @return array */ public function generate_ocr_text( array $metadata, int $attachment_id ): array { - if ( - ! $this->is_feature_enabled() || - 'automatic' !== $this->get_processing_mode() - ) { + $mode = $this->get_processing_mode(); + + if ( ! $this->is_feature_enabled() || 'manual' === $mode ) { + return $metadata; + } + + if ( 'automatic_async' === $mode && $this->enqueue_async_image_job( $attachment_id, 'ocr' ) ) { return $metadata; } @@ -460,7 +467,7 @@ public function get_feature_default_settings(): array { public function sanitize_default_feature_settings( array $new_settings ): array { $settings = $this->get_settings(); - $new_settings['processing_mode'] = sanitize_text_field( $new_settings['processing_mode'] ?? $settings['processing_mode'] ); + $new_settings['processing_mode'] = $this->sanitize_processing_mode( $new_settings['processing_mode'] ?? null, $settings['processing_mode'] ); return $new_settings; } diff --git a/includes/Classifai/Plugin.php b/includes/Classifai/Plugin.php index 2beb71ed6..3368b63bd 100644 --- a/includes/Classifai/Plugin.php +++ b/includes/Classifai/Plugin.php @@ -317,6 +317,10 @@ public function load_action_scheduler() { new \Classifai\Features\RecommendedContent(), new \Classifai\Features\TextToSpeech(), new \Classifai\Features\APIUsageTracking(), + new \Classifai\Features\DescriptiveTextGenerator(), + new \Classifai\Features\ImageTagsGenerator(), + new \Classifai\Features\ImageTextExtraction(), + new \Classifai\Features\ImageCropping(), ); $is_feature_being_enabled = false; @@ -326,6 +330,24 @@ public function load_action_scheduler() { continue; } + // Image Processing features only need Action Scheduler when set to + // process asynchronously, and may use any of several providers, so + // gate them on their processing mode rather than the provider ID. + if ( method_exists( $feature, 'get_processing_mode' ) ) { + $option_name = $feature->get_option_name(); + $async_being_enabled = isset( $_POST[ $option_name ]['processing_mode'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing + && 'automatic_async' === sanitize_text_field( wp_unslash( $_POST[ $option_name ]['processing_mode'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + + if ( + ( 'automatic_async' === $feature->get_processing_mode() && $feature->is_enabled() ) || + $async_being_enabled + ) { + require_once CLASSIFAI_PLUGIN_DIR . '/vendor/woocommerce/action-scheduler/action-scheduler.php'; + } + + continue; + } + // Check if the Feature is using a Provider that needs Action Scheduler. switch ( $feature->get_feature_provider_instance()::ID ) { case 'openai_embeddings': From 14bdc7a4e067dc800b53b37ae8ca6872132ee5d7 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 18 Jun 2026 11:03:14 -0600 Subject: [PATCH 2/5] Add the ajax server handling --- .../Classifai/Services/ImageProcessing.php | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/includes/Classifai/Services/ImageProcessing.php b/includes/Classifai/Services/ImageProcessing.php index de07e8144..4ea0201f4 100644 --- a/includes/Classifai/Services/ImageProcessing.php +++ b/includes/Classifai/Services/ImageProcessing.php @@ -6,6 +6,9 @@ namespace Classifai\Services; use Classifai\Features\DescriptiveTextGenerator; +use Classifai\Features\ImageTagsGenerator; +use Classifai\Features\ImageTextExtraction; +use Classifai\Features\ImageCropping; use Classifai\Taxonomy\ImageTagTaxonomy; use function Classifai\get_asset_info; @@ -16,6 +19,32 @@ class ImageProcessing extends Service { + /** + * Action Scheduler hook shared by the async image processing jobs. + * + * @var string + */ + const ASYNC_JOB_HOOK = 'classifai_schedule_image_process_job'; + + /** + * Attachment meta key holding async processing status, keyed by Feature ID. + * + * @var string + */ + const STATUS_META = '_classifai_image_process_status'; + + /** + * Map of Feature ID => run type for the async-capable image features. + * + * @var array + */ + const ASYNC_FEATURES = array( + DescriptiveTextGenerator::ID => 'descriptive_text', + ImageTagsGenerator::ID => 'tags', + ImageTextExtraction::ID => 'ocr', + ImageCropping::ID => 'crop', + ); + /** * ImageProcessing constructor. */ @@ -37,6 +66,68 @@ public function init() { add_filter( 'attachment_fields_to_edit', array( $this, 'custom_fields_edit' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_media_scripts' ) ); + add_action( 'wp_ajax_classifai_get_image_process_status', array( $this, 'get_image_process_status_ajax' ) ); + } + + /** + * AJAX callback returning the async processing status for an attachment. + * + * Powers the media-modal polling that updates fields without a page reload. + * Returns, per run type, the current status plus the canonical saved values + * so the UI can populate fields when a job completes. + */ + public function get_image_process_status_ajax() { + if ( ! wp_doing_ajax() ) { + return; + } + + if ( ! check_ajax_referer( 'classifai', 'nonce', false ) ) { + wp_send_json_error( new \WP_Error( 'classifai_nonce_error', __( 'Nonce could not be verified.', 'classifai' ) ) ); + } + + $attachment_id = (int) filter_input( INPUT_POST, 'attachment_id', FILTER_SANITIZE_NUMBER_INT ); + + if ( empty( $attachment_id ) || 'attachment' !== get_post_type( $attachment_id ) ) { + wp_send_json_error( new \WP_Error( 'invalid_post', __( 'Invalid attachment ID.', 'classifai' ) ) ); + } + + if ( ! current_user_can( 'edit_post', $attachment_id ) ) { + wp_send_json_error( new \WP_Error( 'unauthorized_access', __( 'Unauthorized access.', 'classifai' ) ) ); + } + + $statuses = get_post_meta( $attachment_id, self::STATUS_META, true ); + $statuses = is_array( $statuses ) ? $statuses : array(); + $response = array(); + + foreach ( self::ASYNC_FEATURES as $feature_id => $type ) { + $entry = $statuses[ $feature_id ] ?? array(); + $data = array( + 'status' => $entry['status'] ?? 'done', + ); + + if ( ! empty( $entry['message'] ) ) { + $data['message'] = $entry['message']; + } + + // Surface the current saved values so the UI can update on completion. + switch ( $type ) { + case 'descriptive_text': + $data['fields'] = array( + 'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), + 'caption' => get_the_excerpt( $attachment_id ), + 'description' => get_the_content( null, false, $attachment_id ), + ); + break; + + case 'ocr': + $data['description'] = get_the_content( null, false, $attachment_id ); + break; + } + + $response[ $type ] = $data; + } + + wp_send_json_success( $response ); } /** @@ -90,6 +181,8 @@ public function enqueue_media_scripts() { 'const classifaiMediaVars = ' . wp_json_encode( array( 'enabledAltTextFields' => $feature->get_alt_text_settings() ? $feature->get_alt_text_settings() : array(), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'classifai' ), ) ), 'before' From 041d4d90a14119ccc2c96187543e15be39da1b1a Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 18 Jun 2026 11:04:21 -0600 Subject: [PATCH 3/5] Add the async mode settings --- .../descriptive-text-generator.js | 9 ++++++++- .../feature-additional-settings/image-cropping.js | 9 ++++++++- .../feature-additional-settings/image-tag-generator.js | 9 ++++++++- .../image-to-text-generator.js | 9 ++++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/js/settings/components/feature-additional-settings/descriptive-text-generator.js b/src/js/settings/components/feature-additional-settings/descriptive-text-generator.js index 5d055f53a..ae9ab24f0 100644 --- a/src/js/settings/components/feature-additional-settings/descriptive-text-generator.js +++ b/src/js/settings/components/feature-additional-settings/descriptive-text-generator.js @@ -65,7 +65,7 @@ export const DescriptiveTextGeneratorSettings = () => { @@ -81,6 +81,13 @@ export const DescriptiveTextGeneratorSettings = () => { label: __( 'Automatically on upload', 'classifai' ), value: 'automatic', }, + { + label: __( + 'Automatically on upload (background)', + 'classifai' + ), + value: 'automatic_async', + }, { label: __( 'Manually trigger', 'classifai' ), value: 'manual', diff --git a/src/js/settings/components/feature-additional-settings/image-cropping.js b/src/js/settings/components/feature-additional-settings/image-cropping.js index 345f6a3d7..ecfc97c85 100644 --- a/src/js/settings/components/feature-additional-settings/image-cropping.js +++ b/src/js/settings/components/feature-additional-settings/image-cropping.js @@ -26,7 +26,7 @@ export const ImageCroppingSettings = () => { @@ -42,6 +42,13 @@ export const ImageCroppingSettings = () => { label: __( 'Automatically on upload', 'classifai' ), value: 'automatic', }, + { + label: __( + 'Automatically on upload (background)', + 'classifai' + ), + value: 'automatic_async', + }, { label: __( 'Manually trigger', 'classifai' ), value: 'manual', diff --git a/src/js/settings/components/feature-additional-settings/image-tag-generator.js b/src/js/settings/components/feature-additional-settings/image-tag-generator.js index dfde81864..a412ce2d6 100644 --- a/src/js/settings/components/feature-additional-settings/image-tag-generator.js +++ b/src/js/settings/components/feature-additional-settings/image-tag-generator.js @@ -56,7 +56,7 @@ export const ImageTagGeneratorSettings = () => { @@ -72,6 +72,13 @@ export const ImageTagGeneratorSettings = () => { label: __( 'Automatically on upload', 'classifai' ), value: 'automatic', }, + { + label: __( + 'Automatically on upload (background)', + 'classifai' + ), + value: 'automatic_async', + }, { label: __( 'Manually trigger', 'classifai' ), value: 'manual', diff --git a/src/js/settings/components/feature-additional-settings/image-to-text-generator.js b/src/js/settings/components/feature-additional-settings/image-to-text-generator.js index 9ffa686c1..a3077ce91 100644 --- a/src/js/settings/components/feature-additional-settings/image-to-text-generator.js +++ b/src/js/settings/components/feature-additional-settings/image-to-text-generator.js @@ -26,7 +26,7 @@ export const ImageToTextGeneratorSettings = () => { @@ -42,6 +42,13 @@ export const ImageToTextGeneratorSettings = () => { label: __( 'Automatically on upload', 'classifai' ), value: 'automatic', }, + { + label: __( + 'Automatically on upload (background)', + 'classifai' + ), + value: 'automatic_async', + }, { label: __( 'Manually trigger', 'classifai' ), value: 'manual', From 5eab494064165702c0946bf7b9d438f96532141b Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 18 Jun 2026 11:05:30 -0600 Subject: [PATCH 4/5] Add polling to check for media processing and update the UI when done --- .../features/media-processing/media-upload.js | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/src/js/features/media-processing/media-upload.js b/src/js/features/media-processing/media-upload.js index d76515d4d..bc4e24982 100644 --- a/src/js/features/media-processing/media-upload.js +++ b/src/js/features/media-processing/media-upload.js @@ -200,22 +200,223 @@ import { __ } from '@wordpress/i18n'; } ); }; + /** + * Map of async run type to its media-modal rescan button ID. + */ + const imageProcessButtons = { + descriptive_text: 'classifai-rescan-alt-tags', + tags: 'classifai-rescan-image-tags', + ocr: 'classifai-rescan-ocr', + crop: 'classifai-rescan-smart-crop', + }; + + let imageProcessInterval = null; + const imageProcessing = new Set(); + + /** + * Set the value of the first matching attachment-details field. + * + * @param {string[]} ids Candidate element IDs (two-column and single-column). + * @param {string} value Value to set. + */ + const setAttachmentField = ( ids, value ) => { + if ( ! value ) { + return; + } + + const field = ids + .map( ( id ) => document.getElementById( id ) ) + .find( Boolean ); + + if ( field ) { + field.value = value; + } + }; + + /** + * Populate the media-modal fields once an async job completes. + * + * @param {string} type Run type that finished. + * @param {Object} info Status payload for that type. + */ + const populateImageFields = ( type, info ) => { + if ( type === 'descriptive_text' && info.fields ) { + const { enabledAltTextFields = [] } = classifaiMediaVars; + + if ( enabledAltTextFields.includes( 'alt' ) ) { + setAttachmentField( + [ + 'attachment-details-two-column-alt-text', + 'attachment-details-alt-text', + ], + info.fields.alt + ); + } + + if ( enabledAltTextFields.includes( 'caption' ) ) { + setAttachmentField( + [ + 'attachment-details-two-column-caption', + 'attachment-details-caption', + ], + info.fields.caption + ); + } + + if ( enabledAltTextFields.includes( 'description' ) ) { + setAttachmentField( + [ + 'attachment-details-two-column-description', + 'attachment-details-description', + ], + info.fields.description + ); + } + } else if ( type === 'ocr' ) { + setAttachmentField( + [ + 'attachment-details-two-column-description', + 'attachment-details-description', + ], + info.description + ); + } + }; + + /** + * Poll the async processing status and reflect it in the media modal, + * updating fields when a job finishes without requiring a page reload. + */ + const checkImageProcessStatus = () => { + let postId = null; + + Object.values( imageProcessButtons ).some( ( id ) => { + const button = document.getElementById( id ); + + if ( button ) { + postId = button.getAttribute( 'data-id' ); + return true; + } + + return false; + } ); + + if ( ! postId ) { + return; + } + + const processingLabel = __( 'ClassifAI is processing…', 'classifai' ); + + $.ajax( { + url: classifaiMediaVars.ajaxUrl || ajaxurl, + type: 'POST', + data: { + action: 'classifai_get_image_process_status', + attachment_id: postId, + nonce: classifaiMediaVars.nonce || ClassifAI.ajax_nonce, + }, + success: ( resp ) => { + if ( ! resp?.success || ! resp?.data ) { + return; + } + + let anyProcessing = false; + + Object.keys( imageProcessButtons ).forEach( ( type ) => { + const button = document.getElementById( + imageProcessButtons[ type ] + ); + + if ( ! button ) { + return; + } + + const info = resp.data[ type ] || {}; + const status = info.status; + const [ spinner ] = + button.parentNode.getElementsByClassName( 'spinner' ); + + if ( status === 'scheduled' || status === 'running' ) { + anyProcessing = true; + imageProcessing.add( type ); + button.setAttribute( 'disabled', 'disabled' ); + button.textContent = processingLabel; + + if ( spinner ) { + spinner.style.display = 'inline-block'; + spinner.classList.add( 'is-active' ); + } + } else { + // No longer processing: surface the result if tracked. + if ( imageProcessing.has( type ) ) { + imageProcessing.delete( type ); + + if ( status === 'error' ) { + const [ errorContainer ] = + button.parentNode.getElementsByClassName( + 'error' + ); + + if ( errorContainer ) { + errorContainer.style.display = + 'inline-block'; + errorContainer.textContent = + info.message || + __( 'Processing failed.', 'classifai' ); + } + } else { + populateImageFields( type, info ); + } + } + + button.removeAttribute( 'disabled' ); + + if ( spinner ) { + spinner.style.display = 'none'; + spinner.classList.remove( 'is-active' ); + } + + if ( button.textContent === processingLabel ) { + button.textContent = __( 'Rescan', 'classifai' ); + } + } + } ); + + if ( anyProcessing && ! imageProcessInterval ) { + imageProcessInterval = setInterval( + checkImageProcessStatus, + 5000 + ); + } else if ( ! anyProcessing && imageProcessInterval ) { + clearInterval( imageProcessInterval ); + imageProcessInterval = null; + } + }, + } ); + }; + $( document ).ready( function () { if ( wp.media ) { wp.media.view.Modal.prototype.on( 'open', function () { wp.media.frame.on( 'selection:toggle', handleButtonsClick ); wp.media.frame.on( 'selection:toggle', checkPdfReadStatus ); + wp.media.frame.on( + 'selection:toggle', + checkImageProcessStatus + ); } ); } if ( wp.media.frame ) { wp.media.frame.on( 'edit:attachment', handleButtonsClick ); wp.media.frame.on( 'edit:attachment', checkPdfReadStatus ); + wp.media.frame.on( 'edit:attachment', checkImageProcessStatus ); } // For new uploaded media. if ( wp.Uploader && wp.Uploader.queue ) { wp.Uploader.queue.on( 'reset', handleButtonsClick ); + wp.Uploader.queue.on( 'reset', checkImageProcessStatus ); } } ); } )( jQuery ); From d54d78ff11b2b500a8d663c3e5485c741c3f1527 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 18 Jun 2026 11:39:28 -0600 Subject: [PATCH 5/5] Add tests --- .../features/media-processing/media-upload.js | 2 +- .../Features/AsyncImageProcessingTest.php | 235 ++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Features/AsyncImageProcessingTest.php diff --git a/src/js/features/media-processing/media-upload.js b/src/js/features/media-processing/media-upload.js index bc4e24982..2ad6e2c02 100644 --- a/src/js/features/media-processing/media-upload.js +++ b/src/js/features/media-processing/media-upload.js @@ -305,7 +305,7 @@ import { __ } from '@wordpress/i18n'; return; } - const processingLabel = __( 'ClassifAI is processing…', 'classifai' ); + const processingLabel = __( 'Processing…', 'classifai' ); $.ajax( { url: classifaiMediaVars.ajaxUrl || ajaxurl, diff --git a/tests/Integration/Features/AsyncImageProcessingTest.php b/tests/Integration/Features/AsyncImageProcessingTest.php new file mode 100644 index 000000000..ecfbadfa3 --- /dev/null +++ b/tests/Integration/Features/AsyncImageProcessingTest.php @@ -0,0 +1,235 @@ + '1', + 'provider' => 'ms_computer_vision', + 'ms_computer_vision' => [ 'authenticated' => true ], + 'roles' => [ 'administrator' => 'administrator' ], + 'descriptive_text_fields' => [ + 'alt' => 'alt', + 'caption' => 0, + 'description' => 0, + ], + 'processing_mode' => $processing_mode, + ] + ); + } + + /** + * Short-circuit the provider call so no HTTP request is made. + * + * @param mixed $response Value to return from run(). + */ + private function mock_run( $response ) { + add_filter( + 'classifai_pre_fetch_feature_response', + static function () use ( $response ) { + return $response; + } + ); + } + + /** + * Create an image attachment for testing. + * + * @return int + */ + private function create_image(): int { + return self::factory()->attachment->create_upload_object( + DIR_TESTDATA . '/images/canola.jpg' + ); + } + + /** + * @covers ::sanitize_processing_mode + */ + public function test_sanitize_processing_mode_allowlist() { + $feature = new DescriptiveTextGenerator(); + + $this->assertSame( 'automatic', $feature->sanitize_processing_mode( 'automatic', 'manual' ) ); + $this->assertSame( 'automatic_async', $feature->sanitize_processing_mode( 'automatic_async', 'manual' ) ); + $this->assertSame( 'manual', $feature->sanitize_processing_mode( 'manual', 'automatic' ) ); + + // Garbage falls back to the current value. + $this->assertSame( 'automatic', $feature->sanitize_processing_mode( 'nonsense', 'automatic' ) ); + $this->assertSame( 'manual', $feature->sanitize_processing_mode( null, 'manual' ) ); + } + + /** + * @covers ::set_async_status + * @covers ::clear_async_status + */ + public function test_status_meta_is_keyed_by_feature() { + $feature = new DescriptiveTextGenerator(); + $attachment_id = self::factory()->post->create( [ 'post_type' => 'attachment' ] ); + + $feature->set_async_status( $attachment_id, [ 'status' => 'scheduled', 'type' => 'descriptive_text' ] ); + + $statuses = get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ); + $this->assertSame( 'scheduled', $statuses[ DescriptiveTextGenerator::ID ]['status'] ); + + $feature->clear_async_status( $attachment_id ); + $this->assertSame( '', get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ) ); + } + + /** + * Jobs scheduled for another feature are ignored by the shared handler. + * + * @covers ::handle_async_image_job + */ + public function test_handle_job_ignores_other_feature() { + $attachment_id = self::factory()->post->create( [ 'post_type' => 'attachment' ] ); + + ( new DescriptiveTextGenerator() )->handle_async_image_job( $attachment_id, 'feature_some_other', 'descriptive_text', 0 ); + + $this->assertSame( '', get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ); + $this->assertSame( '', get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ) ); + } + + /** + * A deleted attachment is a no-op. + * + * @covers ::handle_async_image_job + */ + public function test_handle_job_skips_missing_attachment() { + $this->mock_run( self::ALT_TEXT ); + + // 999999 does not exist. + ( new DescriptiveTextGenerator() )->handle_async_image_job( 999999, DescriptiveTextGenerator::ID, 'descriptive_text', 0 ); + + $this->assertSame( '', get_post_meta( 999999, '_wp_attachment_image_alt', true ) ); + } + + /** + * The handler runs the feature, saves the result and clears the status. + * + * @covers ::handle_async_image_job + */ + public function test_handle_job_saves_result_and_clears_status() { + $this->as_user_with_role( 'administrator' ); + $this->enable_feature(); + $this->mock_run( self::ALT_TEXT ); + + $attachment_id = $this->create_image(); + ( new DescriptiveTextGenerator() )->set_async_status( $attachment_id, [ 'status' => 'scheduled' ] ); + + ( new DescriptiveTextGenerator() )->handle_async_image_job( $attachment_id, DescriptiveTextGenerator::ID, 'descriptive_text', get_current_user_id() ); + + $this->assertSame( self::ALT_TEXT, get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ); + $this->assertSame( '', get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ), 'Status is cleared on success.' ); + } + + /** + * If the feature was disabled between enqueue and run, the status is + * cleared and nothing is saved. + * + * @covers ::handle_async_image_job + */ + public function test_handle_job_clears_status_when_feature_disabled() { + $this->as_user_with_role( 'administrator' ); + // Feature option left unset => disabled. + $this->mock_run( self::ALT_TEXT ); + + $attachment_id = self::factory()->post->create( [ 'post_type' => 'attachment' ] ); + ( new DescriptiveTextGenerator() )->set_async_status( $attachment_id, [ 'status' => 'scheduled' ] ); + + ( new DescriptiveTextGenerator() )->handle_async_image_job( $attachment_id, DescriptiveTextGenerator::ID, 'descriptive_text', get_current_user_id() ); + + $this->assertSame( '', get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ); + $this->assertSame( '', get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ) ); + } + + /** + * A failed run records an error status. + * + * @covers ::handle_async_image_job + */ + public function test_handle_job_records_error_status() { + $this->as_user_with_role( 'administrator' ); + $this->enable_feature(); + $this->mock_run( new WP_Error( 'boom', 'Provider failed.' ) ); + + $attachment_id = $this->create_image(); + + ( new DescriptiveTextGenerator() )->handle_async_image_job( $attachment_id, DescriptiveTextGenerator::ID, 'descriptive_text', get_current_user_id() ); + + $statuses = get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ); + $this->assertSame( 'error', $statuses[ DescriptiveTextGenerator::ID ]['status'] ); + $this->assertSame( 'Provider failed.', $statuses[ DescriptiveTextGenerator::ID ]['message'] ); + $this->assertSame( '', get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ); + } + + /** + * Manual mode performs no work on upload. + * + * @covers ::generate_image_alt_tags + */ + public function test_manual_mode_does_not_process_on_upload() { + $this->as_user_with_role( 'administrator' ); + $this->enable_feature( 'manual' ); + $this->mock_run( self::ALT_TEXT ); + + $attachment_id = $this->create_image(); + ( new DescriptiveTextGenerator() )->generate_image_alt_tags( [], $attachment_id ); + + $this->assertSame( '', get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ); + $this->assertSame( '', get_post_meta( $attachment_id, ImageProcessing::STATUS_META, true ) ); + } + + /** + * When Action Scheduler is unavailable, async mode falls back to processing + * synchronously so behavior is never silently dropped. + * + * @covers ::generate_image_alt_tags + * @covers ::enqueue_async_image_job + */ + public function test_async_mode_falls_back_to_sync_without_action_scheduler() { + if ( function_exists( 'as_enqueue_async_action' ) ) { + $this->markTestSkipped( 'Action Scheduler is loaded; fallback path not exercised.' ); + } + + $this->as_user_with_role( 'administrator' ); + $this->enable_feature( 'automatic_async' ); + $this->mock_run( self::ALT_TEXT ); + + $attachment_id = $this->create_image(); + ( new DescriptiveTextGenerator() )->generate_image_alt_tags( [], $attachment_id ); + + $this->assertSame( self::ALT_TEXT, get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ); + } +}