diff --git a/README.md b/README.md index 499fd9d..d4d9566 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,15 @@ We don’t charge. We don’t sit in the middle. You use **your** own key. Every - **Auto-generate on upload** — New images get alt text as soon as they hit the Media Library. - **AI image titles** — Generate descriptive WordPress image titles too, not just alt text — automatically on upload, in bulk, or one at a time. -- **Bulk generate** — Fix hundreds of images without alt text (or still named like their raw filename) in one go. +- **Bulk generate in the background** — Queue hundreds or thousands of images and close the tab; EveryAlt keeps working, retries failures, and uploads never wait for the AI. - **Review & edit** — See everything that already has alt text or a title; edit or regenerate anytime. - **In the block editor** — Generate or regenerate alt text right from the Image block. - **On the media screen** — Button next to the alt field for single images. - **Logs & export** — See what ran, what cost what, and export as CSV. +- **Fixes older posts too** — Images already in posts pick up their new alt text automatically. +- **Decorative images** — Purely decorative images (dividers, spacers, patterns) get empty alt text so screen readers skip them, as WCAG recommends. +- **Spending limits** — See estimated spend per month and per model, and set a monthly cap. +- **Your language** — Alt text and titles are written in your site's language (Polylang and WPML supported). Works on localhost, behind HTTP auth, and with your existing workflow. @@ -109,6 +113,10 @@ Gemini has a free tier, but Google may use free-tier content to improve its prod ### For developers - `everyalt_model` filters the model ID sent to the provider: `( string $model_id, string $model_slug )`. This replaces `everyalt_openai_model`. +- `everyalt_output_locale` changes the language text is written in: `( string $locale, int $attachment_id )`. +- `everyalt_vision_prompt` / `everyalt_title_prompt` now also receive the attachment ID, and the prompt they receive already includes the language instruction. +- Queue REST routes (admins only): `GET/POST/DELETE everyalt-api/v1/queue` (status / add `{type, media_ids | all}` / clear) and `POST everyalt-api/v1/queue/process`. +- `bulk_generate_alt` accepts `describe: true` to skip decorative detection, and returns `decorative`. - `everyalt_input_token_price_per_million` / `everyalt_output_token_price_per_million` override the prices used for cost estimates: `( float $price, string $model_slug )`. --- @@ -142,6 +150,11 @@ The plugin uses the `everyalt` text domain and ships with a `.pot` in `languages ## 📝 Changelog ### Unreleased +- **New: alt text shows up in existing posts.** WordPress copies an image's alt text into a post when the image is inserted, so alt text generated later never reached older posts. EveryAlt now fills in empty alt text in post content from the Media Library as pages are displayed (WordPress 6.0+). Posts aren't modified, and alt text written in a post is never replaced. On by default; turn off under **Settings → Existing posts**. +- **New: background processing.** Bulk generation and new uploads now run in a background queue: close the tab and it keeps going, failures retry automatically (after 1, 5, and 30 minutes), and uploads finish immediately. The queue runs on WP-Cron and also whenever an EveryAlt page is open, so it works on sites where WP-Cron is blocked (e.g. behind HTTP auth). Bulk tabs gain a **Generate for all** button. Prefer the old behavior for uploads? Choose **Settings → New uploads → Generate during the upload**. +- **New: decorative image detection.** When the AI judges an image clearly decorative, its alt text is left empty on purpose (per WCAG) and it's flagged on the Review Alt Text tab with a **Describe anyway** button. When in doubt, the AI describes the image. Turn off under **Settings → Decorative images**. +- **New: spending controls.** The Logs tab shows estimated spend per month and per model. Set a **monthly spending limit** in Settings: when it's reached, generation pauses (queued images wait), admins see a notice, and it resumes next month or when the limit is raised. +- **New: alt text in your site's language.** Alt text and titles are now written in your site language, or each image's own language on multilingual sites using Polylang or WPML. Choose a specific language under **Settings → Language**. - **New: choose your AI model.** Pick from **GPT-5.4 nano** (OpenAI), **Gemini 3.1 Flash-Lite** (Google), or **DeepSeek V4.1 Flash** / **GLM-5.3-Flash** (DeepInfra) in Settings. Keys are stored per provider, so you can switch without re-entering them. Settings shows each model's published pricing, step-by-step key instructions, and privacy details, including DeepInfra's US/Canada hosting and zero data retention. - **Changed** OpenAI requests now use **GPT-5.4 nano**; `gpt-5-nano` is being retired. Existing installs switch automatically. - **Added** a Model column to the Logs tab and CSV export. diff --git a/admin/class-everyalt-admin.php b/admin/class-everyalt-admin.php index d5bb320..6c7b080 100644 --- a/admin/class-everyalt-admin.php +++ b/admin/class-everyalt-admin.php @@ -36,6 +36,57 @@ class Every_Alt_Admin { const GENERATION_LOG_OPTION = 'every_alt_generation_log'; const GENERATION_LOG_MAX = 100; + /** Attachment meta: set when the model judged the image purely decorative (alt left empty on purpose). */ + const DECORATIVE_META = '_everyalt_decorative'; + + /** Option: 'background' (default) or 'immediate' generation for new uploads. */ + const UPLOAD_MODE_OPTION = 'every_alt_upload_mode'; + + /** @var Every_Alt_Queue|null */ + private $queue; + + /** + * @param Every_Alt_Queue $queue + */ + public function set_queue( $queue ) { + $this->queue = $queue; + } + + /** + * @return string 'background' or 'immediate'. + */ + public static function upload_mode() { + return get_option( self::UPLOAD_MODE_OPTION, 'background' ) === 'immediate' ? 'immediate' : 'background'; + } + + /** + * Why the last generation call returned null: 'no_key', 'budget', 'invalid_image', 'api', or '' on success. + * The queue uses this to decide whether to retry, drop, or pause. + * + * @var string + */ + private $last_failure = ''; + + /** + * @return string See $last_failure. + */ + public function every_alt_last_failure() { + return $this->last_failure; + } + + /** + * Log/notice text used when the monthly spending limit stops generation. + * + * @return string + */ + public function every_alt_budget_reached_message() { + return sprintf( + /* translators: %s: monthly spending limit, e.g. $5.00 */ + __( 'Skipped: monthly spending limit of %s reached. Generation resumes next month, or raise the limit in Settings.', 'everyalt' ), + Every_Alt_Usage::format_usd( Every_Alt_Usage::budget() ) + ); + } + /** * Append an entry to the generation log (last 100 entries). * @@ -213,6 +264,26 @@ public function every_alt_key_decrypt_notice() { ) . '

'; } + /** + * Tell admins when the monthly spending limit has paused generation (auto-generate would otherwise stop silently). + */ + public function every_alt_budget_notice() { + if ( ! current_user_can( 'manage_options' ) || ! Every_Alt_Usage::budget_reached() ) { + return; + } + $url = admin_url( 'upload.php?page=everyalt&tab=settings' ); + echo '

' . wp_kses( + sprintf( + /* translators: 1: spent this month, 2: monthly limit, 3: settings URL */ + __( 'EveryAlt has paused: this month’s estimated spend (%1$s) reached your monthly limit of %2$s. New images will be processed next month, or you can raise the limit.', 'everyalt' ), + esc_html( Every_Alt_Usage::format_usd( Every_Alt_Usage::month_total() ) ), + esc_html( Every_Alt_Usage::format_usd( Every_Alt_Usage::budget() ) ), + esc_url( $url ) + ), + array( 'a' => array( 'href' => true ) ) + ) . '

