0, $l <= 125 => 100, $l <= 150 => 80, $l <= 175 => 60, $l <= 200 => 20, default => 0, }; } /*─────────────────────────────────────────────────────────────── * Alt text quality label (field + Media Library column) * Empty = missing, under 10 characters = too short, * up to 125 = good, over 125 = too long. *───────────────────────────────────────────────────────────────*/ function aatb_alt_quality( string $alt ): array { $len = mb_strlen( trim( $alt ) ); if ( 0 === $len ) { return [ 'key' => 'missing', 'label' => __( 'Missing', 'ai-alt-text-builder' ), 'len' => 0 ]; } if ( $len < 10 ) { return [ 'key' => 'short', 'label' => __( 'Too short', 'ai-alt-text-builder' ), 'len' => $len ]; } if ( $len <= 125 ) { return [ 'key' => 'good', 'label' => __( 'Good', 'ai-alt-text-builder' ), 'len' => $len ]; } return [ 'key' => 'long', 'label' => __( 'Too long', 'ai-alt-text-builder' ), 'len' => $len ]; } /** Quality badge: coloured dot + label (+ character count when asked). */ function aatb_quality_html( string $alt, bool $with_count = false, string $id_attr = '' ): string { $q = aatb_alt_quality( $alt ); return '' . '' . esc_html( $q['label'] ) . '' . ( $with_count && $q['len'] ? '' . esc_html( $q['len'] . ' / 125' ) . '' : '' ) . ''; } /** Generate control under the alt text field (same markup admin.js builds on the attachment screen). */ function aatb_control_html( int $id, string $alt ): string { $label = '' === trim( $alt ) ? __( 'Generate alt text', 'ai-alt-text-builder' ) : __( 'Regenerate', 'ai-alt-text-builder' ); $sid = 'aatb-status-' . $id; return '
' . '' . '' . aatb_quality_html( $alt, true, 'aatb-quality-' . $id ) . '
'; } /** Result line of a finished bulk job: the list notice and the grid status show the same words. */ function aatb_bulk_result_text( int $done, int $fail, bool $stopped ): string { if ( $done > 0 && 0 === $fail ) { /* translators: %d: number of images that received alt text. */ $text = sprintf( _n( '%d alt text saved.', '%d alt texts saved.', $done, 'ai-alt-text-builder' ), $done ); } elseif ( $done > 0 ) { /* translators: 1: images that received alt text, 2: images that failed. */ $text = sprintf( _n( '%1$d alt text saved, %2$d failed.', '%1$d alt texts saved, %2$d failed.', $done, 'ai-alt-text-builder' ), $done, $fail ); } elseif ( $stopped ) { $text = __( 'Bulk generation stopped. No alt text was changed.', 'ai-alt-text-builder' ); } else { /* translators: %d: number of images that could not be processed. */ $text = sprintf( _n( 'Alt text could not be generated for %d image.', 'Alt text could not be generated for %d images.', $fail, 'ai-alt-text-builder' ), $fail ); } if ( $stopped && $done > 0 ) { $text .= ' ' . __( 'Stopped before the end of the selection.', 'ai-alt-text-builder' ); } return $text; } // === Model cost & remaining helpers (Token Guard) === if ( ! function_exists('aatb_model_cost') ) { function aatb_model_cost(string $model): int { $m = strtolower(trim($model)); // Same mapping as Snippet: 4o=5, 4.1=3, mini=1 return match ($m) { 'gpt-4o' => 5, 'gpt-4.1' => 3, 'gpt-4.1-mini' => 1, default => 1, // safe default }; } } if ( ! function_exists('aatb_model_tier') ) { function aatb_model_tier(string $model): string { $m = strtolower(trim($model)); // Tiering consistent with Snippet return match ($m) { 'gpt-4o' => 'premium', 'gpt-4.1' => 'balanced', default => 'economical', }; } } if ( ! function_exists('aatb_model_alias_pack') ) { function aatb_model_alias_pack(string $model): array { $m = strtolower(trim($model)); switch ($m) { // BALANCED case 'gpt-4.1': case 'gpt-4-1': case 'gpt_4_1': case 'gpt41': case 'gpt4.1': return [ 'canonical' => 'gpt-4.1', 'engine' => 'gpt-4.1', 'family' => 'gpt41', 'aliases' => ['gpt-4.1','gpt-4-1','gpt_4_1','gpt41','gpt4.1'], ]; // PREMIUM case 'gpt-4o': case 'gpt4o': case 'o4': case '4o': return [ 'canonical' => 'gpt-4o', 'engine' => 'gpt-4o', 'family' => 'gpt4o', 'aliases' => ['gpt-4o','gpt4o','o4','4o'], ]; // ECONOMY (DEFAULT) default: return [ 'canonical' => 'gpt-4.1-mini', 'engine' => 'gpt-4.1-mini', 'family' => 'gpt4omini', 'aliases' => ['gpt-4.1-mini','gpt-4o-mini','gpt4o-mini','4o-mini','mini'], ]; } } } /** Vision-capable models */ function aatb_vision_models(): array { return [ 'gpt-4.1-mini', 'gpt-4o', 'gpt-4.1' ]; } /** Base prompt (custom text is appended if provided) */ function aatb_base_prompt( string $lang ): string { return 'You are an assistant that writes short, SEO-friendly ALT text for images in **'.$lang.'**. Rules: - Max 125 characters (hard limit). - Focus: object (≈70%), material/texture (≈20%), dominant color (≈10%). - No AI disclaimers. Avoid camera/brand names unless custom directives explicitly require them. - If custom directives are provided, they OVERRIDE the above rules (except the 125-character limit). Output: - Return ONLY JSON: {"alt_text":"..."}'; } final class AATB_Plugin { private static $ins; public static function get_instance() { return self::$ins ?: self::$ins = new self; } private function __construct() { $this->hooks(); } /* ……………………… HOOKS ……………………… */ private function hooks() { add_action( 'admin_menu', [ $this, 'menu' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'assets' ] ); add_filter( 'attachment_fields_to_edit', [ $this, 'ui' ], 10, 2 ); add_filter( 'attachment_fields_to_save', [ $this, 'save' ], 10, 2 ); add_filter( 'manage_upload_columns', [ $this, 'add_col' ] ); add_filter( 'manage_upload_sortable_columns', [ $this, 'sortable_col' ] ); add_action( 'manage_media_custom_column', [ $this, 'col' ], 10, 2 ); add_action( 'wp_ajax_aatb_generate_alt', [ $this, 'ajax' ] ); add_filter( 'bulk_actions-upload', [ $this, 'bulk_items' ] ); add_filter( 'handle_bulk_actions-upload', [ $this, 'bulk_handle' ], 10, 3 ); add_action( 'admin_notices', [ $this, 'bulk_notice' ] ); add_action( 'restrict_manage_posts', [ $this, 'filter_dd' ] ); add_filter( 'posts_clauses', [ $this, 'filter_query' ], 10, 2 ); add_filter( 'plugin_action_links_' . plugin_basename(__FILE__), [ $this, 'plugin_links' ] ); add_action( 'wp_ajax_aatb_bulk_step', [ $this, 'bulk_step_ajax' ] ); add_action( 'wp_ajax_aatb_bulk_cancel', [ $this, 'bulk_cancel_ajax' ] ); add_action( 'wp_ajax_aatb_bulk_start', [ $this, 'bulk_start_ajax' ] ); add_action( 'wp_ajax_aatb_bulk_finish', [ $this, 'bulk_finish_ajax' ] ); add_action( 'wp_ajax_aatb_trial_register', [ $this, 'trial_register_ajax' ] ); add_action( 'wp_ajax_aatb_save_byok_key', [ $this, 'save_byok_key_ajax' ] ); add_action( 'wp_ajax_aatb_remove_byok_key', [ $this, 'remove_byok_key_ajax' ] ); add_action( 'wp_ajax_aatb_remove_trial', [ $this, 'remove_trial_ajax' ] ); add_action( 'add_attachment', [ $this, 'auto_generate_on_upload' ] ); } /* ……………………… MENU ……………………… */ public function menu() { add_menu_page( 'AI Alt Text Builder', 'AI Alt Text Builder', 'manage_options', 'ai-alt-text-builder', 'aatb_render_settings_page', plugin_dir_url(__FILE__) . 'admin/assets/img/ai-alt-text-builder.svg', 25 ); } /* ……………………… ASSETS ……………………… */ public function assets( $hook ) { $need = in_array( $hook, [ 'upload.php', 'media.php' ], true ) || ( strpos( (string) $hook, 'ai-alt-text-builder' ) !== false ) || ( $hook === 'post.php' && function_exists('get_current_screen') && ( get_current_screen()->post_type ?? '' ) === 'attachment' ) // The media modal renders our button through attachment_fields_to_edit on any // screen that loads it, so follow the modal instead of listing screens. || wp_script_is( 'media-editor', 'enqueued' ); if ( ! $need ) return; wp_enqueue_style ( 'aatb-css', AATB_URL . 'admin/assets/css/admin.css', [], AATB_VERSION ); // Dynamic menu icon color fix without admin_head