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