'; + } + /** * AJAX: Validate a provider's API key (key from POST, or that provider's stored key if empty). */ @@ -347,7 +418,17 @@ public function enqueue_scripts() { 'restNonce' => wp_create_nonce( 'wp_rest' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'validateKeyNonce' => wp_create_nonce( 'everyalt_validate_key' ), + 'queue' => Every_Alt_Queue::status(), 'i18n' => array( + 'queueWorking' => __( 'Generating in the background: %d remaining. You can leave this page; it continues on its own.', 'everyalt' ), + 'queueWaiting' => __( '%d waiting to retry. They will be tried again automatically.', 'everyalt' ), + 'queueDone' => __( 'All done.', 'everyalt' ), + 'queuePausedBudget' => __( 'Paused: monthly spending limit reached. Raise the limit in Settings or wait until next month.', 'everyalt' ), + 'queuePausedKey' => __( 'Paused: add an API key for the selected model in Settings.', 'everyalt' ), + 'queueAdded' => __( 'Added %d images to the queue.', 'everyalt' ), + 'queueNothing' => __( 'Those images were already queued.', 'everyalt' ), + 'queueCleared' => __( 'Queue cleared.', 'everyalt' ), + 'decorative' => __( 'Marked as decorative (alt text left empty on purpose)', 'everyalt' ), 'error' => __( 'Error', 'everyalt' ), 'errorPrefix' => __( 'Error:', 'everyalt' ), 'requestFailed' => __( 'Request failed', 'everyalt' ), @@ -373,6 +454,7 @@ private function every_alt_button_i18n() { 'generating' => __( 'Generating…', 'everyalt' ), 'generated' => __( 'Alt text generated.', 'everyalt' ), 'failed' => __( 'Could not generate alt text.', 'everyalt' ), + 'decorative' => __( 'Marked as decorative: alt text left empty on purpose.', 'everyalt' ), 'errorPrefix' => __( 'Error:', 'everyalt' ), ); } @@ -457,13 +539,26 @@ public function every_alt_maybe_auto_on_generate_metadata( $metadata, $attachmen return $metadata; } - // Auto alt text: only when alt is currently empty. - if ( get_option( $this->option_name . '_auto' ) && get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) === '' ) { - $this->every_alt_auto_add_image_alt_text( $attachment_id, false, $metadata ); + $want_alt = get_option( $this->option_name . '_auto' ) && get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) === ''; + $want_title = get_option( $this->option_name . '_auto_title' ) && $this->every_alt_title_is_filename_like( $attachment_id ); + + // Background (default): the upload finishes right away and the queue generates shortly after. + // Posts still show the alt text once it exists, via Every_Alt_Public::fill_missing_alt(). + if ( self::upload_mode() === 'background' && $this->queue ) { + if ( $want_alt ) { + $this->queue->enqueue( array( $attachment_id ), 'alt' ); + } + if ( $want_title ) { + $this->queue->enqueue( array( $attachment_id ), 'title' ); + } + return $metadata; } - // Auto title: only when the current title still looks like the raw filename. - if ( get_option( $this->option_name . '_auto_title' ) && $this->every_alt_title_is_filename_like( $attachment_id ) ) { + // Immediate: the upload request waits for the model. + if ( $want_alt ) { + $this->every_alt_auto_add_image_alt_text( $attachment_id, false, $metadata ); + } + if ( $want_title ) { $this->every_alt_auto_add_image_title( $attachment_id, false, $metadata ); } @@ -471,27 +566,36 @@ public function every_alt_maybe_auto_on_generate_metadata( $metadata, $attachmen } //auto alt – direct OpenAI Vision API, image sent as base64 (medium size) - public function every_alt_auto_add_image_alt_text( $attachment_ID, $bulk = null, $metadata = null ) { + public function every_alt_auto_add_image_alt_text( $attachment_ID, $bulk = null, $metadata = null, $allow_decorative = true ) { + $this->last_failure = ''; $api_key = $this->get_api_key(); $auto = get_option( $this->option_name . '_auto' ); if ( $bulk ) { $auto = 1; } if ( ! $api_key || ! $auto ) { + $this->last_failure = 'no_key'; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( 'Skipped: no API key or auto-generate disabled.', 'everyalt' ), '' ); return null; } if ( ! $this->every_alt_validate_token() ) { + $this->last_failure = 'no_key'; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( 'Skipped: API key validation failed.', 'everyalt' ), '' ); return null; } + if ( Every_Alt_Usage::budget_reached() ) { + $this->last_failure = 'budget'; + $this->every_alt_add_generation_log( $attachment_ID, 'error', '' . $this->every_alt_budget_reached_message(), '' ); + return null; + } if ( ! $this->every_alt_is_valid_image( $attachment_ID, $metadata ) ) { + $this->last_failure = 'invalid_image'; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( 'Skipped: not a valid image or file exceeds 4MB.', 'everyalt' ), '' ); return null; } $openai = new Every_Alt_OpenAI( $api_key ); - $generated_alt = $openai->generate_alt( $attachment_ID, $metadata ); + $generated_alt = $openai->generate_alt( $attachment_ID, $metadata, $allow_decorative ); if ( ! empty( $generated_alt->error ) ) { $usage = isset( $generated_alt->usage ) ? $generated_alt->usage : ''; @@ -501,10 +605,19 @@ public function every_alt_auto_add_image_alt_text( $attachment_ID, $bulk = null, if ( $detail_for_log !== '' && ( strpos( $detail_for_log, '/' ) !== false || strpos( $detail_for_log, '\\' ) !== false ) ) { $detail_for_log = ''; } + $this->last_failure = 'api'; $this->every_alt_add_generation_log( $attachment_ID, 'error', $generated_alt->error, $detail_for_log, $usage, $cost ); return null; } + if ( ! empty( $generated_alt->decorative ) ) { + // Decorative images get deliberately empty alt text (WCAG). The flag stops bulk generation re-queueing them. + update_post_meta( $attachment_ID, '_wp_attachment_image_alt', '' ); + update_post_meta( $attachment_ID, self::DECORATIVE_META, 1 ); + $this->every_alt_add_generation_log( $attachment_ID, 'success', __( 'Marked as decorative (alt text left empty on purpose).', 'everyalt' ), '', $generated_alt->usage, $generated_alt->cost ); + return $bulk ? $generated_alt : ''; + } if ( empty( $generated_alt->alt ) ) { + $this->last_failure = 'api'; $usage = isset( $generated_alt->usage ) ? $generated_alt->usage : ''; $cost = isset( $generated_alt->cost ) ? $generated_alt->cost : ''; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( 'No alt text returned from API.', 'everyalt' ), '', $usage, $cost ); @@ -512,6 +625,7 @@ public function every_alt_auto_add_image_alt_text( $attachment_ID, $bulk = null, } update_post_meta( $attachment_ID, '_wp_attachment_image_alt', $generated_alt->alt ); + delete_post_meta( $attachment_ID, self::DECORATIVE_META ); $usage = isset( $generated_alt->usage ) ? $generated_alt->usage : ''; $cost = isset( $generated_alt->cost ) ? $generated_alt->cost : ''; $this->every_alt_add_generation_log( $attachment_ID, 'success', $generated_alt->alt, '', $usage, $cost ); @@ -524,20 +638,29 @@ public function every_alt_auto_add_image_alt_text( $attachment_ID, $bulk = null, //auto title – direct OpenAI Vision API, image sent as base64 (medium size) public function every_alt_auto_add_image_title( $attachment_ID, $bulk = null, $metadata = null ) { + $this->last_failure = ''; $api_key = $this->get_api_key(); $auto = get_option( $this->option_name . '_auto_title' ); if ( $bulk ) { $auto = 1; } if ( ! $api_key || ! $auto ) { + $this->last_failure = 'no_key'; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( '[Title] Skipped: no API key or auto-generate disabled.', 'everyalt' ), '' ); return null; } if ( ! $this->every_alt_validate_token() ) { + $this->last_failure = 'no_key'; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( '[Title] Skipped: API key validation failed.', 'everyalt' ), '' ); return null; } + if ( Every_Alt_Usage::budget_reached() ) { + $this->last_failure = 'budget'; + $this->every_alt_add_generation_log( $attachment_ID, 'error', '[Title] ' . $this->every_alt_budget_reached_message(), '' ); + return null; + } if ( ! $this->every_alt_is_valid_image( $attachment_ID, $metadata ) ) { + $this->last_failure = 'invalid_image'; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( '[Title] Skipped: not a valid image or file exceeds 4MB.', 'everyalt' ), '' ); return null; } @@ -553,10 +676,12 @@ public function every_alt_auto_add_image_title( $attachment_ID, $bulk = null, $m if ( $detail_for_log !== '' && ( strpos( $detail_for_log, '/' ) !== false || strpos( $detail_for_log, '\\' ) !== false ) ) { $detail_for_log = ''; } + $this->last_failure = 'api'; $this->every_alt_add_generation_log( $attachment_ID, 'error', '[Title] ' . $generated_title->error, $detail_for_log, $usage, $cost ); return null; } if ( empty( $generated_title->title ) ) { + $this->last_failure = 'api'; $usage = isset( $generated_title->usage ) ? $generated_title->usage : ''; $cost = isset( $generated_title->cost ) ? $generated_title->cost : ''; $this->every_alt_add_generation_log( $attachment_ID, 'error', __( '[Title] No title returned from API.', 'everyalt' ), '', $usage, $cost ); @@ -744,16 +869,50 @@ private function every_alt_current_page() { * @return array { images: WP_Post[], total: int, pages: int, current: int } */ private function every_alt_get_images_by_alt( $with_alt ) { + $meta_query = $this->every_alt_alt_meta_query( $with_alt ); + $current = $this->every_alt_current_page(); + $query = new WP_Query( array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'post_status' => 'any', + 'posts_per_page' => self::IMAGES_PER_PAGE, + 'paged' => $current, + 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery + 'update_post_term_cache' => false, + ) ); + return array( + 'images' => $query->posts, + 'total' => (int) $query->found_posts, + 'pages' => (int) $query->max_num_pages, + 'current' => $current, + ); + } + + /** + * Meta query for images with alt text (including ones marked decorative, which are reviewed there), + * or missing it (excluding decorative ones, which are empty on purpose). + * + * @param bool $with_alt + * @return array + */ + private function every_alt_alt_meta_query( $with_alt ) { if ( $with_alt ) { - $meta_query = array( + return array( + 'relation' => 'OR', array( 'key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '!=', ), + array( + 'key' => self::DECORATIVE_META, + 'compare' => 'EXISTS', + ), ); - } else { - $meta_query = array( + } + return array( + 'relation' => 'AND', + array( 'relation' => 'OR', array( 'key' => '_wp_attachment_image_alt', @@ -763,36 +922,40 @@ private function every_alt_get_images_by_alt( $with_alt ) { 'key' => '_wp_attachment_image_alt', 'value' => '', ), - ); - } - $current = $this->every_alt_current_page(); - $query = new WP_Query( array( - 'post_type' => 'attachment', - 'post_mime_type' => 'image', - 'post_status' => 'any', - 'posts_per_page' => self::IMAGES_PER_PAGE, - 'paged' => $current, - 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery - 'update_post_term_cache' => false, - ) ); - return array( - 'images' => $query->posts, - 'total' => (int) $query->found_posts, - 'pages' => (int) $query->max_num_pages, - 'current' => $current, + ), + array( + 'key' => self::DECORATIVE_META, + 'compare' => 'NOT EXISTS', + ), ); } /** - * One page of image attachments whose title does (or does not) look like the raw upload filename. + * IDs of every image missing alt text (all pages), for "Queue all". + * + * @return int[] + */ + private function every_alt_missing_alt_ids() { + return array_map( 'intval', get_posts( array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'meta_query' => $this->every_alt_alt_meta_query( false ), // phpcs:ignore WordPress.DB.SlowDBQuery + ) ) ); + } + + /** + * IDs of every image whose title does (or does not) look like the raw upload filename, newest first. * * The filename check can't be expressed in SQL, so this scans a lightweight (ID, title, file) row per * image in one query instead of loading every attachment post. * - * @param bool $filename_like True for images needing a title, false for images with a custom title. - * @return array { images: WP_Post[], total: int, pages: int, current: int } + * @param bool $filename_like + * @return int[] */ - private function every_alt_get_images_by_title( $filename_like ) { + private function every_alt_title_ids( $filename_like ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT p.ID, p.post_title, pm.meta_value AS file @@ -809,6 +972,20 @@ private function every_alt_get_images_by_title( $filename_like ) { $ids[] = (int) $row->ID; } } + return $ids; + } + + /** + * One page of image attachments whose title does (or does not) look like the raw upload filename. + * + * The filename check can't be expressed in SQL, so this scans a lightweight (ID, title, file) row per + * image in one query instead of loading every attachment post. + * + * @param bool $filename_like True for images needing a title, false for images with a custom title. + * @return array { images: WP_Post[], total: int, pages: int, current: int } + */ + private function every_alt_get_images_by_title( $filename_like ) { + $ids = $this->every_alt_title_ids( $filename_like ); $total = count( $ids ); $pages = (int) ceil( $total / self::IMAGES_PER_PAGE ); @@ -985,6 +1162,22 @@ public function every_alt_save_settings() { } update_option( $this->option_name . '_auto', ! empty( $_POST[ $this->option_name . '_auto' ] ) ? 1 : 0 ); update_option( $this->option_name . '_auto_title', ! empty( $_POST[ $this->option_name . '_auto_title' ] ) ? 1 : 0 ); + update_option( Every_Alt_Public::FILL_OPTION, ! empty( $_POST[ Every_Alt_Public::FILL_OPTION ] ) ? 1 : 0 ); + update_option( Every_Alt_OpenAI::DECORATIVE_OPTION, ! empty( $_POST[ Every_Alt_OpenAI::DECORATIVE_OPTION ] ) ? 1 : 0 ); + if ( isset( $_POST[ self::UPLOAD_MODE_OPTION ] ) ) { + update_option( self::UPLOAD_MODE_OPTION, $_POST[ self::UPLOAD_MODE_OPTION ] === 'immediate' ? 'immediate' : 'background' ); + } + if ( isset( $_POST[ Every_Alt_Usage::BUDGET_OPTION ] ) ) { + $budget = sanitize_text_field( wp_unslash( $_POST[ Every_Alt_Usage::BUDGET_OPTION ] ) ); + $budget = $budget === '' ? '' : max( 0, round( (float) $budget, 2 ) ); + update_option( Every_Alt_Usage::BUDGET_OPTION, $budget ? $budget : '' ); + } + if ( isset( $_POST[ Every_Alt_Language::OPTION ] ) ) { + $language = sanitize_text_field( wp_unslash( $_POST[ Every_Alt_Language::OPTION ] ) ); + if ( $language === '' || isset( Every_Alt_Language::languages()[ $language ] ) ) { + update_option( Every_Alt_Language::OPTION, $language ); + } + } if ( isset( $_POST['every_alt_vision_prompt'] ) ) { update_option( 'every_alt_vision_prompt', sanitize_textarea_field( wp_unslash( $_POST['every_alt_vision_prompt'] ) ) ); } @@ -1032,7 +1225,56 @@ public function every_alt_custom_admin_endpoints() { 'minimum' => 1, 'sanitize_callback' => 'absint', ), + // True to always get a description ("Describe anyway" on an image marked decorative). + 'describe' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ), + ), + ) ); + + register_rest_route( 'everyalt-api/v1', '/queue', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'every_alt_rest_queue_status' ), + 'permission_callback' => array( $this, 'every_alt_rest_can_manage' ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'every_alt_rest_queue_add' ), + 'permission_callback' => array( $this, 'every_alt_rest_can_manage' ), + 'args' => array( + 'type' => array( + 'required' => true, + 'type' => 'string', + 'enum' => Every_Alt_Queue::TYPES, + ), + 'media_ids' => array( + 'required' => false, + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'default' => array(), + ), + // Queue every image that needs this type, across all pages. + 'all' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ), + ), ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'every_alt_rest_queue_clear' ), + 'permission_callback' => array( $this, 'every_alt_rest_can_manage' ), + ), + ) ); + + register_rest_route( 'everyalt-api/v1', '/queue/process', array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'every_alt_rest_queue_process' ), + 'permission_callback' => array( $this, 'every_alt_rest_can_manage' ), ) ); register_rest_route( 'everyalt-api/v1', '/save_title', array( @@ -1090,13 +1332,16 @@ public function every_alt_rest_can_edit_media( $request ) { public function bulk_generate_alt( $request ) { $media_id = absint( $request->get_param( 'media_id' ) ); - $result = $this->every_alt_auto_add_image_alt_text( $media_id, true ); + $result = $this->every_alt_auto_add_image_alt_text( $media_id, true, null, ! $request->get_param( 'describe' ) ); if ( $result && isset( $result->alt ) ) { + // Generated here, so drop any queued job for it. + Every_Alt_Queue::dequeue( $media_id, 'alt' ); $response = array( - 'media_id' => $media_id, - 'success' => true, - 'alt_text' => $result->alt, + 'media_id' => $media_id, + 'success' => true, + 'alt_text' => $result->alt, + 'decorative' => ! empty( $result->decorative ), ); } else { $last_error = $this->every_alt_get_last_log_message_for_attachment( $media_id ); @@ -1113,6 +1358,9 @@ public function bulk_generate_alt( $request ) { public function bulk_generate_title( $request ) { $media_id = absint( $request->get_param( 'media_id' ) ); $result = $this->every_alt_auto_add_image_title( $media_id, true ); + if ( $result ) { + Every_Alt_Queue::dequeue( $media_id, 'title' ); + } if ( $result && isset( $result->title ) ) { $response = array( @@ -1132,13 +1380,49 @@ public function bulk_generate_title( $request ) { return new WP_REST_Response( $response, 200 ); } + /** + * REST permission for queue routes: the admin page's users. + * + * @return bool + */ + public function every_alt_rest_can_manage() { + return current_user_can( 'manage_options' ); + } + + public function every_alt_rest_queue_status() { + return new WP_REST_Response( Every_Alt_Queue::status(), 200 ); + } + + public function every_alt_rest_queue_add( $request ) { + $type = $request->get_param( 'type' ); + if ( $request->get_param( 'all' ) ) { + $ids = $type === 'alt' ? $this->every_alt_missing_alt_ids() : $this->every_alt_title_ids( true ); + } else { + $ids = array_map( 'absint', (array) $request->get_param( 'media_ids' ) ); + } + $added = $this->queue->enqueue( $ids, $type ); + return new WP_REST_Response( array( 'added' => $added, 'status' => Every_Alt_Queue::status() ), 200 ); + } + + public function every_alt_rest_queue_clear() { + Every_Alt_Queue::clear(); + return new WP_REST_Response( Every_Alt_Queue::status(), 200 ); + } + + /** + * Browser runner: process jobs for up to ~20 seconds and report each result. + */ + public function every_alt_rest_queue_process() { + return new WP_REST_Response( $this->queue->process( 20 ), 200 ); + } + /** * Get the message from the most recent generation log entry for an attachment. * * @param int $attachment_id * @return string Empty string if none. */ - private function every_alt_get_last_log_message_for_attachment( $attachment_id ) { + public function every_alt_get_last_log_message_for_attachment( $attachment_id ) { $log = get_option( self::GENERATION_LOG_OPTION, array() ); if ( ! is_array( $log ) ) { return ''; @@ -1155,6 +1439,11 @@ public function every_alt_save_alt( $request ) { $media_id = absint( $request->get_param( 'media_id' ) ); $alt_text = sanitize_text_field( $request->get_param( 'alt_text' ) ); $update = update_post_meta( $media_id, '_wp_attachment_image_alt', $alt_text ); + if ( $alt_text !== '' ) { + // A person wrote alt text: it is no longer "decorative", and needs no queued generation. + delete_post_meta( $media_id, self::DECORATIVE_META ); + Every_Alt_Queue::dequeue( $media_id, 'alt' ); + } $response = [ 'alt' => $alt_text, 'media_id' => $media_id, @@ -1166,6 +1455,7 @@ public function every_alt_save_alt( $request ) { public function every_alt_save_title( $request ) { $media_id = absint( $request->get_param( 'media_id' ) ); $title = sanitize_text_field( $request->get_param( 'title' ) ); + Every_Alt_Queue::dequeue( $media_id, 'title' ); wp_update_post( array( 'ID' => $media_id, 'post_title' => $title, diff --git a/admin/class-everyalt-openai.php b/admin/class-everyalt-openai.php index 4d6e7ec..2da024f 100644 --- a/admin/class-everyalt-openai.php +++ b/admin/class-everyalt-openai.php @@ -20,6 +20,15 @@ class Every_Alt_OpenAI { // Reasoning models use tokens for internal "thinking"; we need enough for reasoning + actual output. const DEFAULT_MAX_COMPLETION_TOKENS = 1024; + /** Option: ask the model to flag purely decorative images (default on). */ + const DECORATIVE_OPTION = 'every_alt_detect_decorative'; + + /** Exact answer the model gives for a decorative image. */ + const DECORATIVE_MARKER = 'DECORATIVE'; + + /** Appended to the alt text prompt when decorative detection is on. Conservative on purpose: a wrongly skipped image is worse than a described divider. */ + const DECORATIVE_INSTRUCTION = 'Exception: if the image is purely decorative and conveys no information (for example a divider line, spacer, background texture, or abstract pattern), reply with only the English word DECORATIVE. If in doubt, describe the image.'; + /** Filter: change the model ID sent to the provider. Args: ( string $model_id, string $model_slug ). */ const FILTER_MODEL = 'everyalt_model'; @@ -122,23 +131,69 @@ private function get_mime_type( $path ) { /** * Generate alt text for an attachment using the selected vision model. Image is sent as base64. * + * When decorative-image detection is on (and $allow_decorative is true), the model may answer + * DECORATIVE for images that convey no information. The result then has decorative = true and alt = ''. + * * @param int $attachment_id - * @param array|null $metadata Unsaved attachment metadata (during upload), see get_image_path_for_vision(). - * @return object { alt: string|null, error: string|null, error_detail: string|null } Always returns object; check ->error for failure. + * @param array|null $metadata Unsaved attachment metadata (during upload), see get_image_path_for_vision(). + * @param bool $allow_decorative False to always get a description (e.g. "Describe anyway"). + * @return object { alt: string|null, decorative: bool, error: string|null, error_detail: string|null } Always returns object; check ->error for failure. */ - public function generate_alt( $attachment_id, $metadata = null ) { + public function generate_alt( $attachment_id, $metadata = null, $allow_decorative = true ) { $saved_prompt = get_option( 'every_alt_vision_prompt', '' ); $prompt = $saved_prompt !== '' ? $saved_prompt : self::DEFAULT_PROMPT; - $prompt = apply_filters( 'everyalt_vision_prompt', $prompt ); + $prompt = self::with_language( $prompt, $attachment_id ); + $detect = $allow_decorative && self::decorative_detection_enabled(); + if ( $detect ) { + // After the language instruction, so the marker word is not translated. + $prompt = rtrim( $prompt ) . ' ' . self::DECORATIVE_INSTRUCTION; + } + $prompt = apply_filters( 'everyalt_vision_prompt', $prompt, $attachment_id ); $result = $this->generate_text( $attachment_id, $prompt, $metadata ); // Map shared 'text' field onto 'alt' for backward compatibility. - $result->alt = isset( $result->text ) ? $result->text : null; + $result->alt = isset( $result->text ) ? $result->text : null; + $result->decorative = false; unset( $result->text ); + if ( $detect && is_string( $result->alt ) && self::is_decorative_marker( $result->alt ) ) { + $result->alt = ''; + $result->decorative = true; + } return $result; } + /** + * Whether decorative-image detection is enabled in Settings (default on). + * + * @return bool + */ + public static function decorative_detection_enabled() { + return (bool) get_option( self::DECORATIVE_OPTION, 1 ); + } + + /** + * Whether the model's answer is the decorative marker (tolerating case, punctuation, and quotes). + * + * @param string $text + * @return bool + */ + public static function is_decorative_marker( $text ) { + return strtoupper( trim( $text, " \t\n\r.!\"'`*" ) ) === self::DECORATIVE_MARKER; + } + + /** + * Append the output-language instruction (see Every_Alt_Language) to a prompt. + * + * @param string $prompt + * @param int $attachment_id + * @return string + */ + private static function with_language( $prompt, $attachment_id ) { + $instruction = Every_Alt_Language::prompt_instruction( $attachment_id ); + return $instruction === '' ? $prompt : rtrim( $prompt ) . ' ' . $instruction; + } + /** * Generate a short image title for an attachment using the selected vision model. Image is sent as base64. * @@ -149,7 +204,8 @@ public function generate_alt( $attachment_id, $metadata = null ) { public function generate_title( $attachment_id, $metadata = null ) { $saved_prompt = get_option( 'every_alt_title_prompt', '' ); $prompt = $saved_prompt !== '' ? $saved_prompt : self::DEFAULT_TITLE_PROMPT; - $prompt = apply_filters( 'everyalt_title_prompt', $prompt ); + $prompt = self::with_language( $prompt, $attachment_id ); + $prompt = apply_filters( 'everyalt_title_prompt', $prompt, $attachment_id ); $result = $this->generate_text( $attachment_id, $prompt, $metadata ); @@ -258,7 +314,12 @@ private function generate_text( $attachment_id, $prompt, $metadata = null ) { $json = json_decode( $body_raw, true ); $usage_raw = isset( $json['usage'] ) ? $json['usage'] : null; $usage = self::format_usage( $usage_raw ); - $cost = $this->format_cost( $usage_raw ); + $cost_usd = $this->cost_usd( $usage_raw ); + $cost = $cost_usd === null ? '' : number_format( $cost_usd * 100, 4 ) . '¢'; + if ( $cost_usd !== null ) { + // Every billed request counts toward the monthly total, including failed or cut-off ones. + Every_Alt_Usage::record( $this->model_def['slug'], $cost_usd ); + } if ( $code < 200 || $code >= 300 ) { $detail = $body_raw; @@ -344,24 +405,22 @@ private static function format_usage( $usage ) { } /** - * Format estimated cost for display in cents. Uses filtered input/output price per 1M tokens. + * Estimated cost of a request in USD. Uses filtered input/output price per 1M tokens. * * @param array|null $usage API usage array with prompt_tokens, completion_tokens. - * @return string Formatted cost e.g. "0.0123¢" or empty string if no usage. + * @return float|null Null if the response had no usage. */ - private function format_cost( $usage ) { + private function cost_usd( $usage ) { if ( ! is_array( $usage ) ) { - return ''; + return null; } $p = isset( $usage['prompt_tokens'] ) ? (int) $usage['prompt_tokens'] : 0; $c = isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0; if ( $p === 0 && $c === 0 ) { - return ''; + return null; } $input_price = (float) apply_filters( self::FILTER_INPUT_PRICE_PER_MILLION, $this->model_def['input_price'], $this->model_def['slug'] ); $output_price = (float) apply_filters( self::FILTER_OUTPUT_PRICE_PER_MILLION, $this->model_def['output_price'], $this->model_def['slug'] ); - $cost_dollars = ( $p * $input_price / 1000000 ) + ( $c * $output_price / 1000000 ); - $cost_cents = $cost_dollars * 100; - return number_format( $cost_cents, 4 ) . '¢'; + return ( $p * $input_price / 1000000 ) + ( $c * $output_price / 1000000 ); } } diff --git a/admin/css/everyalt-admin.css b/admin/css/everyalt-admin.css index d157b40..ebc7a57 100644 --- a/admin/css/everyalt-admin.css +++ b/admin/css/everyalt-admin.css @@ -1 +1 @@ -*{box-sizing:inherit}.everyalt-settings-page{box-sizing:border-box;font-family:Helvetica Neue,Helvetica,Arial,Lucida Grande,sans-serif;font-size:14px;position:relative}#everyalt-history-images-empty{margin-left:auto;margin-right:auto;margin-top:2rem;max-width:1280px;padding:0 15px}#everyalt-history-images{grid-column-gap:10px;grid-row-gap:10px;display:grid;grid-template-columns:repeat(2,1fr);margin-top:2rem;padding-left:15px;padding-right:15px}@media (min-width:768px){#everyalt-history-images{grid-template-columns:repeat(3,1fr)}}@media (min-width:1200px){#everyalt-history-images{grid-template-columns:repeat(4,1fr)}}@media (min-width:1400px){#everyalt-history-images{grid-template-columns:repeat(5,1fr)}}@media (min-width:1800px){#everyalt-history-images{grid-template-columns:repeat(7,1fr)}}#everyalt-history-images img{height:auto;width:100%}#everyalt-history-images a{display:block;margin-bottom:1rem}#everyalt-history .components-snackbar-list__notice-container{display:flex;justify-content:end;padding-right:15px}#everyalt-custom-media-button .components-snackbar-list{bottom:15px;position:fixed;right:15px}#everyalt-custom-media-button .components-snackbar-list__notice-container{display:flex;justify-content:end}#everyalt-bulk-generator{margin-left:auto;margin-right:auto;max-width:1280px;padding:0 15px}#everyalt-bulk-generator .components-card{background-color:#fefefa;margin-top:2rem}#everyalt-bulk-generator .components-card .everyalt-progress-bar{margin-top:1rem}#everyalt-bulk-generator .components-card .everyalt-progress-bar .every-bar-wrapper{background-color:#fff;border:1px solid #b7c9d9;border-radius:3px;height:20px;overflow:hidden;position:relative}#everyalt-bulk-generator .components-card .everyalt-progress-bar .every-bar-bar{background-color:#0750b6;height:100%;left:0;position:absolute;top:0}#everyalt-bulk-generator .components-card .components-button.is-primary{background-color:#0750b6;text-transform:uppercase}#everyalt-bulk-generator table{margin-top:2rem;width:100%}#everyalt-bulk-generator table th{background-color:#00205b;color:#fff;font-size:.75rem;padding:5px;text-align:left;text-transform:uppercase;width:50%}#everyalt-bulk-generator table td{font-size:.75rem;padding:5px}#everyalt-bulk-generator table td.has_error{background-color:crimson;color:#fff}#everyalt-bulk-generator table tr.even{background-color:#e8e8e8}#everyalt-bulk-generator .everyalt-spinner{display:flex;justify-content:center;text-align:center}#everyalt-bulk-generator .everyalt-spinner-container{text-align:center}#everyalt-bulk-generator .completed-message{padding:20px;text-align:center}#everyalt-bulk-generator .completed-message h2{color:#00205b}#everyalt-plugin .components-snackbar-list{bottom:15px;left:0;position:fixed;width:100%}#everyalt-plugin .is-primary{background-color:#0750b6}#everyalt-plugin .every_alt_http_password_holder{margin-top:50px}#everyalt-plugin .every_alt_http_password_holder .components-base-control__help{margin-top:-15px}#everyalt-plugin .every_alt_http_password_holder h2{margin-bottom:5px}#everyalt-plugin .everyalt-main{padding:0 15px}#everyalt-plugin .components-form-toggle.is-checked .components-form-toggle__track{background-color:#0750b6}#everyalt-plugin .components-card{background-color:#fefefa;margin-top:2rem}#everyalt-plugin .components-card .everyalt-progress-bar{margin-top:2rem}#everyalt-plugin .components-card .everyalt-progress-bar .every-bar-wrapper{background-color:#fff;border:1px solid #b7c9d9;border-radius:3px;height:20px;overflow:hidden;position:relative}#everyalt-plugin .components-card .everyalt-progress-bar .every-bar-bar{background-color:#0750b6;height:100%;left:0;position:absolute;top:0}#everyalt-plugin .components-card .components-toggle-control{margin-top:2rem}#everyalt-plugin .components-card .components-base-control .components-base-control__field{align-items:center;display:flex;margin-bottom:10px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field{width:100%}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field{display:block}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field label{font-weight:600;margin-bottom:10px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field input{border:1px solid #b7c9d9;border-radius:3px;box-shadow:none;color:#444;font-size:14px;height:40px;margin:0 0 10px;padding:9px 15px;width:100%}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field input:disabled{background:#f6f6f6;border-color:#eee;color:#bbb}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field input:focus{border-color:#00a0d2;box-shadow:0 0 0 1px #00a0d2;outline:2px solid transparent;outline-offset:-2px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group{align-items:center;display:flex;margin:2px 0 15px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group .is-primary{background-color:#0750b6;margin-right:10px}#everyalt-plugin .components-snackbar-list__notice-container{display:flex;justify-content:end;padding-right:15px}#everyalt-plugin .components-notice{margin:0;width:100%}#everyalt-plugin .components-placeholder{background:#f6f6f6;box-shadow:none}#everyalt-plugin .components-placeholder .components-placeholder__fieldset{display:flex;justify-content:center}#everyalt-plugin .everyalt-main-error{margin-top:2rem}#everyalt-plugin .everyalt-main{margin-left:auto;margin-right:auto;max-width:1280px}#everyalt-plugin .everyalt-main .components-panel__body{background:#fff;border:1px solid #e2e4e7;margin:25px 0}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__body-toggle{border-bottom:1px solid #d6e2ed}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row{margin:0;padding:10px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control .components-base-control__field{align-items:center;display:flex;margin-bottom:10px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field{width:100%}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field{display:block}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field label{font-weight:600;margin-bottom:10px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field input{border:1px solid #b7c9d9;border-radius:3px;box-shadow:none;color:#444;font-size:14px;height:40px;margin:0 0 10px;padding:9px 15px;width:100%}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field input:disabled{background:#f6f6f6;border-color:#eee;color:#bbb}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field input:focus{border-color:#00a0d2;box-shadow:0 0 0 1px #00a0d2;outline:2px solid transparent;outline-offset:-2px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group{align-items:center;display:flex;margin:2px 0 15px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group .is-primary{margin-right:10px}#everyalt-plugin .everyalt-main .everyalt-info{padding:0 10px 10px}#everyalt-plugin .everyalt-main .everyalt-info h2{font-weight:300;margin-bottom:10px}#everyalt-plugin .everyalt-main .everyalt-info p{margin:0 0 1.5em}#everyalt-plugin .everyalt-main .everyalt-info .everyalt-info-button-group{display:flex}#everyalt-plugin .everyalt-main .everyalt-info .everyalt-info-button-group .is-default{font-size:14px;height:auto;padding:2px 20px}#everyalt-plugin .everyalt-main .everyalt-info .everyalt-info-button-group .is-default:first-child{margin-right:15px}#every_alt_pagination{display:flex;font-size:.85rem;font-weight:700;justify-content:center;margin-top:2rem}#every_alt_pagination a.page-numbers{background-color:#0750b6;color:#fff;display:inline-block;margin-right:-4px;min-width:20px;padding:8px;text-align:center;text-decoration:none}#every_alt_pagination a.page-numbers:hover{background-color:#00205b}#every_alt_pagination a.next,#every_alt_pagination a.prev{background-color:transparent;color:#0750b6;padding:0}#every_alt_pagination a.next:hover,#every_alt_pagination a.prev:hover{background-color:transparent;color:#00205b}#every_alt_pagination a.next{margin-left:10px}#every_alt_pagination a.prev{margin-right:10px}#every_alt_pagination span.current{background-color:#00205b;color:#fff;display:inline-block;margin-right:-4px;min-width:20px;padding:8px;text-align:center}#everyalt-main-error{background-color:#fefefa;margin-left:auto;margin-right:auto;margin-top:2rem;max-width:1280px;padding:15px}#everyalt-main-error h2{color:#00205b;text-align:center!important}#everyalt-gutenber-button p{font-size:.75rem}.everyalt-progress.hidden,.hidden{display:none!important}.everyalt-settings-wrap .form-table,.everyalt-bulk-wrap table,.everyalt-history-wrap table{margin-top:1em}.everyalt-log-detail{max-width:480px;vertical-align:top}.everyalt-log-detail-inner{max-height:280px;overflow-y:auto;overflow-x:auto;padding:6px 8px;background:#f6f7f7;border:1px solid #c3c4c7;border-radius:3px;font-size:12px;line-height:1.4;white-space:pre-wrap;word-break:break-word}.everyalt-key-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.everyalt-key-row .regular-text{flex:1;min-width:200px}.everyalt-bulk-actions{margin:1em 0}.everyalt-bulk-actions .button{margin-right:8px}.everyalt-bulk-progress{margin:1em 0;padding:1em;background:#f6f7f7;border:1px solid #c3c4c7;border-radius:3px}.everyalt-bulk-progress-bar{height:22px;background:#fff;border:1px solid #c3c4c7;border-radius:3px;overflow:hidden}.everyalt-bulk-progress-bar span{display:block;height:100%;background:#2271b1;transition:width .2s}.everyalt-bulk-progress-log{margin:.5em 0 0;padding-left:1.5em;max-height:200px;overflow-y:auto;font-size:13px}.everyalt-bulk-progress-log li.success{color:#00a32a}.everyalt-bulk-progress-log li.error{color:#b32d2e}.everyalt-bulk-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;list-style:none;margin:1em 0;padding:0}.everyalt-bulk-item label{display:block;cursor:pointer}.everyalt-bulk-item .everyalt-bulk-thumb{display:block;line-height:0}.everyalt-bulk-item .everyalt-bulk-thumb img{width:100%;height:auto;display:block;vertical-align:middle}.everyalt-bulk-item .everyalt-bulk-checkbox{margin:6px 6px 0 0}.everyalt-bulk-item .everyalt-bulk-edit-link{display:inline-block;margin-top:4px;font-size:12px}.everyalt-bulk-item-status{display:block;margin-top:4px;font-size:11px;line-height:1.3}.everyalt-bulk-item-status.success{color:#00a32a}.everyalt-bulk-item-status.error{color:#b32d2e}.everyalt-review-wrap .description{margin-bottom:1em}.everyalt-review-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;list-style:none;margin:1em 0;padding:0}.everyalt-review-item{border:1px solid #c3c4c7;border-radius:4px;padding:10px;background:#fff}.everyalt-review-item .everyalt-review-thumb{display:block;line-height:0;margin-bottom:8px}.everyalt-review-item .everyalt-review-thumb img{width:100%;height:auto;display:block}.everyalt-review-item .everyalt-review-edit-link{display:inline-block;font-size:12px;margin-bottom:6px}.everyalt-review-item .everyalt-review-alt-field{width:100%;margin:0 0 8px;padding:6px;font-size:13px;min-height:60px;display:block;box-sizing:border-box}.everyalt-review-item .everyalt-review-actions{display:flex;gap:6px;flex-wrap:wrap}.everyalt-review-item .everyalt-review-actions .button{margin:0}.everyalt-review-item .everyalt-review-status{display:block;margin-top:6px;font-size:12px}.everyalt-review-item .everyalt-review-status.success{color:#00a32a}.everyalt-review-item .everyalt-review-status.error{color:#b32d2e}.everyalt-page-count{margin:1em 0 0;color:#646970}.everyalt-pagination{display:flex;flex-wrap:wrap;gap:4px;margin:1.5em 0}.everyalt-pagination .page-numbers{display:inline-block;min-width:30px;padding:4px 8px;text-align:center;border:1px solid #c3c4c7;border-radius:3px;background:#fff;text-decoration:none}.everyalt-pagination .page-numbers.current{background:#2271b1;border-color:#2271b1;color:#fff}.everyalt-pagination .page-numbers.dots{border-color:transparent;background:transparent}.everyalt-model-list{display:grid;gap:8px;max-width:640px}.everyalt-model-option{display:grid;grid-template-columns:auto 1fr;column-gap:8px;align-items:baseline;padding:10px 12px;border:1px solid #c3c4c7;border-radius:4px;background:#fff;cursor:pointer}.everyalt-model-option input{grid-row:span 3;margin:2px 0 0}.everyalt-model-option:has(input:checked){border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}.everyalt-model-provider,.everyalt-model-price{color:#646970;font-size:13px}.everyalt-provider-key{max-width:640px;margin-bottom:1.5em}.everyalt-provider-key-title{display:flex;align-items:center;gap:10px;margin:0 0 8px;font-size:14px}.everyalt-key-status{font-size:12px;font-weight:400;padding:1px 8px;border-radius:10px}.everyalt-key-status.is-saved{background:#edfaef;color:#008a20}.everyalt-key-status.is-missing{background:#fcf9e8;color:#8a6d00}.everyalt-key-steps{margin:10px 0 8px 1.5em}.everyalt-key-steps li{margin-bottom:4px}.everyalt-privacy-note{margin-top:10px;padding:10px 12px;border-left:4px solid #00a32a;background:#f6f7f7}.everyalt-privacy-note p{margin:0 0 6px}.everyalt-privacy-note p:last-child{margin-bottom:0} \ No newline at end of file +*{box-sizing:inherit}.everyalt-settings-page{box-sizing:border-box;font-family:Helvetica Neue,Helvetica,Arial,Lucida Grande,sans-serif;font-size:14px;position:relative}#everyalt-history-images-empty{margin-left:auto;margin-right:auto;margin-top:2rem;max-width:1280px;padding:0 15px}#everyalt-history-images{grid-column-gap:10px;grid-row-gap:10px;display:grid;grid-template-columns:repeat(2,1fr);margin-top:2rem;padding-left:15px;padding-right:15px}@media (min-width:768px){#everyalt-history-images{grid-template-columns:repeat(3,1fr)}}@media (min-width:1200px){#everyalt-history-images{grid-template-columns:repeat(4,1fr)}}@media (min-width:1400px){#everyalt-history-images{grid-template-columns:repeat(5,1fr)}}@media (min-width:1800px){#everyalt-history-images{grid-template-columns:repeat(7,1fr)}}#everyalt-history-images img{height:auto;width:100%}#everyalt-history-images a{display:block;margin-bottom:1rem}#everyalt-history .components-snackbar-list__notice-container{display:flex;justify-content:end;padding-right:15px}#everyalt-custom-media-button .components-snackbar-list{bottom:15px;position:fixed;right:15px}#everyalt-custom-media-button .components-snackbar-list__notice-container{display:flex;justify-content:end}#everyalt-bulk-generator{margin-left:auto;margin-right:auto;max-width:1280px;padding:0 15px}#everyalt-bulk-generator .components-card{background-color:#fefefa;margin-top:2rem}#everyalt-bulk-generator .components-card .everyalt-progress-bar{margin-top:1rem}#everyalt-bulk-generator .components-card .everyalt-progress-bar .every-bar-wrapper{background-color:#fff;border:1px solid #b7c9d9;border-radius:3px;height:20px;overflow:hidden;position:relative}#everyalt-bulk-generator .components-card .everyalt-progress-bar .every-bar-bar{background-color:#0750b6;height:100%;left:0;position:absolute;top:0}#everyalt-bulk-generator .components-card .components-button.is-primary{background-color:#0750b6;text-transform:uppercase}#everyalt-bulk-generator table{margin-top:2rem;width:100%}#everyalt-bulk-generator table th{background-color:#00205b;color:#fff;font-size:.75rem;padding:5px;text-align:left;text-transform:uppercase;width:50%}#everyalt-bulk-generator table td{font-size:.75rem;padding:5px}#everyalt-bulk-generator table td.has_error{background-color:crimson;color:#fff}#everyalt-bulk-generator table tr.even{background-color:#e8e8e8}#everyalt-bulk-generator .everyalt-spinner{display:flex;justify-content:center;text-align:center}#everyalt-bulk-generator .everyalt-spinner-container{text-align:center}#everyalt-bulk-generator .completed-message{padding:20px;text-align:center}#everyalt-bulk-generator .completed-message h2{color:#00205b}#everyalt-plugin .components-snackbar-list{bottom:15px;left:0;position:fixed;width:100%}#everyalt-plugin .is-primary{background-color:#0750b6}#everyalt-plugin .every_alt_http_password_holder{margin-top:50px}#everyalt-plugin .every_alt_http_password_holder .components-base-control__help{margin-top:-15px}#everyalt-plugin .every_alt_http_password_holder h2{margin-bottom:5px}#everyalt-plugin .everyalt-main{padding:0 15px}#everyalt-plugin .components-form-toggle.is-checked .components-form-toggle__track{background-color:#0750b6}#everyalt-plugin .components-card{background-color:#fefefa;margin-top:2rem}#everyalt-plugin .components-card .everyalt-progress-bar{margin-top:2rem}#everyalt-plugin .components-card .everyalt-progress-bar .every-bar-wrapper{background-color:#fff;border:1px solid #b7c9d9;border-radius:3px;height:20px;overflow:hidden;position:relative}#everyalt-plugin .components-card .everyalt-progress-bar .every-bar-bar{background-color:#0750b6;height:100%;left:0;position:absolute;top:0}#everyalt-plugin .components-card .components-toggle-control{margin-top:2rem}#everyalt-plugin .components-card .components-base-control .components-base-control__field{align-items:center;display:flex;margin-bottom:10px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field{width:100%}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field{display:block}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field label{font-weight:600;margin-bottom:10px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field input{border:1px solid #b7c9d9;border-radius:3px;box-shadow:none;color:#444;font-size:14px;height:40px;margin:0 0 10px;padding:9px 15px;width:100%}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field input:disabled{background:#f6f6f6;border-color:#eee;color:#bbb}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field input:focus{border-color:#00a0d2;box-shadow:0 0 0 1px #00a0d2;outline:2px solid transparent;outline-offset:-2px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group{align-items:center;display:flex;margin:2px 0 15px}#everyalt-plugin .components-card .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group .is-primary{background-color:#0750b6;margin-right:10px}#everyalt-plugin .components-snackbar-list__notice-container{display:flex;justify-content:end;padding-right:15px}#everyalt-plugin .components-notice{margin:0;width:100%}#everyalt-plugin .components-placeholder{background:#f6f6f6;box-shadow:none}#everyalt-plugin .components-placeholder .components-placeholder__fieldset{display:flex;justify-content:center}#everyalt-plugin .everyalt-main-error{margin-top:2rem}#everyalt-plugin .everyalt-main{margin-left:auto;margin-right:auto;max-width:1280px}#everyalt-plugin .everyalt-main .components-panel__body{background:#fff;border:1px solid #e2e4e7;margin:25px 0}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__body-toggle{border-bottom:1px solid #d6e2ed}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row{margin:0;padding:10px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control .components-base-control__field{align-items:center;display:flex;margin-bottom:10px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field{width:100%}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field{display:block}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field label{font-weight:600;margin-bottom:10px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field input{border:1px solid #b7c9d9;border-radius:3px;box-shadow:none;color:#444;font-size:14px;height:40px;margin:0 0 10px;padding:9px 15px;width:100%}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field input:disabled{background:#f6f6f6;border-color:#eee;color:#bbb}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field input:focus{border-color:#00a0d2;box-shadow:0 0 0 1px #00a0d2;outline:2px solid transparent;outline-offset:-2px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group{align-items:center;display:flex;margin:2px 0 15px}#everyalt-plugin .everyalt-main .components-panel__body .components-panel__row .components-base-control.everyalt-text-field .components-base-control__field .everyalt-text-field-button-group .is-primary{margin-right:10px}#everyalt-plugin .everyalt-main .everyalt-info{padding:0 10px 10px}#everyalt-plugin .everyalt-main .everyalt-info h2{font-weight:300;margin-bottom:10px}#everyalt-plugin .everyalt-main .everyalt-info p{margin:0 0 1.5em}#everyalt-plugin .everyalt-main .everyalt-info .everyalt-info-button-group{display:flex}#everyalt-plugin .everyalt-main .everyalt-info .everyalt-info-button-group .is-default{font-size:14px;height:auto;padding:2px 20px}#everyalt-plugin .everyalt-main .everyalt-info .everyalt-info-button-group .is-default:first-child{margin-right:15px}#every_alt_pagination{display:flex;font-size:.85rem;font-weight:700;justify-content:center;margin-top:2rem}#every_alt_pagination a.page-numbers{background-color:#0750b6;color:#fff;display:inline-block;margin-right:-4px;min-width:20px;padding:8px;text-align:center;text-decoration:none}#every_alt_pagination a.page-numbers:hover{background-color:#00205b}#every_alt_pagination a.next,#every_alt_pagination a.prev{background-color:transparent;color:#0750b6;padding:0}#every_alt_pagination a.next:hover,#every_alt_pagination a.prev:hover{background-color:transparent;color:#00205b}#every_alt_pagination a.next{margin-left:10px}#every_alt_pagination a.prev{margin-right:10px}#every_alt_pagination span.current{background-color:#00205b;color:#fff;display:inline-block;margin-right:-4px;min-width:20px;padding:8px;text-align:center}#everyalt-main-error{background-color:#fefefa;margin-left:auto;margin-right:auto;margin-top:2rem;max-width:1280px;padding:15px}#everyalt-main-error h2{color:#00205b;text-align:center!important}#everyalt-gutenber-button p{font-size:.75rem}.everyalt-progress.hidden,.hidden{display:none!important}.everyalt-settings-wrap .form-table,.everyalt-bulk-wrap table,.everyalt-history-wrap table{margin-top:1em}.everyalt-log-detail{max-width:480px;vertical-align:top}.everyalt-log-detail-inner{max-height:280px;overflow-y:auto;overflow-x:auto;padding:6px 8px;background:#f6f7f7;border:1px solid #c3c4c7;border-radius:3px;font-size:12px;line-height:1.4;white-space:pre-wrap;word-break:break-word}.everyalt-key-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.everyalt-key-row .regular-text{flex:1;min-width:200px}.everyalt-bulk-actions{margin:1em 0}.everyalt-bulk-actions .button{margin-right:8px}.everyalt-bulk-progress{margin:1em 0;padding:1em;background:#f6f7f7;border:1px solid #c3c4c7;border-radius:3px}.everyalt-bulk-progress-bar{height:22px;background:#fff;border:1px solid #c3c4c7;border-radius:3px;overflow:hidden}.everyalt-bulk-progress-bar span{display:block;height:100%;background:#2271b1;transition:width .2s}.everyalt-bulk-progress-log{margin:.5em 0 0;padding-left:1.5em;max-height:200px;overflow-y:auto;font-size:13px}.everyalt-bulk-progress-log li.success{color:#00a32a}.everyalt-bulk-progress-log li.error{color:#b32d2e}.everyalt-bulk-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;list-style:none;margin:1em 0;padding:0}.everyalt-bulk-item label{display:block;cursor:pointer}.everyalt-bulk-item .everyalt-bulk-thumb{display:block;line-height:0}.everyalt-bulk-item .everyalt-bulk-thumb img{width:100%;height:auto;display:block;vertical-align:middle}.everyalt-bulk-item .everyalt-bulk-checkbox{margin:6px 6px 0 0}.everyalt-bulk-item .everyalt-bulk-edit-link{display:inline-block;margin-top:4px;font-size:12px}.everyalt-bulk-item-status{display:block;margin-top:4px;font-size:11px;line-height:1.3}.everyalt-bulk-item-status.success{color:#00a32a}.everyalt-bulk-item-status.error{color:#b32d2e}.everyalt-review-wrap .description{margin-bottom:1em}.everyalt-review-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;list-style:none;margin:1em 0;padding:0}.everyalt-review-item{border:1px solid #c3c4c7;border-radius:4px;padding:10px;background:#fff}.everyalt-review-item .everyalt-review-thumb{display:block;line-height:0;margin-bottom:8px}.everyalt-review-item .everyalt-review-thumb img{width:100%;height:auto;display:block}.everyalt-review-item .everyalt-review-edit-link{display:inline-block;font-size:12px;margin-bottom:6px}.everyalt-review-item .everyalt-review-alt-field{width:100%;margin:0 0 8px;padding:6px;font-size:13px;min-height:60px;display:block;box-sizing:border-box}.everyalt-review-item .everyalt-review-actions{display:flex;gap:6px;flex-wrap:wrap}.everyalt-review-item .everyalt-review-actions .button{margin:0}.everyalt-review-item .everyalt-review-status{display:block;margin-top:6px;font-size:12px}.everyalt-review-item .everyalt-review-status.success{color:#00a32a}.everyalt-review-item .everyalt-review-status.error{color:#b32d2e}.everyalt-page-count{margin:1em 0 0;color:#646970}.everyalt-pagination{display:flex;flex-wrap:wrap;gap:4px;margin:1.5em 0}.everyalt-pagination .page-numbers{display:inline-block;min-width:30px;padding:4px 8px;text-align:center;border:1px solid #c3c4c7;border-radius:3px;background:#fff;text-decoration:none}.everyalt-pagination .page-numbers.current{background:#2271b1;border-color:#2271b1;color:#fff}.everyalt-pagination .page-numbers.dots{border-color:transparent;background:transparent}.everyalt-model-list{display:grid;gap:8px;max-width:640px}.form-table td fieldset.everyalt-model-list label.everyalt-model-option{display:grid;grid-template-columns:auto 1fr;column-gap:8px;align-items:baseline;padding:10px 12px;border:1px solid #c3c4c7;border-radius:4px;background:#fff;cursor:pointer}.form-table td fieldset.everyalt-model-list label.everyalt-model-option input{grid-row:span 3;margin:2px 0 0}.everyalt-model-option .everyalt-model-provider,.everyalt-model-option .everyalt-model-price{display:block}.everyalt-model-option:has(input:checked){border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}.everyalt-model-provider,.everyalt-model-price{color:#646970;font-size:13px}.everyalt-provider-key{max-width:640px;margin-bottom:1.5em}.everyalt-provider-key-title{display:flex;align-items:center;gap:10px;margin:0 0 8px;font-size:14px}.everyalt-key-status{font-size:12px;font-weight:400;padding:1px 8px;border-radius:10px}.everyalt-key-status.is-saved{background:#edfaef;color:#008a20}.everyalt-key-status.is-missing{background:#fcf9e8;color:#8a6d00}.everyalt-key-steps{margin:10px 0 8px 1.5em}.everyalt-key-steps li{margin-bottom:4px}.everyalt-privacy-note{margin-top:10px;padding:10px 12px;border-left:4px solid #00a32a;background:#f6f7f7}.everyalt-privacy-note p{margin:0 0 6px}.everyalt-privacy-note p:last-child{margin-bottom:0}.everyalt-queue-panel p{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.everyalt-queue-panel .everyalt-queue-spinner{float:none;margin:0}.everyalt-queue-panel:not(.is-working) .everyalt-queue-spinner{display:none}.everyalt-queue-panel .everyalt-bulk-progress-log{margin:0 0 8px;max-height:160px}.everyalt-queue-panel .everyalt-bulk-progress-log:empty{display:none}#everyalt-queue-clear{margin-left:auto}.everyalt-review-item.is-decorative{border-style:dashed}.everyalt-decorative-badge{display:inline-block;margin:0 6px 6px 0;padding:1px 8px;border-radius:10px;background:#f0f6fc;color:#0a4b78;font-size:12px}.everyalt-budget-bar{max-width:480px;margin:0 0 1em}.everyalt-budget-bar.is-full span{background:#d63638}.everyalt-usage-table{max-width:900px;margin-bottom:.5em} \ No newline at end of file diff --git a/admin/js/everyalt-admin-simple.js b/admin/js/everyalt-admin-simple.js index 4ed59b5..fafc448 100644 --- a/admin/js/everyalt-admin-simple.js +++ b/admin/js/everyalt-admin-simple.js @@ -100,91 +100,142 @@ }); } - // Bulk run: generate for selected, progress bar + per-item log, no reload - var bulkBtn = document.getElementById('everyalt-bulk-run'); - if (bulkBtn) { - var progressEl = document.getElementById('everyalt-bulk-progress'); - var progressTextEl = document.getElementById('everyalt-bulk-progress-text'); - var progressFill = document.getElementById('everyalt-bulk-progress-fill'); - var progressBar = document.querySelector('.everyalt-bulk-progress-bar'); - var progressLog = document.getElementById('everyalt-bulk-progress-log'); - bulkBtn.addEventListener('click', function() { - var checkboxes = document.querySelectorAll('.everyalt-bulk-checkbox:checked'); - var ids = Array.prototype.map.call(checkboxes, function(cb) { return parseInt(cb.value, 10); }); - if (!ids.length) return; - progressEl.classList.remove('hidden'); - bulkBtn.disabled = true; - if (selectAllBtn) selectAllBtn.disabled = true; - if (selectNoneBtn) selectNoneBtn.disabled = true; - document.querySelectorAll('.everyalt-bulk-checkbox').forEach(function(cb) { cb.disabled = true; }); - var total = ids.length; - var done = 0; - if (progressLog) progressLog.innerHTML = ''; - function updateProgress() { - var pct = total ? Math.round((done / total) * 100) : 0; - if (progressTextEl) progressTextEl.textContent = done + ' / ' + total; - if (progressFill) progressFill.style.width = pct + '%'; - if (progressBar) progressBar.setAttribute('aria-valuenow', pct); + // Background queue: "Generate for selected" / "Generate for all" add jobs, then this page helps work + // through them (WP-Cron also does, even after the page is closed). The panel shows progress on every + // EveryAlt tab, and resumes automatically if jobs are waiting when a page opens. + var queuePanel = document.getElementById('everyalt-queue-panel'); + var queueText = document.getElementById('everyalt-queue-text'); + var queueLog = document.getElementById('everyalt-queue-log'); + var queueClearBtn = document.getElementById('everyalt-queue-clear'); + var queueRunning = false; + + function fmt(str, n) { return String(str).replace('%d', n); } + + function renderQueue(status) { + if (!queuePanel || !status) return; + var due = status.due || 0; + var waiting = (status.total || 0) - due; + var msg; + if (status.paused === 'budget') { + msg = t('queuePausedBudget', 'Paused: monthly spending limit reached.'); + } else if (status.paused === 'no_key') { + msg = t('queuePausedKey', 'Paused: add an API key in Settings.'); + } else if (due > 0) { + msg = fmt(t('queueWorking', 'Generating in the background: %d remaining.'), due); + if (waiting > 0) msg += ' ' + fmt(t('queueWaiting', '%d waiting to retry.'), waiting); + } else if (waiting > 0) { + msg = fmt(t('queueWaiting', '%d waiting to retry.'), waiting); + } else { + msg = t('queueDone', 'All done.'); + } + queueText.textContent = msg; + queuePanel.classList.remove('hidden'); + queuePanel.classList.toggle('is-working', due > 0 && !status.paused); + queuePanel.classList.toggle('notice-warning', !!status.paused); + queuePanel.classList.toggle('notice-info', !status.paused); + if (queueClearBtn) queueClearBtn.classList.toggle('hidden', !status.total); + } + + function markItem(r) { + var selector = r.type === 'title' ? '.everyalt-bulk-title-item' : '.everyalt-bulk-item'; + var itemEl = document.querySelector(selector + '[data-media-id="' + r.media_id + '"]'); + var text = r.success ? (r.decorative ? t('decorative', 'Marked as decorative') : r.text) : (r.message || t('error', 'Error')); + if (itemEl) { + var statusEl = itemEl.querySelector('.everyalt-bulk-item-status'); + if (statusEl) { + statusEl.textContent = (r.success ? '\u2713 ' : '\u2717 ') + text; + statusEl.className = 'everyalt-bulk-item-status ' + (r.success ? 'success' : 'error'); } - var index = 0; - function next() { - if (index >= ids.length) { - progressEl.classList.add('hidden'); - bulkBtn.disabled = false; - if (selectAllBtn) selectAllBtn.disabled = false; - if (selectNoneBtn) selectNoneBtn.disabled = false; - document.querySelectorAll('.everyalt-bulk-checkbox').forEach(function(cb) { cb.disabled = false; }); - return; - } - var id = ids[index]; - var itemEl = document.querySelector('.everyalt-bulk-item[data-media-id="' + id + '"]'); - var statusEl = itemEl ? itemEl.querySelector('.everyalt-bulk-item-status') : null; - request('POST', '/everyalt-api/v1/bulk_generate_alt', { media_id: id }) - .then(function(data) { - done++; - updateProgress(); - var success = data && data.success; - var msg = success ? (data.alt_text || '') : (data && data.message) ? data.message : ''; - if (statusEl) { - statusEl.textContent = success ? '\u2713 ' + (data.alt_text || '') : '\u2717 ' + (data.message || t('error', 'Error')); - statusEl.className = 'everyalt-bulk-item-status ' + (success ? 'success' : 'error'); - } - if (progressLog) { - var li = document.createElement('li'); - li.className = success ? 'success' : 'error'; - li.textContent = '#' + id + ': ' + (success ? (data.alt_text || '') : (data.message || t('error', 'Error'))); - progressLog.appendChild(li); - } - if (success && itemEl) { - var label = itemEl.querySelector('label'); - if (label) label.style.opacity = '0.5'; - } - index++; - next(); - }) - .catch(function(err) { - done++; - updateProgress(); - var errMsg = err && err.message ? err.message : t('requestFailed', 'Request failed'); - if (statusEl) { - statusEl.textContent = '\u2717 ' + errMsg; - statusEl.className = 'everyalt-bulk-item-status error'; - } - if (progressLog) { - var li = document.createElement('li'); - li.className = 'error'; - li.textContent = '#' + id + ': ' + errMsg; - progressLog.appendChild(li); - } - index++; - next(); - }); + var cb = itemEl.querySelector('input[type="checkbox"]'); + if (cb && r.success) { cb.checked = false; cb.disabled = true; } + if (r.success) { + var label = itemEl.querySelector('label'); + if (label) label.style.opacity = '0.5'; } - updateProgress(); - next(); + } + if (queueLog) { + var li = document.createElement('li'); + li.className = r.success ? 'success' : 'error'; + li.textContent = '#' + r.media_id + ': ' + text; + queueLog.insertBefore(li, queueLog.firstChild); + while (queueLog.children.length > 50) queueLog.removeChild(queueLog.lastChild); + } + } + + // Keep calling /queue/process while jobs are due. If WP-Cron holds the lock, check back shortly. + function runQueue() { + if (queueRunning) return; + queueRunning = true; + (function step() { + request('POST', '/everyalt-api/v1/queue/process') + .then(function(data) { + (data.results || []).forEach(markItem); + renderQueue(data.status); + var st = data.status || {}; + if (st.paused || !st.total) { queueRunning = false; return; } + if (data.locked || !st.due) { + setTimeout(step, st.due ? 5000 : 30000); + return; + } + step(); + }) + .catch(function() { + // Network hiccup or timeout: WP-Cron keeps going; try again in a bit. + setTimeout(step, 15000); + }); + })(); + } + + function addToQueue(body, btn) { + if (btn) btn.disabled = true; + return request('POST', '/everyalt-api/v1/queue', body) + .then(function(data) { + if (btn) btn.disabled = false; + if (queueLog) { + var li = document.createElement('li'); + li.textContent = data.added ? fmt(t('queueAdded', 'Added %d images to the queue.'), data.added) : t('queueNothing', 'Those images were already queued.'); + queueLog.insertBefore(li, queueLog.firstChild); + } + renderQueue(data.status); + runQueue(); + }) + .catch(function(err) { + if (btn) btn.disabled = false; + renderQueue({ total: 0 }); + queueText.textContent = t('errorPrefix', 'Error:') + ' ' + (err && err.message ? err.message : t('requestFailed', 'Request failed')); + }); + } + + document.querySelectorAll('.everyalt-queue-selected').forEach(function(btn) { + btn.addEventListener('click', function() { + var boxes = document.querySelectorAll(btn.getAttribute('data-checkboxes') + ':checked'); + var ids = Array.prototype.map.call(boxes, function(cb) { return parseInt(cb.value, 10); }); + if (!ids.length) return; + addToQueue({ type: btn.getAttribute('data-type'), media_ids: ids }, btn); + }); + }); + + document.querySelectorAll('.everyalt-queue-all').forEach(function(btn) { + btn.addEventListener('click', function() { + addToQueue({ type: btn.getAttribute('data-type'), all: true }, btn); + }); + }); + + if (queueClearBtn) { + queueClearBtn.addEventListener('click', function() { + request('DELETE', '/everyalt-api/v1/queue').then(function(status) { + renderQueue(status); + queueText.textContent = t('queueCleared', 'Queue cleared.'); + }); }); } + var initialQueue = typeof everyaltAdmin !== 'undefined' ? everyaltAdmin.queue : null; + if (initialQueue && initialQueue.total) { + renderQueue(initialQueue); + if (!initialQueue.paused) runQueue(); + } + // Review Alt Text: Save edited alt (AJAX) document.querySelectorAll('.everyalt-review-save').forEach(function(btn) { btn.addEventListener('click', function() { @@ -225,13 +276,15 @@ var statusEl = item ? item.querySelector('.everyalt-review-status') : null; btn.disabled = true; if (statusEl) statusEl.textContent = ''; - request('POST', '/everyalt-api/v1/bulk_generate_alt', { media_id: mediaId }) + var body = { media_id: mediaId }; + if (btn.getAttribute('data-describe')) body.describe = true; + request('POST', '/everyalt-api/v1/bulk_generate_alt', body) .then(function(data) { btn.disabled = false; if (data && data.success && data.alt_text !== undefined) { if (textarea) textarea.value = data.alt_text; if (statusEl) { - statusEl.textContent = t('regenerated', 'Regenerated!'); + statusEl.textContent = data.decorative ? t('decorative', 'Marked as decorative') : t('regenerated', 'Regenerated!'); statusEl.className = 'everyalt-review-status success'; setTimeout(function() { statusEl.textContent = ''; statusEl.className = 'everyalt-review-status'; }, 2000); } @@ -266,90 +319,6 @@ }); } - // Bulk Titles run: generate titles for selected, progress bar + per-item log, no reload - var bulkTitleBtn = document.getElementById('everyalt-bulk-title-run'); - if (bulkTitleBtn) { - var titleProgressEl = document.getElementById('everyalt-bulk-title-progress'); - var titleProgressTextEl = document.getElementById('everyalt-bulk-title-progress-text'); - var titleProgressFill = document.getElementById('everyalt-bulk-title-progress-fill'); - var titleProgressBar = titleProgressEl ? titleProgressEl.querySelector('.everyalt-bulk-progress-bar') : null; - var titleProgressLog = document.getElementById('everyalt-bulk-title-progress-log'); - bulkTitleBtn.addEventListener('click', function() { - var checkboxes = document.querySelectorAll('.everyalt-bulk-title-checkbox:checked'); - var ids = Array.prototype.map.call(checkboxes, function(cb) { return parseInt(cb.value, 10); }); - if (!ids.length) return; - titleProgressEl.classList.remove('hidden'); - bulkTitleBtn.disabled = true; - if (titleSelectAllBtn) titleSelectAllBtn.disabled = true; - if (titleSelectNoneBtn) titleSelectNoneBtn.disabled = true; - document.querySelectorAll('.everyalt-bulk-title-checkbox').forEach(function(cb) { cb.disabled = true; }); - var total = ids.length; - var done = 0; - if (titleProgressLog) titleProgressLog.innerHTML = ''; - function updateProgress() { - var pct = total ? Math.round((done / total) * 100) : 0; - if (titleProgressTextEl) titleProgressTextEl.textContent = done + ' / ' + total; - if (titleProgressFill) titleProgressFill.style.width = pct + '%'; - if (titleProgressBar) titleProgressBar.setAttribute('aria-valuenow', pct); - } - var index = 0; - function next() { - if (index >= ids.length) { - titleProgressEl.classList.add('hidden'); - bulkTitleBtn.disabled = false; - if (titleSelectAllBtn) titleSelectAllBtn.disabled = false; - if (titleSelectNoneBtn) titleSelectNoneBtn.disabled = false; - document.querySelectorAll('.everyalt-bulk-title-checkbox').forEach(function(cb) { cb.disabled = false; }); - return; - } - var id = ids[index]; - var itemEl = document.querySelector('.everyalt-bulk-title-item[data-media-id="' + id + '"]'); - var statusEl = itemEl ? itemEl.querySelector('.everyalt-bulk-item-status') : null; - request('POST', '/everyalt-api/v1/bulk_generate_title', { media_id: id }) - .then(function(data) { - done++; - updateProgress(); - var success = data && data.success; - if (statusEl) { - statusEl.textContent = success ? '✓ ' + (data.title || '') : '✗ ' + (data.message || t('error', 'Error')); - statusEl.className = 'everyalt-bulk-item-status ' + (success ? 'success' : 'error'); - } - if (titleProgressLog) { - var li = document.createElement('li'); - li.className = success ? 'success' : 'error'; - li.textContent = '#' + id + ': ' + (success ? (data.title || '') : (data.message || t('error', 'Error'))); - titleProgressLog.appendChild(li); - } - if (success && itemEl) { - var label = itemEl.querySelector('label'); - if (label) label.style.opacity = '0.5'; - } - index++; - next(); - }) - .catch(function(err) { - done++; - updateProgress(); - var errMsg = err && err.message ? err.message : t('requestFailed', 'Request failed'); - if (statusEl) { - statusEl.textContent = '✗ ' + errMsg; - statusEl.className = 'everyalt-bulk-item-status error'; - } - if (titleProgressLog) { - var li = document.createElement('li'); - li.className = 'error'; - li.textContent = '#' + id + ': ' + errMsg; - titleProgressLog.appendChild(li); - } - index++; - next(); - }); - } - updateProgress(); - next(); - }); - } - // Review Image Titles: Save edited title (AJAX) document.querySelectorAll('.everyalt-review-title-save').forEach(function(btn) { btn.addEventListener('click', function() { diff --git a/admin/js/everyalt-gutenberg-button.js b/admin/js/everyalt-gutenberg-button.js index fde3ab3..f1ab429 100644 --- a/admin/js/everyalt-gutenberg-button.js +++ b/admin/js/everyalt-gutenberg-button.js @@ -48,7 +48,10 @@ data: { media_id: attachmentId } }).then(function (res) { setIsBusy(false); - if (res && res.success && res.alt_text) { + if (res && res.success && res.decorative) { + setAttributes({ alt: '' }); + notify('success', t('decorative', 'Marked as decorative: alt text left empty on purpose.')); + } else if (res && res.success && res.alt_text) { setAttributes({ alt: res.alt_text }); notify('success', t('generated', 'Alt text generated.')); } else { diff --git a/admin/js/everyalt-media-simple.js b/admin/js/everyalt-media-simple.js index 761ce1a..85a84c2 100644 --- a/admin/js/everyalt-media-simple.js +++ b/admin/js/everyalt-media-simple.js @@ -46,9 +46,9 @@ }) .then(function(data) { btn.disabled = false; - var success = data && data.success && data.alt_text; + var success = data && data.success && (data.alt_text || data.decorative); if (success) { - msg.textContent = t('generated', 'Alt text generated.'); + msg.textContent = data.decorative ? t('decorative', 'Marked as decorative: alt text left empty on purpose.') : t('generated', 'Alt text generated.'); msg.className = 'everyalt-media-message notice notice-success'; var altField = document.getElementById('attachment_alt') || document.querySelector('textarea[name*="_wp_attachment_image_alt"]') || document.querySelector('input[name*="_wp_attachment_image_alt"]'); if (altField) { diff --git a/admin/partials/everyalt-options-display.php b/admin/partials/everyalt-options-display.php index c522b3a..39dfa8f 100644 --- a/admin/partials/everyalt-options-display.php +++ b/admin/partials/everyalt-options-display.php @@ -47,6 +47,15 @@

+ +

@@ -178,6 +187,68 @@ + + + +
+ +
+ +
+

+ + + + + + +

+ + + + + + + $ + +

+ +

+ + + + + + +

+ + + + + + + +

+ + @@ -229,13 +300,14 @@

- + +

-