From 1a28098d9a1b80cdb9666a9cc45a7ba93dd456aa Mon Sep 17 00:00:00 2001
From: Rob Howard
' . 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
}