PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.83
51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 All 39 releases
← All changes | includes/extensions/alt-text-generator/Alt_Text_Generator.php +672 -0 51.1.251.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 +}