← All changes
|
includes/extensions/alt-text-generator/Alt_Text_Generator.php
+672
-0
51.1.2
→
51.1.83
View file →
| @@ -1,0 +1,672 @@ | ||
| 1 | +<?php | |
| 2 | +/** | |
| 3 | + * Handles automatic alt text generation for images using AI. | |
| 4 | + * | |
| 5 | + * @package King_Addons | |
| 6 | + */ | |
| 7 | + | |
| 8 | +namespace King_Addons; | |
| 9 | + | |
| 10 | +if (!defined('ABSPATH')) { | |
| 11 | + exit; // Exit if accessed directly. | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Class Alt_Text_Generator | |
| 16 | + */ | |
| 17 | +class Alt_Text_Generator { | |
| 18 | + | |
| 19 | + // Constants for batch processing | |
| 20 | + private const BATCH_OPTION_PENDING = 'king_addons_alt_text_pending_queue'; | |
| 21 | + private const BATCH_OPTION_PROCESSING = 'king_addons_alt_text_processing'; | |
| 22 | + private const CRON_HOOK = 'king_addons_process_alt_text_queue'; | |
| 23 | + private const BATCH_SIZE = 1; // Process 1 image at a time to respect rate limits | |
| 24 | + private const RETRY_LIMIT = 3; // Maximum retry attempts | |
| 25 | + private const RATE_LIMIT_DELAY = 5; // Delay between batches in seconds | |
| 26 | + | |
| 27 | + /** | |
| 28 | + * Constructor. | |
| 29 | + * Hooks into WordPress actions. | |
| 30 | + */ | |
| 31 | + public function __construct() { | |
| 32 | + // Check if AI features are enabled | |
| 33 | + $options = get_option('king_addons_ai_options', []); | |
| 34 | + | |
| 35 | + // Check if any alt text feature is enabled (with proper defaults) | |
| 36 | + $button_enabled = isset($options['enable_ai_alt_text_button']) ? (bool) $options['enable_ai_alt_text_button'] : true; // Default to true | |
| 37 | + $auto_enabled = isset($options['enable_ai_alt_text_auto_generation']) ? (bool) $options['enable_ai_alt_text_auto_generation'] : false; // Default to false | |
| 38 | + | |
| 39 | + if (!$button_enabled && !$auto_enabled) { | |
| 40 | + return; // Don't initialize if both features are disabled | |
| 41 | + } | |
| 42 | + | |
| 43 | + $has_api_key = '' !== \King_Addons\AI_Provider::getApiKey(); | |
| 44 | + | |
| 45 | + // Hook for new attachments (only if auto generation is enabled and API key exists). | |
| 46 | + if ($auto_enabled && $has_api_key) { | |
| 47 | + if(king_addons_freemius()->can_use_premium_code()){ | |
| 48 | + add_action('add_attachment', array($this, 'add_to_processing_queue')); | |
| 49 | + add_action(self::CRON_HOOK, array($this, 'process_alt_text_queue')); | |
| 50 | + | |
| 51 | + // Add custom cron interval | |
| 52 | + add_filter('cron_schedules', array($this, 'add_custom_cron_intervals')); | |
| 53 | + | |
| 54 | + // Schedule recurring cron job if not already scheduled | |
| 55 | + if (!wp_next_scheduled(self::CRON_HOOK)) { | |
| 56 | + wp_schedule_event(time(), 'king_addons_alt_text_interval', self::CRON_HOOK); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + // Hooks for Media Library integration (always enable if button is enabled, regardless of API key). | |
| 62 | + if ($button_enabled) { | |
| 63 | + add_filter('manage_media_columns', array($this, 'add_alt_text_column')); | |
| 64 | + add_action('manage_media_custom_column', array($this, 'display_alt_text_column'), 10, 2); | |
| 65 | + add_action('admin_enqueue_scripts', array($this, 'enqueue_media_scripts')); | |
| 66 | + | |
| 67 | + // AJAX handler for manual generation (only if API key exists). | |
| 68 | + if ($has_api_key) { | |
| 69 | + add_action('wp_ajax_king_addons_generate_single_alt', array($this, 'handle_ajax_generate_single_alt')); | |
| 70 | + } | |
| 71 | + | |
| 72 | + // AJAX handler for queue status (debugging) | |
| 73 | + add_action('wp_ajax_king_addons_alt_text_queue_status', array($this, 'handle_ajax_queue_status')); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // Hook for cleanup on plugin deactivation | |
| 77 | + register_deactivation_hook(KING_ADDONS_PATH . 'king-addons.php', array($this, 'cleanup_cron_jobs')); | |
| 78 | + | |
| 79 | + // Hook to update cron schedule when settings change | |
| 80 | + add_action('update_option_king_addons_ai_options', array($this, 'update_cron_schedule'), 10, 3); | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * Enqueues scripts needed for the media library screen. | |
| 85 | + * | |
| 86 | + * @param string $hook The current admin page hook. | |
| 87 | + */ | |
| 88 | + public function enqueue_media_scripts(string $hook): void { | |
| 89 | + // Load on the upload.php screen (Media Library List view) and post editing screens (for media modal) | |
| 90 | + if (!in_array($hook, ['upload.php', 'post.php', 'post-new.php', 'page.php', 'page-new.php'])) { | |
| 91 | + return; | |
| 92 | + } | |
| 93 | + | |
| 94 | + wp_enqueue_style( | |
| 95 | + 'king-addons-media-alt-text-styles', | |
| 96 | + KING_ADDONS_URL . 'includes/extensions/alt-text-generator/alt-text-styles.css', | |
| 97 | + array(), | |
| 98 | + KING_ADDONS_VERSION | |
| 99 | + ); | |
| 100 | + | |
| 101 | + wp_enqueue_script( | |
| 102 | + 'king-addons-media-alt-text', | |
| 103 | + KING_ADDONS_URL . 'includes/extensions/alt-text-generator/alt-text-media.js', | |
| 104 | + array('jquery'), | |
| 105 | + KING_ADDONS_VERSION, | |
| 106 | + true | |
| 107 | + ); | |
| 108 | + | |
| 109 | + // Check if API key exists | |
| 110 | + $options = get_option('king_addons_ai_options', []); | |
| 111 | + $has_api_key = '' !== \King_Addons\AI_Provider::getApiKey(); | |
| 112 | + | |
| 113 | + // Pass data to JavaScript. | |
| 114 | + wp_localize_script('king-addons-media-alt-text', 'kingAddonsMediaAltText', array( | |
| 115 | + 'ajax_url' => admin_url('admin-ajax.php'), | |
| 116 | + 'nonce' => wp_create_nonce('king_addons_generate_alt_nonce'), | |
| 117 | + 'generating_text' => esc_html__('Generate with AI', 'king-addons'), | |
| 118 | + 'error_text' => esc_html__('Error', 'king-addons'), | |
| 119 | + 'has_api_key' => $has_api_key, | |
| 120 | + 'settings_url' => admin_url('admin.php?page=king-addons-ai-settings'), | |
| 121 | + )); | |
| 122 | + } | |
| 123 | + | |
| 124 | + /** | |
| 125 | + * Adds a custom column to the Media Library list view. | |
| 126 | + * | |
| 127 | + * @param array $columns Existing columns. | |
| 128 | + * @return array Modified columns. | |
| 129 | + */ | |
| 130 | + public function add_alt_text_column($columns): array { | |
| 131 | + $columns = is_array($columns) ? $columns : array(); | |
| 132 | + // Add column before 'Date'. | |
| 133 | + $new_columns = array(); | |
| 134 | + foreach ($columns as $key => $title) { | |
| 135 | + if ('date' === $key) { | |
| 136 | + $new_columns['king_addons_alt_text'] = esc_html__('AI Alt Text', 'king-addons'); | |
| 137 | + } | |
| 138 | + $new_columns[$key] = $title; | |
| 139 | + } | |
| 140 | + // If 'date' column wasn't found, add it at the end. | |
| 141 | + if (!isset($new_columns['king_addons_alt_text'])) { | |
| 142 | + $new_columns['king_addons_alt_text'] = esc_html__('AI Alt Text', 'king-addons'); | |
| 143 | + } | |
| 144 | + return $new_columns; | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * Displays content for the custom alt text column. | |
| 149 | + * | |
| 150 | + * @param string $column_name The name of the column being displayed. | |
| 151 | + * @param int $attachment_id The ID of the current attachment. | |
| 152 | + */ | |
| 153 | + public function display_alt_text_column(string $column_name, int $attachment_id): void { | |
| 154 | + if ('king_addons_alt_text' !== $column_name) { | |
| 155 | + return; | |
| 156 | + } | |
| 157 | + | |
| 158 | + // Only show for images. | |
| 159 | + if (!wp_attachment_is_image($attachment_id)) { | |
| 160 | + echo '—'; // Not an image | |
| 161 | + return; | |
| 162 | + } | |
| 163 | + | |
| 164 | + $alt_text = get_post_meta($attachment_id, '_wp_attachment_image_alt', true); | |
| 165 | + $options = get_option('king_addons_ai_options', []); | |
| 166 | + $has_api_key = '' !== \King_Addons\AI_Provider::getApiKey(); | |
| 167 | + | |
| 168 | + echo '<div class="king-addons-alt-text-status" data-attachment-id="' . esc_attr($attachment_id) . '">'; | |
| 169 | + if (!empty($alt_text)) { | |
| 170 | + echo '<span>' . esc_html($alt_text) . '</span>'; | |
| 171 | + } else { | |
| 172 | + if ($has_api_key) { | |
| 173 | + // Button to generate alt text when API key exists. | |
| 174 | + printf( | |
| 175 | + '<button type="button" class="button button-secondary button-small king-addons-generate-alt-button">%s</button>', | |
| 176 | + esc_html__('Generate', 'king-addons') | |
| 177 | + ); | |
| 178 | + echo '<span class="king-addons-alt-text-result king-addons-status-inline"></span>'; // For displaying results/errors | |
| 179 | + echo '<span class="spinner king-addons-spinner-inline"></span>'; | |
| 180 | + } else { | |
| 181 | + // Link to settings when API key is missing. | |
| 182 | + printf( | |
| 183 | + '<a href="%s" class="button button-secondary button-small" target="_blank">%s</a>', | |
| 184 | + esc_url(admin_url('admin.php?page=king-addons-ai-settings')), | |
| 185 | + esc_html__('Set API Key', 'king-addons') | |
| 186 | + ); | |
| 187 | + echo '<span class="king-addons-alt-text-result king-addons-status-inline"></span>'; | |
| 188 | + } | |
| 189 | + } | |
| 190 | + echo '</div>'; | |
| 191 | + } | |
| 192 | + | |
| 193 | + /** | |
| 194 | + * Handles the AJAX request to generate alt text for a single image. | |
| 195 | + */ | |
| 196 | + public function handle_ajax_generate_single_alt(): void { | |
| 197 | + check_ajax_referer('king_addons_generate_alt_nonce', 'nonce'); | |
| 198 | + | |
| 199 | + if (!current_user_can('upload_files')) { | |
| 200 | + wp_send_json_error(array('message' => esc_html__('Permission denied.', 'king-addons')), 403); | |
| 201 | + } | |
| 202 | + | |
| 203 | + // Check if API key exists | |
| 204 | + $options = get_option('king_addons_ai_options', []); | |
| 205 | + if ('' === \King_Addons\AI_Provider::getApiKey()) { | |
| 206 | + wp_send_json_error(array( | |
| 207 | + 'message' => esc_html__('OpenAI API key is not configured. Please set it in the AI settings.', 'king-addons'), | |
| 208 | + 'needs_setup' => true | |
| 209 | + ), 400); | |
| 210 | + } | |
| 211 | + | |
| 212 | + $attachment_id = isset($_POST['attachment_id']) ? intval($_POST['attachment_id']) : 0; | |
| 213 | + | |
| 214 | + if (!$attachment_id || !wp_attachment_is_image($attachment_id)) { | |
| 215 | + wp_send_json_error(array('message' => esc_html__('Invalid attachment ID.', 'king-addons')), 400); | |
| 216 | + } | |
| 217 | + | |
| 218 | + // Call the existing generation logic, but slightly refactored to return the result. | |
| 219 | + $result = $this->generate_alt_text_for_image($attachment_id, true); // Pass true to indicate AJAX context. | |
| 220 | + | |
| 221 | + if (is_wp_error($result)) { | |
| 222 | + wp_send_json_error(array('message' => $result->get_error_message()), 500); | |
| 223 | + } elseif ($result === false) { | |
| 224 | + // Handle cases where generation failed silently within the function (e.g., API key missing) | |
| 225 | + wp_send_json_error(array('message' => esc_html__('Alt text generation failed. Check logs or API key.', 'king-addons')), 500); | |
| 226 | + } elseif (is_string($result)) { | |
| 227 | + // Success! Return the generated alt text. | |
| 228 | + wp_send_json_success(array('alt_text' => $result)); | |
| 229 | + } else { | |
| 230 | + // Unexpected result. | |
| 231 | + wp_send_json_error(array('message' => esc_html__('An unexpected error occurred.', 'king-addons')), 500); | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + /** | |
| 236 | + * Handles the AJAX request to get queue status. | |
| 237 | + */ | |
| 238 | + public function handle_ajax_queue_status(): void { | |
| 239 | + check_ajax_referer('king_addons_generate_alt_nonce', 'nonce'); | |
| 240 | + | |
| 241 | + if (!current_user_can('manage_options')) { | |
| 242 | + wp_send_json_error(array('message' => esc_html__('Permission denied.', 'king-addons')), 403); | |
| 243 | + } | |
| 244 | + | |
| 245 | + $status = $this->get_queue_status(); | |
| 246 | + wp_send_json_success($status); | |
| 247 | + } | |
| 248 | + | |
| 249 | + /** | |
| 250 | + * Adds custom cron intervals. | |
| 251 | + * | |
| 252 | + * Note: Recommended interval is 60+ seconds to avoid provider API rate limits. | |
| 253 | + * Lower intervals may cause API errors during high usage periods. | |
| 254 | + * | |
| 255 | + * @param array $schedules Existing cron schedules. | |
| 256 | + * @return array Modified schedules. | |
| 257 | + */ | |
| 258 | + public function add_custom_cron_intervals(array $schedules): array { | |
| 259 | + // Get the interval from settings, default to 20 seconds | |
| 260 | + $options = get_option('king_addons_ai_options', []); | |
| 261 | + $interval = isset($options['ai_alt_text_generation_interval']) ? (int) $options['ai_alt_text_generation_interval'] : 20; | |
| 262 | + | |
| 263 | + // Ensure interval is within acceptable range | |
| 264 | + $interval = max(10, min(3600, $interval)); | |
| 265 | + | |
| 266 | + $schedules['king_addons_alt_text_interval'] = array( | |
| 267 | + 'interval' => $interval, | |
| 268 | + 'display' => sprintf(esc_html__('Every %d Seconds (King Addons Alt Text)', 'king-addons'), $interval) | |
| 269 | + ); | |
| 270 | + return $schedules; | |
| 271 | + } | |
| 272 | + | |
| 273 | + /** | |
| 274 | + * Adds a new attachment to the processing queue instead of immediate processing. | |
| 275 | + * | |
| 276 | + * @param int $attachment_id The ID of the attachment just added. | |
| 277 | + */ | |
| 278 | + public function add_to_processing_queue(int $attachment_id): void { | |
| 279 | + // Check if the attachment is an image. | |
| 280 | + if (!wp_attachment_is_image($attachment_id)) { | |
| 281 | + return; | |
| 282 | + } | |
| 283 | + | |
| 284 | + // Check if alt text already exists | |
| 285 | + $existing_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true); | |
| 286 | + if (!empty($existing_alt)) { | |
| 287 | + return; // Skip if alt text already exists | |
| 288 | + } | |
| 289 | + | |
| 290 | + // Get current queue | |
| 291 | + $queue = get_option(self::BATCH_OPTION_PENDING, []); | |
| 292 | + | |
| 293 | + // Add to queue if not already present | |
| 294 | + if (!in_array($attachment_id, $queue)) { | |
| 295 | + $queue[] = $attachment_id; | |
| 296 | + update_option(self::BATCH_OPTION_PENDING, $queue, false); | |
| 297 | + } | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** | |
| 301 | + * Processes the alt text generation queue in batches. | |
| 302 | + */ | |
| 303 | + public function process_alt_text_queue(): void { | |
| 304 | + // Check if already processing to avoid conflicts | |
| 305 | + $processing = get_option(self::BATCH_OPTION_PROCESSING, false); | |
| 306 | + if ($processing && (time() - $processing) < 300) { // 5 minute timeout | |
| 307 | + return; // Another process is already running | |
| 308 | + } | |
| 309 | + | |
| 310 | + // Set processing flag | |
| 311 | + update_option(self::BATCH_OPTION_PROCESSING, time(), false); | |
| 312 | + | |
| 313 | + try { | |
| 314 | + // Get pending queue | |
| 315 | + $queue = get_option(self::BATCH_OPTION_PENDING, []); | |
| 316 | + | |
| 317 | + if (empty($queue)) { | |
| 318 | + delete_option(self::BATCH_OPTION_PROCESSING); | |
| 319 | + return; // Nothing to process | |
| 320 | + } | |
| 321 | + | |
| 322 | + // Process batch | |
| 323 | + $batch = array_splice($queue, 0, self::BATCH_SIZE); | |
| 324 | + | |
| 325 | + foreach ($batch as $attachment_id) { | |
| 326 | + $this->process_single_attachment_with_retry($attachment_id); | |
| 327 | + | |
| 328 | + // Add delay between requests to respect rate limits | |
| 329 | + if (count($batch) > 1) { | |
| 330 | + sleep(2); // 2 second delay between individual requests | |
| 331 | + } | |
| 332 | + } | |
| 333 | + | |
| 334 | + // Update queue | |
| 335 | + update_option(self::BATCH_OPTION_PENDING, $queue, false); | |
| 336 | + | |
| 337 | + // Schedule next batch if queue is not empty | |
| 338 | + if (!empty($queue)) { | |
| 339 | + wp_schedule_single_event(time() + self::RATE_LIMIT_DELAY, self::CRON_HOOK); | |
| 340 | + } | |
| 341 | + | |
| 342 | + } finally { | |
| 343 | + // Always clear processing flag | |
| 344 | + delete_option(self::BATCH_OPTION_PROCESSING); | |
| 345 | + } | |
| 346 | + } | |
| 347 | + | |
| 348 | + /** | |
| 349 | + * Processes a single attachment with retry logic. | |
| 350 | + * | |
| 351 | + * @param int $attachment_id The attachment ID to process. | |
| 352 | + */ | |
| 353 | + private function process_single_attachment_with_retry(int $attachment_id): void { | |
| 354 | + $retry_count = get_post_meta($attachment_id, '_king_addons_alt_retry_count', true); | |
| 355 | + $retry_count = $retry_count ? (int) $retry_count : 0; | |
| 356 | + | |
| 357 | + if ($retry_count >= self::RETRY_LIMIT) { | |
| 358 | + // Max retries reached, skip this attachment | |
| 359 | + delete_post_meta($attachment_id, '_king_addons_alt_retry_count'); | |
| 360 | + return; | |
| 361 | + } | |
| 362 | + | |
| 363 | + $result = $this->generate_alt_text_for_image($attachment_id, false); | |
| 364 | + | |
| 365 | + if ($result !== true) { | |
| 366 | + // Generation failed, increment retry count | |
| 367 | + update_post_meta($attachment_id, '_king_addons_alt_retry_count', $retry_count + 1); | |
| 368 | + | |
| 369 | + // Add back to queue for retry (at the end) | |
| 370 | + $queue = get_option(self::BATCH_OPTION_PENDING, []); | |
| 371 | + if (!in_array($attachment_id, $queue)) { | |
| 372 | + $queue[] = $attachment_id; | |
| 373 | + update_option(self::BATCH_OPTION_PENDING, $queue, false); | |
| 374 | + } | |
| 375 | + } else { | |
| 376 | + // Success, clean up retry count | |
| 377 | + delete_post_meta($attachment_id, '_king_addons_alt_retry_count'); | |
| 378 | + } | |
| 379 | + } | |
| 380 | + | |
| 381 | + /** | |
| 382 | + * Generates alt text for a given image attachment using an AI service. | |
| 383 | + * | |
| 384 | + * @param int $attachment_id The ID of the attachment. | |
| 385 | + * @param bool $is_ajax Optional. Indicates if called via AJAX context. If true, returns result/error instead of void. | |
| 386 | + * @return bool|string|WP_Error Returns true on success (non-AJAX), generated alt text (string) on success (AJAX), specific error message (string) on failure (non-AJAX), or WP_Error on failure (AJAX). | |
| 387 | + */ | |
| 388 | + public function generate_alt_text_for_image(int $attachment_id, bool $is_ajax = false) { | |
| 389 | + // Verify it's an image. | |
| 390 | + if (!wp_attachment_is_image($attachment_id)) { | |
| 391 | + $error_msg = esc_html__('Not an image.', 'king-addons'); | |
| 392 | + return $is_ajax ? new \WP_Error('invalid_attachment', $error_msg) : $error_msg; | |
| 393 | + } | |
| 394 | + | |
| 395 | + // Check if alt text already exists (only if not forced, future enhancement). | |
| 396 | + $existing_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true); | |
| 397 | + if (!empty($existing_alt)) { | |
| 398 | + // If called via AJAX, maybe return existing text or an indication it wasn't generated. | |
| 399 | + // For now, we assume the button won't be shown if alt exists, so this path is mainly for the cron job. | |
| 400 | + return $is_ajax ? $existing_alt : true; | |
| 401 | + } | |
| 402 | + | |
| 403 | + // Retrieve the API key and settings of the selected AI provider. | |
| 404 | + $options = get_option('king_addons_ai_options', []); | |
| 405 | + $api_key = \King_Addons\AI_Provider::getApiKey(); | |
| 406 | + // Use dedicated vision model for image analysis (fallback to text model). | |
| 407 | + $model = \King_Addons\AI_Provider::getVisionModel(); | |
| 408 | + // Get image detail level from settings (default to 'low') | |
| 409 | + $image_detail_level = $options['ai_alt_text_image_detail_level'] ?? 'low'; | |
| 410 | + | |
| 411 | + if (empty($api_key)) { | |
| 412 | + $error_msg = sprintf( | |
| 413 | + /* translators: %s: provider name */ | |
| 414 | + esc_html__('%s API key is missing.', 'king-addons'), | |
| 415 | + \King_Addons\AI_Provider::getLabel() | |
| 416 | + ); | |
| 417 | + return $is_ajax ? new \WP_Error('missing_api_key', $error_msg) : $error_msg; | |
| 418 | + } | |
| 419 | + | |
| 420 | + // Get image path instead of URL. | |
| 421 | + $image_path = get_attached_file($attachment_id); | |
| 422 | + if (!$image_path || !file_exists($image_path)) { | |
| 423 | + $error_msg = esc_html__('Could not retrieve image file path.', 'king-addons'); | |
| 424 | + return $is_ajax ? new \WP_Error('no_image_path', $error_msg) : $error_msg; | |
| 425 | + } | |
| 426 | + | |
| 427 | + // Read image data. | |
| 428 | + $image_data = file_get_contents($image_path); | |
| 429 | + if (false === $image_data) { | |
| 430 | + $error_msg = esc_html__('Could not read image file.', 'king-addons'); | |
| 431 | + return $is_ajax ? new \WP_Error('read_image_failed', $error_msg) : $error_msg; | |
| 432 | + } | |
| 433 | + | |
| 434 | + // Get MIME type. | |
| 435 | + $file_info = wp_check_filetype(basename($image_path)); | |
| 436 | + if (!$file_info || empty($file_info['type'])) { | |
| 437 | + $error_msg = esc_html__('Could not determine image type.', 'king-addons'); | |
| 438 | + return $is_ajax ? new \WP_Error('mime_type_failed', $error_msg) : $error_msg; | |
| 439 | + } | |
| 440 | + $mime_type = $file_info['type']; | |
| 441 | + | |
| 442 | + // Encode image data in Base64. | |
| 443 | + $base64_image = base64_encode($image_data); | |
| 444 | + | |
| 445 | + // Create the data URI. | |
| 446 | + $image_data_uri = "data:{$mime_type};base64,{$base64_image}"; | |
| 447 | + | |
| 448 | + // --- AI provider API call --- // | |
| 449 | + $api_endpoint = \King_Addons\AI_Provider::getChatEndpoint(); | |
| 450 | + | |
| 451 | + $options_for_lang = get_option('king_addons_ai_options', []); | |
| 452 | + $custom_lang_enabled = !empty($options_for_lang['content_language_custom_enable']); | |
| 453 | + $custom_lang = trim($options_for_lang['content_language_custom'] ?? ''); | |
| 454 | + | |
| 455 | + $prompt_text = 'Generate a concise, descriptive alt text for this image, suitable for SEO and accessibility. Focus on the main subject and action. Maximum 125 characters.'; | |
| 456 | + if ($custom_lang_enabled && $custom_lang !== '') { | |
| 457 | + $prompt_text .= ' Use language: ' . $custom_lang; | |
| 458 | + } else { | |
| 459 | + $prompt_text .= ' Respond in English only.'; | |
| 460 | + } | |
| 461 | + | |
| 462 | + $payload = array( | |
| 463 | + 'model' => $model, | |
| 464 | + 'messages' => array( | |
| 465 | + array( | |
| 466 | + 'role' => 'user', | |
| 467 | + 'content' => array( | |
| 468 | + array( | |
| 469 | + 'type' => 'text', | |
| 470 | + 'text' => $prompt_text | |
| 471 | + ), | |
| 472 | + array( | |
| 473 | + 'type' => 'image_url', | |
| 474 | + 'image_url' => array( | |
| 475 | + 'url' => $image_data_uri, | |
| 476 | + 'detail' => $image_detail_level | |
| 477 | + ) | |
| 478 | + ) | |
| 479 | + ) | |
| 480 | + ) | |
| 481 | + ), | |
| 482 | + 'max_tokens' => 50 // Limit the response length | |
| 483 | + ); | |
| 484 | + | |
| 485 | + $args = array( | |
| 486 | + 'headers' => \King_Addons\AI_Provider::getHeaders(), | |
| 487 | + 'body' => wp_json_encode(\King_Addons\AI_Provider::prepareChatPayload($payload)), | |
| 488 | + 'timeout' => 60, // Increased timeout | |
| 489 | + 'method' => 'POST', | |
| 490 | + 'data_format' => 'body', | |
| 491 | + ); | |
| 492 | + | |
| 493 | + $response = wp_remote_post($api_endpoint, $args); | |
| 494 | + | |
| 495 | + // Handle the response. | |
| 496 | + if (is_wp_error($response)) { | |
| 497 | + $error_message = $response->get_error_message(); | |
| 498 | + /* translators: %s: Error message returned from the network request. */ | |
| 499 | + return $is_ajax ? $response : sprintf(esc_html__('Network error: %s', 'king-addons'), $error_message); | |
| 500 | + } | |
| 501 | + | |
| 502 | + $response_code = wp_remote_retrieve_response_code($response); | |
| 503 | + $response_body = wp_remote_retrieve_body($response); | |
| 504 | + $decoded_body = json_decode($response_body, true); | |
| 505 | + | |
| 506 | + if ($response_code !== 200 || isset($decoded_body['error'])) { | |
| 507 | + $api_error_message = \King_Addons\AI_Provider::extractErrorMessage($decoded_body, esc_html__('API request failed or returned unexpected data.', 'king-addons')); | |
| 508 | + /* translators: 1: HTTP response code, 2: API error message. */ | |
| 509 | + return $is_ajax ? new \WP_Error('api_error', $api_error_message, array('status' => $response_code)) : sprintf(esc_html__('API error (%1$d): %2$s', 'king-addons'), $response_code, $api_error_message); | |
| 510 | + } | |
| 511 | + | |
| 512 | + $generated_alt_text = \King_Addons\AI_Provider::extractMessageContent($decoded_body); | |
| 513 | + if (is_wp_error($generated_alt_text)) { | |
| 514 | + return $is_ajax ? $generated_alt_text : $generated_alt_text->get_error_message(); | |
| 515 | + } | |
| 516 | + // --- End API Call --- // | |
| 517 | + | |
| 518 | + // If the response starts with "I'm sorry" (case-insensitive, allow whitespace before), treat as error and do not save | |
| 519 | + if (preg_match('/^\s*I\'m sorry/i', $generated_alt_text)) { | |
| 520 | + $generated_alt_text = ''; | |
| 521 | + } | |
| 522 | + | |
| 523 | + // Sanitize and potentially trim the generated alt text. | |
| 524 | + $sanitized_alt_text = sanitize_text_field(trim($generated_alt_text)); | |
| 525 | + $sanitized_alt_text = trim($sanitized_alt_text, '"'); // Remove surrounding quotes | |
| 526 | + $sanitized_alt_text = mb_substr($sanitized_alt_text, 0, 125); // Enforce length limit | |
| 527 | + | |
| 528 | + // Update the image alt text meta data. | |
| 529 | + if (!empty($sanitized_alt_text)) { | |
| 530 | + if (update_post_meta($attachment_id, '_wp_attachment_image_alt', $sanitized_alt_text)) { | |
| 531 | + // Log success for debugging | |
| 532 | + // error_log("King Addons Alt Text: Successfully generated alt text for attachment {$attachment_id}: {$sanitized_alt_text}"); | |
| 533 | + return $is_ajax ? $sanitized_alt_text : true; | |
| 534 | + } else { | |
| 535 | + $error_msg = esc_html__('Failed to save the generated alt text.', 'king-addons'); | |
| 536 | + // error_log("King Addons Alt Text: Failed to save alt text for attachment {$attachment_id}"); | |
| 537 | + return $is_ajax ? new \WP_Error('update_failed', $error_msg) : $error_msg; | |
| 538 | + } | |
| 539 | + } else { | |
| 540 | + $error_msg = esc_html__('AI returned empty or invalid text.', 'king-addons'); | |
| 541 | + // error_log("King Addons Alt Text: AI returned empty text for attachment {$attachment_id}"); | |
| 542 | + return $is_ajax ? new \WP_Error('empty_alt_text', $error_msg) : $error_msg; | |
| 543 | + } | |
| 544 | + | |
| 545 | + // Should not be reached in normal flow due to checks above | |
| 546 | + $error_msg = esc_html__('An unknown error occurred during generation.', 'king-addons'); | |
| 547 | + return $is_ajax ? new \WP_Error('unknown_error', $error_msg) : $error_msg; | |
| 548 | + } | |
| 549 | + | |
| 550 | + /** | |
| 551 | + * Retrieves statistics about image alt text in the Media Library. | |
| 552 | + * | |
| 553 | + * @return array{total:int,with_alt:int,without_alt:int} | |
| 554 | + */ | |
| 555 | + public static function get_alt_text_stats(): array | |
| 556 | + { | |
| 557 | + $cache_key = 'king_addons_alt_text_stats'; | |
| 558 | + $cached = wp_cache_get($cache_key, 'king-addons'); | |
| 559 | + if (false !== $cached && is_array($cached)) { | |
| 560 | + return $cached; | |
| 561 | + } | |
| 562 | + | |
| 563 | + $all_images = get_posts([ | |
| 564 | + 'post_type' => 'attachment', | |
| 565 | + 'post_mime_type' => 'image', | |
| 566 | + 'post_status' => 'inherit', | |
| 567 | + 'posts_per_page' => -1, | |
| 568 | + 'fields' => 'ids', | |
| 569 | + 'no_found_rows' => true, | |
| 570 | + ]); | |
| 571 | + | |
| 572 | + $with_alt = get_posts([ | |
| 573 | + 'post_type' => 'attachment', | |
| 574 | + 'post_mime_type' => 'image', | |
| 575 | + 'post_status' => 'inherit', | |
| 576 | + 'posts_per_page' => -1, | |
| 577 | + 'fields' => 'ids', | |
| 578 | + 'no_found_rows' => true, | |
| 579 | + 'meta_query' => [ | |
| 580 | + [ | |
| 581 | + 'key' => '_wp_attachment_image_alt', | |
| 582 | + 'value' => '', | |
| 583 | + 'compare' => '!=', | |
| 584 | + ], | |
| 585 | + ], | |
| 586 | + ]); | |
| 587 | + | |
| 588 | + $total = count($all_images); | |
| 589 | + $with_alt_count = count($with_alt); | |
| 590 | + $result = [ | |
| 591 | + 'total' => $total, | |
| 592 | + 'with_alt' => $with_alt_count, | |
| 593 | + 'without_alt' => max(0, $total - $with_alt_count), | |
| 594 | + ]; | |
| 595 | + | |
| 596 | + wp_cache_set($cache_key, $result, 'king-addons', 300); | |
| 597 | + return $result; | |
| 598 | + } | |
| 599 | + | |
| 600 | + /** | |
| 601 | + * Cleans up cron jobs and options on plugin deactivation. | |
| 602 | + */ | |
| 603 | + public function cleanup_cron_jobs(): void { | |
| 604 | + // Clear scheduled cron jobs | |
| 605 | + $timestamp = wp_next_scheduled(self::CRON_HOOK); | |
| 606 | + if ($timestamp) { | |
| 607 | + wp_unschedule_event($timestamp, self::CRON_HOOK); | |
| 608 | + } | |
| 609 | + | |
| 610 | + // Clear options | |
| 611 | + delete_option(self::BATCH_OPTION_PENDING); | |
| 612 | + delete_option(self::BATCH_OPTION_PROCESSING); | |
| 613 | + | |
| 614 | + // Clean up retry count meta for all attachments | |
| 615 | + global $wpdb; | |
| 616 | + $wpdb->delete( | |
| 617 | + $wpdb->postmeta, | |
| 618 | + array('meta_key' => '_king_addons_alt_retry_count'), | |
| 619 | + array('%s') | |
| 620 | + ); | |
| 621 | + } | |
| 622 | + | |
| 623 | + /** | |
| 624 | + * Updates the cron schedule if the relevant settings change. | |
| 625 | + * | |
| 626 | + * @param mixed $old_value The old value. | |
| 627 | + * @param mixed $new_value The new value. | |
| 628 | + * @param string $option The option name. | |
| 629 | + */ | |
| 630 | + public function update_cron_schedule($old_value, $new_value, string $option = 'king_addons_ai_options'): void { | |
| 631 | + | |
| 632 | + // Check if interval setting changed | |
| 633 | + $old_interval = isset($old_value['ai_alt_text_generation_interval']) ? (int) $old_value['ai_alt_text_generation_interval'] : 20; | |
| 634 | + $new_interval = isset($new_value['ai_alt_text_generation_interval']) ? (int) $new_value['ai_alt_text_generation_interval'] : 20; | |
| 635 | + | |
| 636 | + // Check if auto generation setting changed | |
| 637 | + $old_auto = isset($old_value['enable_ai_alt_text_auto_generation']) ? (bool) $old_value['enable_ai_alt_text_auto_generation'] : false; | |
| 638 | + $new_auto = isset($new_value['enable_ai_alt_text_auto_generation']) ? (bool) $new_value['enable_ai_alt_text_auto_generation'] : false; | |
| 639 | + | |
| 640 | + if ($old_interval !== $new_interval || $old_auto !== $new_auto) { | |
| 641 | + // Clear existing cron job | |
| 642 | + $timestamp = wp_next_scheduled(self::CRON_HOOK); | |
| 643 | + if ($timestamp) { | |
| 644 | + wp_unschedule_event($timestamp, self::CRON_HOOK); | |
| 645 | + } | |
| 646 | + | |
| 647 | + // Reschedule if auto generation is enabled | |
| 648 | + if ($new_auto) { | |
| 649 | + wp_schedule_event(time(), 'king_addons_alt_text_interval', self::CRON_HOOK); | |
| 650 | + } | |
| 651 | + } | |
| 652 | + } | |
| 653 | + | |
| 654 | + /** | |
| 655 | + * Gets queue status for debugging purposes. | |
| 656 | + * | |
| 657 | + * @return array Queue status information. | |
| 658 | + */ | |
| 659 | + public function get_queue_status(): array { | |
| 660 | + $queue = get_option(self::BATCH_OPTION_PENDING, []); | |
| 661 | + $processing = get_option(self::BATCH_OPTION_PROCESSING, false); | |
| 662 | + $next_scheduled = wp_next_scheduled(self::CRON_HOOK); | |
| 663 | + | |
| 664 | + return array( | |
| 665 | + 'pending_count' => count($queue), | |
| 666 | + 'pending_ids' => $queue, | |
| 667 | + 'is_processing' => $processing ? true : false, | |
| 668 | + 'processing_since' => $processing ? date('Y-m-d H:i:s', $processing) : null, | |
| 669 | + 'next_run' => $next_scheduled ? date('Y-m-d H:i:s', $next_scheduled) : null, | |
| 670 | + ); | |
| 671 | + } | |
| 672 | +} | |