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 ) ) + ) . '
+ +
+- + +
-- + +
-+ 0 ) { + /* translators: 1: spent this month, 2: monthly limit */ + echo esc_html( sprintf( __( 'This month: %1$s of your %2$s limit.', 'everyalt' ), Every_Alt_Usage::format_usd( $everyalt_this_cost ), Every_Alt_Usage::format_usd( $everyalt_budget ) ) ); + } else { + /* translators: %s: spent this month */ + echo esc_html( sprintf( __( 'This month: %s (no limit set).', 'everyalt' ), Every_Alt_Usage::format_usd( $everyalt_this_cost ) ) ); + } + ?> +
+ 0 ) : ?> + + + + +| + | + | + | + |
|---|---|---|---|
| + | + | + | + $everyalt_m ) { + $everyalt_label = isset( $everyalt_models[ $everyalt_slug ] ) ? $everyalt_models[ $everyalt_slug ]['label'] : $everyalt_slug; + $everyalt_parts[] = sprintf( '%s: %s (%s)', $everyalt_label, Every_Alt_Usage::format_usd( $everyalt_m['cost'] ), number_format_i18n( $everyalt_m['requests'] ) ); + } + echo esc_html( implode( ' · ', $everyalt_parts ) ); + ?> + | +
diff --git a/build.sh b/build.sh
index 3069d92..2ed5c2d 100755
--- a/build.sh
+++ b/build.sh
@@ -31,7 +31,8 @@ REF="${1:-HEAD}"
MAIN_FILE="$(git show "${REF}:everyalt.php")"
VERSION="$(sed -nE "s/.*define\( *'EVERY_ALT_VERSION', *'([^']+)'.*/\1/p" <<<"${MAIN_FILE}")"
HEADER_VERSION="$(sed -nE 's/^[ *]*Version: *([^ ]+).*/\1/p' <<<"${MAIN_FILE}")"
-README_VERSION="$(git show "${REF}:README.md" | sed -nE 's/.*badge\/EveryAlt-([0-9][^-]*)-.*/\1/p' | head -1)"
+# Badge text is shields.io-escaped: a literal "-" is written "--" (e.g. EveryAlt-1.1.0--beta.1-7c3aed).
+README_VERSION="$(git show "${REF}:README.md" | sed -nE 's/.*badge\/EveryAlt-(.+)-[0-9a-fA-F]{6}\?.*/\1/p' | head -1 | sed 's/--/-/g')"
if [ -z "${VERSION}" ]; then
echo "Error: could not determine version from everyalt.php" >&2
diff --git a/includes/class-everyalt-deactivator.php b/includes/class-everyalt-deactivator.php
index 73606ef..09607c0 100644
--- a/includes/class-everyalt-deactivator.php
+++ b/includes/class-everyalt-deactivator.php
@@ -31,6 +31,8 @@ public static function deactivate() {
// Settings and the API key are kept so the plugin works again on reactivation.
// Everything is removed on uninstall (see uninstall.php).
delete_option( 'every_alt_do_activation_redirect' );
+ // Stop the background queue runner. Queued jobs stay and resume on reactivation.
+ wp_clear_scheduled_hook( 'everyalt_process_queue' );
}
}
diff --git a/includes/class-everyalt-language.php b/includes/class-everyalt-language.php
new file mode 100644
index 0000000..4f2719c
--- /dev/null
+++ b/includes/class-everyalt-language.php
@@ -0,0 +1,154 @@
+ General).
+ * The result can be changed with the everyalt_output_locale filter.
+ *
+ * @package EveryAlt
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+class Every_Alt_Language {
+
+ /** Option holding a locale to always write in, or '' for automatic. */
+ const OPTION = 'every_alt_language';
+
+ /** Filter: change the locale text is generated in. Args: ( string $locale, int $attachment_id ). */
+ const FILTER_LOCALE = 'everyalt_output_locale';
+
+ /**
+ * English names for common WordPress locales, keyed by locale. Names are sent to the model (which
+ * understands English language names best) and shown in the Settings dropdown.
+ *
+ * @return array
+ */
+ public static function languages() {
+ return array(
+ 'ar' => 'Arabic',
+ 'bg_BG' => 'Bulgarian',
+ 'ca' => 'Catalan',
+ 'cs_CZ' => 'Czech',
+ 'da_DK' => 'Danish',
+ 'de_DE' => 'German',
+ 'de_CH' => 'German (Switzerland)',
+ 'el' => 'Greek',
+ 'en_US' => 'English (US)',
+ 'en_GB' => 'English (UK)',
+ 'en_AU' => 'English (Australia)',
+ 'en_CA' => 'English (Canada)',
+ 'es_ES' => 'Spanish (Spain)',
+ 'es_MX' => 'Spanish (Mexico)',
+ 'fa_IR' => 'Persian',
+ 'fi' => 'Finnish',
+ 'fr_FR' => 'French (France)',
+ 'fr_CA' => 'French (Canada)',
+ 'he_IL' => 'Hebrew',
+ 'hi_IN' => 'Hindi',
+ 'hr' => 'Croatian',
+ 'hu_HU' => 'Hungarian',
+ 'id_ID' => 'Indonesian',
+ 'it_IT' => 'Italian',
+ 'ja' => 'Japanese',
+ 'ko_KR' => 'Korean',
+ 'lt_LT' => 'Lithuanian',
+ 'nb_NO' => 'Norwegian (Bokmål)',
+ 'nl_NL' => 'Dutch',
+ 'pl_PL' => 'Polish',
+ 'pt_BR' => 'Portuguese (Brazil)',
+ 'pt_PT' => 'Portuguese (Portugal)',
+ 'ro_RO' => 'Romanian',
+ 'ru_RU' => 'Russian',
+ 'sk_SK' => 'Slovak',
+ 'sl_SI' => 'Slovenian',
+ 'sr_RS' => 'Serbian',
+ 'sv_SE' => 'Swedish',
+ 'th' => 'Thai',
+ 'tr_TR' => 'Turkish',
+ 'uk' => 'Ukrainian',
+ 'vi' => 'Vietnamese',
+ 'zh_CN' => 'Chinese (Simplified)',
+ 'zh_TW' => 'Chinese (Traditional)',
+ );
+ }
+
+ /**
+ * Locale to write in for an attachment.
+ *
+ * @param int $attachment_id
+ * @return string e.g. "de_DE"
+ */
+ public static function locale_for_attachment( $attachment_id ) {
+ $locale = (string) get_option( self::OPTION, '' );
+ if ( $locale === '' ) {
+ $locale = self::multilingual_locale( $attachment_id );
+ }
+ if ( $locale === '' ) {
+ $locale = get_locale();
+ }
+ return (string) apply_filters( self::FILTER_LOCALE, $locale, $attachment_id );
+ }
+
+ /**
+ * Attachment's language from Polylang or WPML, or '' if neither is active or it has none.
+ *
+ * @param int $attachment_id
+ * @return string
+ */
+ private static function multilingual_locale( $attachment_id ) {
+ if ( function_exists( 'pll_get_post_language' ) ) {
+ $locale = pll_get_post_language( $attachment_id, 'locale' );
+ if ( is_string( $locale ) && $locale !== '' ) {
+ return $locale;
+ }
+ }
+ if ( has_filter( 'wpml_post_language_details' ) ) {
+ $details = apply_filters( 'wpml_post_language_details', null, $attachment_id );
+ if ( is_array( $details ) && ! empty( $details['locale'] ) ) {
+ return (string) $details['locale'];
+ }
+ }
+ return '';
+ }
+
+ /**
+ * English name for a locale, e.g. "German". Falls back to the base language ("de_AT" -> German),
+ * then to the locale code itself, which models also understand.
+ *
+ * @param string $locale
+ * @return string
+ */
+ public static function name( $locale ) {
+ $languages = self::languages();
+ if ( isset( $languages[ $locale ] ) ) {
+ return $languages[ $locale ];
+ }
+ $base = strtok( $locale, '_' );
+ foreach ( $languages as $code => $name ) {
+ if ( strtok( $code, '_' ) === $base ) {
+ return preg_replace( '/\s*\(.*\)$/', '', $name );
+ }
+ }
+ return $locale;
+ }
+
+ /**
+ * Sentence appended to the prompt so the model answers in the right language.
+ *
+ * @param int $attachment_id
+ * @return string
+ */
+ public static function prompt_instruction( $attachment_id ) {
+ $locale = self::locale_for_attachment( $attachment_id );
+ if ( $locale === '' ) {
+ return '';
+ }
+ return sprintf( 'Write your answer in %s.', self::name( $locale ) );
+ }
+}
diff --git a/includes/class-everyalt-queue.php b/includes/class-everyalt-queue.php
new file mode 100644
index 0000000..1bfe855
--- /dev/null
+++ b/includes/class-everyalt-queue.php
@@ -0,0 +1,344 @@
+admin = $admin;
+ }
+
+ /**
+ * Meta key holding a job's next-run time.
+ *
+ * @param string $type 'alt' or 'title'.
+ * @return string
+ */
+ public static function meta_key( $type ) {
+ return '_everyalt_queue_' . $type;
+ }
+
+ /**
+ * Meta key holding a job's failed attempt count.
+ *
+ * @param string $type
+ * @return string
+ */
+ private static function attempts_key( $type ) {
+ return '_everyalt_queue_' . $type . '_attempts';
+ }
+
+ /**
+ * Queue images for generation. Already-queued images are left as they are.
+ *
+ * @param int[] $ids
+ * @param string $type 'alt' or 'title'.
+ * @return int Number of newly queued images.
+ */
+ public function enqueue( $ids, $type ) {
+ if ( ! in_array( $type, self::TYPES, true ) ) {
+ return 0;
+ }
+ $added = 0;
+ $now = time();
+ foreach ( array_unique( array_map( 'absint', (array) $ids ) ) as $id ) {
+ if ( ! $id || ! wp_attachment_is_image( $id ) ) {
+ continue;
+ }
+ if ( add_post_meta( $id, self::meta_key( $type ), $now, true ) ) {
+ delete_post_meta( $id, self::attempts_key( $type ) );
+ $added++;
+ }
+ }
+ if ( $added ) {
+ self::ensure_scheduled();
+ }
+ return $added;
+ }
+
+ /**
+ * Remove an image's job.
+ *
+ * @param int $id
+ * @param string $type
+ */
+ public static function dequeue( $id, $type ) {
+ delete_post_meta( $id, self::meta_key( $type ) );
+ delete_post_meta( $id, self::attempts_key( $type ) );
+ }
+
+ /**
+ * Remove every job.
+ */
+ public static function clear() {
+ foreach ( self::TYPES as $type ) {
+ delete_post_meta_by_key( self::meta_key( $type ) );
+ delete_post_meta_by_key( self::attempts_key( $type ) );
+ }
+ wp_clear_scheduled_hook( self::CRON_HOOK );
+ }
+
+ /**
+ * Queue counts.
+ *
+ * @return array { alt: int, title: int, due: int, total: int, running: bool, paused: string }
+ * paused is 'budget', 'no_key', or ''.
+ */
+ public static function status() {
+ global $wpdb;
+ $now = time();
+ $counts = array( 'alt' => 0, 'title' => 0, 'due' => 0 );
+ foreach ( self::TYPES as $type ) {
+ $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ "SELECT COUNT(*) AS total, SUM( CAST( meta_value AS UNSIGNED ) <= %d ) AS due FROM {$wpdb->postmeta} WHERE meta_key = %s",
+ $now,
+ self::meta_key( $type )
+ ) );
+ $counts[ $type ] = (int) $row->total;
+ $counts['due'] += (int) $row->due;
+ }
+ $counts['total'] = $counts['alt'] + $counts['title'];
+ $counts['running'] = self::is_locked();
+ $counts['paused'] = '';
+ if ( $counts['total'] ) {
+ $model = Every_Alt_Providers::selected_model();
+ if ( Every_Alt_Providers::get_key( $model['provider'] ) === '' ) {
+ $counts['paused'] = 'no_key';
+ } elseif ( Every_Alt_Usage::budget_reached() ) {
+ $counts['paused'] = 'budget';
+ }
+ }
+ return $counts;
+ }
+
+ /**
+ * Work through due jobs until the time limit, alt text first, oldest first.
+ *
+ * @param int $time_limit Seconds. No new job is started after this; a job in progress finishes.
+ * @return array { locked: bool, results: array[], status: array }
+ */
+ public function process( $time_limit ) {
+ if ( ! self::acquire_lock() ) {
+ return array( 'locked' => true, 'results' => array(), 'status' => self::status() );
+ }
+ $started = time();
+ $results = array();
+ try {
+ while ( time() - $started < $time_limit ) {
+ $job = self::next_due_job();
+ if ( ! $job ) {
+ break;
+ }
+ self::refresh_lock();
+ $result = $this->run_job( $job['id'], $job['type'] );
+ $results[] = $result;
+ if ( $result['paused'] ) {
+ break;
+ }
+ }
+ } finally {
+ self::release_lock();
+ }
+ $status = self::status();
+ if ( ! $status['total'] ) {
+ wp_clear_scheduled_hook( self::CRON_HOOK );
+ }
+ return array( 'locked' => false, 'results' => $results, 'status' => $status );
+ }
+
+ /**
+ * WP-Cron callback.
+ */
+ public function process_from_cron() {
+ // Leave headroom under typical 60s PHP limits; a request in progress can take up to 60s itself.
+ $this->process( 40 );
+ }
+
+ /**
+ * Run one job and update the queue according to the outcome.
+ *
+ * @param int $id
+ * @param string $type
+ * @return array { media_id, type, success, text, decorative, message, paused }
+ */
+ private function run_job( $id, $type ) {
+ if ( ! get_post( $id ) ) {
+ self::dequeue( $id, $type );
+ return array( 'media_id' => $id, 'type' => $type, 'success' => false, 'text' => '', 'decorative' => false, 'message' => __( 'Image no longer exists.', 'everyalt' ), 'paused' => '' );
+ }
+
+ $result = $type === 'alt'
+ ? $this->admin->every_alt_auto_add_image_alt_text( $id, true )
+ : $this->admin->every_alt_auto_add_image_title( $id, true );
+
+ $out = array(
+ 'media_id' => $id,
+ 'type' => $type,
+ 'success' => (bool) $result,
+ 'text' => $result ? (string) ( $type === 'alt' ? $result->alt : $result->title ) : '',
+ 'decorative' => $result && ! empty( $result->decorative ),
+ 'message' => $result ? '' : $this->admin->every_alt_get_last_log_message_for_attachment( $id ),
+ 'paused' => '',
+ );
+
+ if ( $result ) {
+ self::dequeue( $id, $type );
+ return $out;
+ }
+
+ switch ( $this->admin->every_alt_last_failure() ) {
+ case 'no_key':
+ case 'budget':
+ // Nothing wrong with this image: leave it queued and stop until the situation changes.
+ $out['paused'] = $this->admin->every_alt_last_failure();
+ break;
+ case 'invalid_image':
+ self::dequeue( $id, $type );
+ break;
+ default:
+ $attempts = (int) get_post_meta( $id, self::attempts_key( $type ), true );
+ if ( $attempts >= count( self::RETRY_DELAYS ) ) {
+ self::dequeue( $id, $type );
+ $out['message'] .= ' ' . __( '(Gave up after several attempts.)', 'everyalt' );
+ } else {
+ update_post_meta( $id, self::attempts_key( $type ), $attempts + 1 );
+ update_post_meta( $id, self::meta_key( $type ), time() + self::RETRY_DELAYS[ $attempts ] );
+ $out['message'] .= ' ' . __( '(Will retry automatically.)', 'everyalt' );
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Oldest due job, alt text before titles.
+ *
+ * @return array|null { id: int, type: string }
+ */
+ private static function next_due_job() {
+ global $wpdb;
+ foreach ( self::TYPES as $type ) {
+ $id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND CAST( meta_value AS UNSIGNED ) <= %d ORDER BY CAST( meta_value AS UNSIGNED ) ASC, post_id ASC LIMIT 1",
+ self::meta_key( $type ),
+ time()
+ ) );
+ if ( $id ) {
+ return array( 'id' => (int) $id, 'type' => $type );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Filter: cron_schedules. Adds a one-minute interval.
+ *
+ * @param array $schedules
+ * @return array
+ */
+ public static function add_cron_schedule( $schedules ) {
+ $schedules[ self::CRON_SCHEDULE ] = array(
+ 'interval' => 60,
+ 'display' => __( 'Every minute (EveryAlt queue)', 'everyalt' ),
+ );
+ return $schedules;
+ }
+
+ /**
+ * Schedule the cron runner if it isn't already, and nudge WP-Cron to start now.
+ */
+ public static function ensure_scheduled() {
+ if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
+ wp_schedule_event( time(), self::CRON_SCHEDULE, self::CRON_HOOK );
+ }
+ if ( function_exists( 'spawn_cron' ) ) {
+ spawn_cron();
+ }
+ }
+
+ /**
+ * Take the processing lock. Uses a direct INSERT IGNORE so only one runner can win, even with a
+ * persistent object cache (add_option() checks the cache first and upserts). Stale locks from a
+ * crashed run expire after LOCK_TTL seconds.
+ *
+ * @return bool
+ */
+ private static function acquire_lock() {
+ global $wpdb;
+ $insert = function () use ( $wpdb ) {
+ return (bool) $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )",
+ self::LOCK_OPTION,
+ time()
+ ) );
+ };
+ if ( $insert() ) {
+ return true;
+ }
+ $locked_at = self::locked_at();
+ if ( $locked_at && time() - $locked_at > self::LOCK_TTL ) {
+ self::release_lock();
+ return $insert();
+ }
+ return false;
+ }
+
+ private static function refresh_lock() {
+ global $wpdb;
+ $wpdb->update( $wpdb->options, array( 'option_value' => time() ), array( 'option_name' => self::LOCK_OPTION ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ }
+
+ private static function release_lock() {
+ global $wpdb;
+ $wpdb->delete( $wpdb->options, array( 'option_name' => self::LOCK_OPTION ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ }
+
+ /**
+ * Lock timestamp read straight from the database (bypassing the options cache), or 0.
+ *
+ * @return int
+ */
+ private static function locked_at() {
+ global $wpdb;
+ return (int) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::LOCK_OPTION ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ }
+
+ /**
+ * @return bool
+ */
+ private static function is_locked() {
+ $locked_at = self::locked_at();
+ return $locked_at && time() - $locked_at <= self::LOCK_TTL;
+ }
+}
diff --git a/includes/class-everyalt-usage.php b/includes/class-everyalt-usage.php
new file mode 100644
index 0000000..52e82aa
--- /dev/null
+++ b/includes/class-everyalt-usage.php
@@ -0,0 +1,121 @@
+ { cost: float, requests: int, models: { slug: { cost, requests } } }. */
+ const OPTION = 'every_alt_usage';
+
+ /** Option: monthly spending limit in USD; '' or 0 means no limit. */
+ const BUDGET_OPTION = 'every_alt_monthly_budget';
+
+ /** Months of history kept. */
+ const MONTHS_KEPT = 12;
+
+ /**
+ * Current month key in the site's timezone, e.g. "2026-09".
+ *
+ * @return string
+ */
+ public static function current_month() {
+ return wp_date( 'Y-m' );
+ }
+
+ /**
+ * Add the cost of one API request to this month's totals.
+ *
+ * @param string $model_slug
+ * @param float $cost_usd
+ */
+ public static function record( $model_slug, $cost_usd ) {
+ $cost_usd = (float) $cost_usd;
+ $usage = self::all();
+ $month = self::current_month();
+ if ( ! isset( $usage[ $month ] ) ) {
+ $usage[ $month ] = array( 'cost' => 0.0, 'requests' => 0, 'models' => array() );
+ }
+ $usage[ $month ]['cost'] += $cost_usd;
+ $usage[ $month ]['requests'] += 1;
+ if ( ! isset( $usage[ $month ]['models'][ $model_slug ] ) ) {
+ $usage[ $month ]['models'][ $model_slug ] = array( 'cost' => 0.0, 'requests' => 0 );
+ }
+ $usage[ $month ]['models'][ $model_slug ]['cost'] += $cost_usd;
+ $usage[ $month ]['models'][ $model_slug ]['requests'] += 1;
+
+ krsort( $usage );
+ $usage = array_slice( $usage, 0, self::MONTHS_KEPT, true );
+ update_option( self::OPTION, $usage, false );
+ }
+
+ /**
+ * All recorded months, newest first.
+ *
+ * @return array
+ */
+ public static function all() {
+ $usage = get_option( self::OPTION, array() );
+ if ( ! is_array( $usage ) ) {
+ return array();
+ }
+ krsort( $usage );
+ return $usage;
+ }
+
+ /**
+ * Estimated spend so far this month, in USD.
+ *
+ * @return float
+ */
+ public static function month_total() {
+ $usage = self::all();
+ $month = self::current_month();
+ return isset( $usage[ $month ]['cost'] ) ? (float) $usage[ $month ]['cost'] : 0.0;
+ }
+
+ /**
+ * Monthly limit in USD, or 0 for no limit.
+ *
+ * @return float
+ */
+ public static function budget() {
+ return max( 0.0, (float) get_option( self::BUDGET_OPTION, '' ) );
+ }
+
+ /**
+ * Whether a limit is set and this month's spend has reached it.
+ *
+ * @return bool
+ */
+ public static function budget_reached() {
+ $budget = self::budget();
+ return $budget > 0 && self::month_total() >= $budget;
+ }
+
+ /**
+ * Format a USD amount for display. Amounts under $1 keep up to 4 decimals so small spends and
+ * overages stay visible ($0.0125, not $0.01); whole-cent amounts still show 2 ($0.50).
+ *
+ * @param float $usd
+ * @return string e.g. "$0.0042", "$0.0125", "$0.50", "$12.37"
+ */
+ public static function format_usd( $usd ) {
+ $usd = (float) $usd;
+ if ( $usd >= 1 || $usd <= 0 ) {
+ return '$' . number_format( max( 0, $usd ), 2 );
+ }
+ $formatted = rtrim( number_format( $usd, 4, '.', '' ), '0' );
+ $decimals = strlen( substr( strrchr( $formatted, '.' ), 1 ) );
+ return '$' . ( $decimals < 2 ? number_format( $usd, 2 ) : $formatted );
+ }
+}
diff --git a/includes/class-everyalt.php b/includes/class-everyalt.php
index e286d36..9982715 100644
--- a/includes/class-everyalt.php
+++ b/includes/class-everyalt.php
@@ -117,8 +117,16 @@ private function load_dependencies() {
require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-everyalt-encryption.php';
require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-everyalt-providers.php';
+ require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-everyalt-language.php';
+ require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-everyalt-usage.php';
+ require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-everyalt-queue.php';
require_once plugin_dir_path( dirname( __FILE__ ) ) . 'admin/class-everyalt-openai.php';
+ /**
+ * The class responsible for front-end behavior (filling missing alt text in post content).
+ */
+ require_once plugin_dir_path( dirname( __FILE__ ) ) . 'public/class-everyalt-public.php';
+
$this->loader = new Every_Alt_Loader();
}
@@ -150,6 +158,13 @@ private function set_locale() {
private function define_admin_hooks() {
$plugin_admin = new Every_Alt_Admin( $this->get_plugin_name(), $this->get_version() );
+ $queue = new Every_Alt_Queue( $plugin_admin );
+ $plugin_admin->set_queue( $queue );
+
+ //background queue: WP-Cron runner (the browser runner is the /queue/process REST route)
+ $this->loader->add_filter( 'cron_schedules', 'Every_Alt_Queue', 'add_cron_schedule' );
+ $this->loader->add_action( Every_Alt_Queue::CRON_HOOK, $queue, 'process_from_cron' );
+ $this->loader->add_action( 'admin_notices', $plugin_admin, 'every_alt_budget_notice' );
$this->loader->add_action( 'admin_enqueue_scripts', $plugin_admin, 'enqueue_styles' );
$this->loader->add_action( 'admin_enqueue_scripts', $plugin_admin, 'enqueue_scripts' );
@@ -196,7 +211,10 @@ private function define_admin_hooks() {
* @access private
*/
private function define_public_hooks() {
- // Public-facing hooks can be registered here if needed.
+ $plugin_public = new Every_Alt_Public();
+
+ // Fill empty alt attributes in post content from the Media Library (WordPress 6.0+).
+ $this->loader->add_filter( 'wp_content_img_tag', $plugin_public, 'fill_missing_alt', 10, 3 );
}
/**
diff --git a/public/class-everyalt-public.php b/public/class-everyalt-public.php
new file mode 100644
index 0000000..2c37a89
--- /dev/null
+++ b/public/class-everyalt-public.php
@@ -0,0 +1,87 @@
+ tag.
+ * @param string $context Filter context, e.g. 'the_content'.
+ * @param int $attachment_id Attachment ID, or 0 if WordPress couldn't identify one.
+ * @return string
+ */
+ public function fill_missing_alt( $filtered_image, $context, $attachment_id ) {
+ if ( ! $attachment_id || ! self::is_enabled() ) {
+ return $filtered_image;
+ }
+ $alt = trim( (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) );
+ if ( $alt === '' ) {
+ return $filtered_image;
+ }
+ return self::set_alt_if_empty( $filtered_image, $alt );
+ }
+
+ /**
+ * Set the alt attribute on an tag only if it is missing or blank.
+ *
+ * @param string $img_tag
+ * @param string $alt Unescaped alt text.
+ * @return string
+ */
+ public static function set_alt_if_empty( $img_tag, $alt ) {
+ // WordPress 6.2+: parse the tag properly.
+ if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {
+ $processor = new WP_HTML_Tag_Processor( $img_tag );
+ if ( ! $processor->next_tag( 'img' ) ) {
+ return $img_tag;
+ }
+ $current = $processor->get_attribute( 'alt' );
+ if ( is_string( $current ) && trim( $current ) !== '' ) {
+ return $img_tag;
+ }
+ $processor->set_attribute( 'alt', $alt );
+ return $processor->get_updated_html();
+ }
+
+ // WordPress 6.0–6.1 fallback.
+ if ( preg_match( '/\salt\s*=\s*(["\'])(.*?)\1/is', $img_tag, $match ) ) {
+ if ( trim( $match[2] ) !== '' ) {
+ return $img_tag;
+ }
+ return str_replace( $match[0], ' alt="' . esc_attr( $alt ) . '"', $img_tag );
+ }
+ return preg_replace( '/^
query( "DROP TABLE IF EXISTS {$wpdb->prefix}every_alt_logs" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
}