can_use_premium_code()){
add_action('add_attachment', array($this, 'add_to_processing_queue'));
add_action(self::CRON_HOOK, array($this, 'process_alt_text_queue'));
// Add custom cron interval
add_filter('cron_schedules', array($this, 'add_custom_cron_intervals'));
// Schedule recurring cron job if not already scheduled
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'king_addons_alt_text_interval', self::CRON_HOOK);
}
}
}
// Hooks for Media Library integration (always enable if button is enabled, regardless of API key).
if ($button_enabled) {
add_filter('manage_media_columns', array($this, 'add_alt_text_column'));
add_action('manage_media_custom_column', array($this, 'display_alt_text_column'), 10, 2);
add_action('admin_enqueue_scripts', array($this, 'enqueue_media_scripts'));
// AJAX handler for manual generation (only if API key exists).
if ($has_api_key) {
add_action('wp_ajax_king_addons_generate_single_alt', array($this, 'handle_ajax_generate_single_alt'));
}
// AJAX handler for queue status (debugging)
add_action('wp_ajax_king_addons_alt_text_queue_status', array($this, 'handle_ajax_queue_status'));
}
// Hook for cleanup on plugin deactivation
register_deactivation_hook(KING_ADDONS_PATH . 'king-addons.php', array($this, 'cleanup_cron_jobs'));
// Hook to update cron schedule when settings change
add_action('update_option_king_addons_ai_options', array($this, 'update_cron_schedule'), 10, 3);
}
/**
* Enqueues scripts needed for the media library screen.
*
* @param string $hook The current admin page hook.
*/
public function enqueue_media_scripts(string $hook): void {
// Load on the upload.php screen (Media Library List view) and post editing screens (for media modal)
if (!in_array($hook, ['upload.php', 'post.php', 'post-new.php', 'page.php', 'page-new.php'])) {
return;
}
wp_enqueue_style(
'king-addons-media-alt-text-styles',
KING_ADDONS_URL . 'includes/extensions/alt-text-generator/alt-text-styles.css',
array(),
KING_ADDONS_VERSION
);
wp_enqueue_script(
'king-addons-media-alt-text',
KING_ADDONS_URL . 'includes/extensions/alt-text-generator/alt-text-media.js',
array('jquery'),
KING_ADDONS_VERSION,
true
);
// Check if API key exists
$options = get_option('king_addons_ai_options', []);
$has_api_key = !empty($options['openai_api_key']);
// Pass data to JavaScript.
wp_localize_script('king-addons-media-alt-text', 'kingAddonsMediaAltText', array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('king_addons_generate_alt_nonce'),
'generating_text' => esc_html__('Generate with AI', 'king-addons'),
'error_text' => esc_html__('Error', 'king-addons'),
'has_api_key' => $has_api_key,
'settings_url' => admin_url('admin.php?page=king-addons-ai-settings'),
));
}
/**
* Adds a custom column to the Media Library list view.
*
* @param array $columns Existing columns.
* @return array Modified columns.
*/
public function add_alt_text_column(array $columns): array {
// Add column before 'Date'.
$new_columns = array();
foreach ($columns as $key => $title) {
if ('date' === $key) {
$new_columns['king_addons_alt_text'] = esc_html__('AI Alt Text', 'king-addons');
}
$new_columns[$key] = $title;
}
// If 'date' column wasn't found, add it at the end.
if (!isset($new_columns['king_addons_alt_text'])) {
$new_columns['king_addons_alt_text'] = esc_html__('AI Alt Text', 'king-addons');
}
return $new_columns;
}
/**
* Displays content for the custom alt text column.
*
* @param string $column_name The name of the column being displayed.
* @param int $attachment_id The ID of the current attachment.
*/
public function display_alt_text_column(string $column_name, int $attachment_id): void {
if ('king_addons_alt_text' !== $column_name) {
return;
}
// Only show for images.
if (!wp_attachment_is_image($attachment_id)) {
echo '—'; // Not an image
return;
}
$alt_text = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
$options = get_option('king_addons_ai_options', []);
$has_api_key = !empty($options['openai_api_key']);
echo '
';
if (!empty($alt_text)) {
echo '' . esc_html($alt_text) . '';
} else {
if ($has_api_key) {
// Button to generate alt text when API key exists.
printf(
'',
esc_html__('Generate', 'king-addons')
);
echo ''; // For displaying results/errors
echo '';
} else {
// Link to settings when API key is missing.
printf(
'%s',
esc_url(admin_url('admin.php?page=king-addons-ai-settings')),
esc_html__('Set API Key', 'king-addons')
);
echo '';
}
}
echo '
';
}
/**
* Handles the AJAX request to generate alt text for a single image.
*/
public function handle_ajax_generate_single_alt(): void {
check_ajax_referer('king_addons_generate_alt_nonce', 'nonce');
if (!current_user_can('upload_files')) {
wp_send_json_error(array('message' => esc_html__('Permission denied.', 'king-addons')), 403);
}
// Check if API key exists
$options = get_option('king_addons_ai_options', []);
if (empty($options['openai_api_key'])) {
wp_send_json_error(array(
'message' => esc_html__('OpenAI API key is not configured. Please set it in the AI settings.', 'king-addons'),
'needs_setup' => true
), 400);
}
$attachment_id = isset($_POST['attachment_id']) ? intval($_POST['attachment_id']) : 0;
if (!$attachment_id || !wp_attachment_is_image($attachment_id)) {
wp_send_json_error(array('message' => esc_html__('Invalid attachment ID.', 'king-addons')), 400);
}
// Call the existing generation logic, but slightly refactored to return the result.
$result = $this->generate_alt_text_for_image($attachment_id, true); // Pass true to indicate AJAX context.
if (is_wp_error($result)) {
wp_send_json_error(array('message' => $result->get_error_message()), 500);
} elseif ($result === false) {
// Handle cases where generation failed silently within the function (e.g., API key missing)
wp_send_json_error(array('message' => esc_html__('Alt text generation failed. Check logs or API key.', 'king-addons')), 500);
} elseif (is_string($result)) {
// Success! Return the generated alt text.
wp_send_json_success(array('alt_text' => $result));
} else {
// Unexpected result.
wp_send_json_error(array('message' => esc_html__('An unexpected error occurred.', 'king-addons')), 500);
}
}
/**
* Handles the AJAX request to get queue status.
*/
public function handle_ajax_queue_status(): void {
check_ajax_referer('king_addons_generate_alt_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => esc_html__('Permission denied.', 'king-addons')), 403);
}
$status = $this->get_queue_status();
wp_send_json_success($status);
}
/**
* Adds custom cron intervals.
*
* Note: Recommended interval is 60+ seconds to avoid OpenAI API rate limits.
* Lower intervals may cause API errors during high usage periods.
*
* @param array $schedules Existing cron schedules.
* @return array Modified schedules.
*/
public function add_custom_cron_intervals(array $schedules): array {
// Get the interval from settings, default to 60 seconds
$options = get_option('king_addons_ai_options', []);
$interval = isset($options['ai_alt_text_generation_interval']) ? (int) $options['ai_alt_text_generation_interval'] : 60;
// Ensure interval is within acceptable range
$interval = max(10, min(3600, $interval));
$schedules['king_addons_alt_text_interval'] = array(
'interval' => $interval,
'display' => sprintf(esc_html__('Every %d Seconds (King Addons Alt Text)', 'king-addons'), $interval)
);
return $schedules;
}
/**
* Adds a new attachment to the processing queue instead of immediate processing.
*
* @param int $attachment_id The ID of the attachment just added.
*/
public function add_to_processing_queue(int $attachment_id): void {
// Check if the attachment is an image.
if (!wp_attachment_is_image($attachment_id)) {
return;
}
// Check if alt text already exists
$existing_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
if (!empty($existing_alt)) {
return; // Skip if alt text already exists
}
// Get current queue
$queue = get_option(self::BATCH_OPTION_PENDING, []);
// Add to queue if not already present
if (!in_array($attachment_id, $queue)) {
$queue[] = $attachment_id;
update_option(self::BATCH_OPTION_PENDING, $queue, false);
}
}
/**
* Processes the alt text generation queue in batches.
*/
public function process_alt_text_queue(): void {
// Check if already processing to avoid conflicts
$processing = get_option(self::BATCH_OPTION_PROCESSING, false);
if ($processing && (time() - $processing) < 300) { // 5 minute timeout
return; // Another process is already running
}
// Set processing flag
update_option(self::BATCH_OPTION_PROCESSING, time(), false);
try {
// Get pending queue
$queue = get_option(self::BATCH_OPTION_PENDING, []);
if (empty($queue)) {
delete_option(self::BATCH_OPTION_PROCESSING);
return; // Nothing to process
}
// Process batch
$batch = array_splice($queue, 0, self::BATCH_SIZE);
foreach ($batch as $attachment_id) {
$this->process_single_attachment_with_retry($attachment_id);
// Add delay between requests to respect rate limits
if (count($batch) > 1) {
sleep(2); // 2 second delay between individual requests
}
}
// Update queue
update_option(self::BATCH_OPTION_PENDING, $queue, false);
// Schedule next batch if queue is not empty
if (!empty($queue)) {
wp_schedule_single_event(time() + self::RATE_LIMIT_DELAY, self::CRON_HOOK);
}
} finally {
// Always clear processing flag
delete_option(self::BATCH_OPTION_PROCESSING);
}
}
/**
* Processes a single attachment with retry logic.
*
* @param int $attachment_id The attachment ID to process.
*/
private function process_single_attachment_with_retry(int $attachment_id): void {
$retry_count = get_post_meta($attachment_id, '_king_addons_alt_retry_count', true);
$retry_count = $retry_count ? (int) $retry_count : 0;
if ($retry_count >= self::RETRY_LIMIT) {
// Max retries reached, skip this attachment
delete_post_meta($attachment_id, '_king_addons_alt_retry_count');
return;
}
$result = $this->generate_alt_text_for_image($attachment_id, false);
if ($result !== true) {
// Generation failed, increment retry count
update_post_meta($attachment_id, '_king_addons_alt_retry_count', $retry_count + 1);
// Add back to queue for retry (at the end)
$queue = get_option(self::BATCH_OPTION_PENDING, []);
if (!in_array($attachment_id, $queue)) {
$queue[] = $attachment_id;
update_option(self::BATCH_OPTION_PENDING, $queue, false);
}
} else {
// Success, clean up retry count
delete_post_meta($attachment_id, '_king_addons_alt_retry_count');
}
}
/**
* Generates alt text for a given image attachment using an AI service.
*
* @param int $attachment_id The ID of the attachment.
* @param bool $is_ajax Optional. Indicates if called via AJAX context. If true, returns result/error instead of void.
* @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).
*/
public function generate_alt_text_for_image(int $attachment_id, bool $is_ajax = false) {
// Verify it's an image.
if (!wp_attachment_is_image($attachment_id)) {
$error_msg = esc_html__('Not an image.', 'king-addons');
return $is_ajax ? new \WP_Error('invalid_attachment', $error_msg) : $error_msg;
}
// Check if alt text already exists (only if not forced, future enhancement).
$existing_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
if (!empty($existing_alt)) {
// If called via AJAX, maybe return existing text or an indication it wasn't generated.
// For now, we assume the button won't be shown if alt exists, so this path is mainly for the cron job.
return $is_ajax ? $existing_alt : true;
}
// Retrieve OpenAI API key and settings from King Addons AI options.
$options = get_option('king_addons_ai_options', []);
$api_key = $options['openai_api_key'] ?? '';
// Use the text model for vision analysis, not the image generation model
$model = $options['openai_model'] ?? 'gpt-4o';
// Get image detail level from settings (default to 'low')
$image_detail_level = $options['ai_alt_text_image_detail_level'] ?? 'low';
if (empty($api_key)) {
$error_msg = esc_html__('OpenAI API key is missing.', 'king-addons');
return $is_ajax ? new \WP_Error('missing_api_key', $error_msg) : $error_msg;
}
// Get image path instead of URL.
$image_path = get_attached_file($attachment_id);
if (!$image_path || !file_exists($image_path)) {
$error_msg = esc_html__('Could not retrieve image file path.', 'king-addons');
return $is_ajax ? new \WP_Error('no_image_path', $error_msg) : $error_msg;
}
// Read image data.
$image_data = file_get_contents($image_path);
if (false === $image_data) {
$error_msg = esc_html__('Could not read image file.', 'king-addons');
return $is_ajax ? new \WP_Error('read_image_failed', $error_msg) : $error_msg;
}
// Get MIME type.
$file_info = wp_check_filetype(basename($image_path));
if (!$file_info || empty($file_info['type'])) {
$error_msg = esc_html__('Could not determine image type.', 'king-addons');
return $is_ajax ? new \WP_Error('mime_type_failed', $error_msg) : $error_msg;
}
$mime_type = $file_info['type'];
// Encode image data in Base64.
$base64_image = base64_encode($image_data);
// Create the data URI.
$image_data_uri = "data:{$mime_type};base64,{$base64_image}";
// --- OpenAI API Call --- //
$api_endpoint = 'https://api.openai.com/v1/chat/completions';
$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.';
$payload = array(
'model' => $model,
'messages' => array(
array(
'role' => 'user',
'content' => array(
array(
'type' => 'text',
'text' => $prompt_text
),
array(
'type' => 'image_url',
'image_url' => array(
'url' => $image_data_uri,
'detail' => $image_detail_level
)
)
)
)
),
'max_tokens' => 50 // Limit the response length
);
$args = array(
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode($payload),
'timeout' => 60, // Increased timeout
'method' => 'POST',
'data_format' => 'body',
);
$response = wp_remote_post($api_endpoint, $args);
// Handle the response.
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
/* translators: %s: Error message returned from the network request. */
return $is_ajax ? $response : sprintf(esc_html__('Network error: %s', 'king-addons'), $error_message);
}
$response_code = wp_remote_retrieve_response_code($response);
$response_body = wp_remote_retrieve_body($response);
$decoded_body = json_decode($response_body, true);
if ($response_code !== 200 || !isset($decoded_body['choices'][0]['message']['content'])) {
$api_error_message = isset($decoded_body['error']['message']) ? $decoded_body['error']['message'] : esc_html__('API request failed or returned unexpected data.', 'king-addons');
/* translators: 1: HTTP response code, 2: API error message. */
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);
}
$generated_alt_text = $decoded_body['choices'][0]['message']['content'];
// --- End API Call --- //
// If the response starts with "I'm sorry" (case-insensitive, allow whitespace before), treat as error and do not save
if (preg_match('/^\s*I\'m sorry/i', $generated_alt_text)) {
$generated_alt_text = '';
}
// Sanitize and potentially trim the generated alt text.
$sanitized_alt_text = sanitize_text_field(trim($generated_alt_text));
$sanitized_alt_text = trim($sanitized_alt_text, '"'); // Remove surrounding quotes
$sanitized_alt_text = mb_substr($sanitized_alt_text, 0, 125); // Enforce length limit
// Update the image alt text meta data.
if (!empty($sanitized_alt_text)) {
if (update_post_meta($attachment_id, '_wp_attachment_image_alt', $sanitized_alt_text)) {
// Log success for debugging
error_log("King Addons Alt Text: Successfully generated alt text for attachment {$attachment_id}: {$sanitized_alt_text}");
return $is_ajax ? $sanitized_alt_text : true;
} else {
$error_msg = esc_html__('Failed to save the generated alt text.', 'king-addons');
error_log("King Addons Alt Text: Failed to save alt text for attachment {$attachment_id}");
return $is_ajax ? new \WP_Error('update_failed', $error_msg) : $error_msg;
}
} else {
$error_msg = esc_html__('AI returned empty or invalid text.', 'king-addons');
error_log("King Addons Alt Text: AI returned empty text for attachment {$attachment_id}");
return $is_ajax ? new \WP_Error('empty_alt_text', $error_msg) : $error_msg;
}
// Should not be reached in normal flow due to checks above
$error_msg = esc_html__('An unknown error occurred during generation.', 'king-addons');
return $is_ajax ? new \WP_Error('unknown_error', $error_msg) : $error_msg;
}
/**
* Cleans up cron jobs and options on plugin deactivation.
*/
public function cleanup_cron_jobs(): void {
// Clear scheduled cron jobs
$timestamp = wp_next_scheduled(self::CRON_HOOK);
if ($timestamp) {
wp_unschedule_event($timestamp, self::CRON_HOOK);
}
// Clear options
delete_option(self::BATCH_OPTION_PENDING);
delete_option(self::BATCH_OPTION_PROCESSING);
// Clean up retry count meta for all attachments
global $wpdb;
$wpdb->delete(
$wpdb->postmeta,
array('meta_key' => '_king_addons_alt_retry_count'),
array('%s')
);
}
/**
* Updates the cron schedule if the relevant settings change.
*
* @param mixed $old_value The old value.
* @param mixed $new_value The new value.
* @param string $option The option name.
*/
public function update_cron_schedule($old_value, $new_value, string $option = 'king_addons_ai_options'): void {
// Check if interval setting changed
$old_interval = isset($old_value['ai_alt_text_generation_interval']) ? (int) $old_value['ai_alt_text_generation_interval'] : 60;
$new_interval = isset($new_value['ai_alt_text_generation_interval']) ? (int) $new_value['ai_alt_text_generation_interval'] : 60;
// Check if auto generation setting changed
$old_auto = isset($old_value['enable_ai_alt_text_auto_generation']) ? (bool) $old_value['enable_ai_alt_text_auto_generation'] : false;
$new_auto = isset($new_value['enable_ai_alt_text_auto_generation']) ? (bool) $new_value['enable_ai_alt_text_auto_generation'] : false;
if ($old_interval !== $new_interval || $old_auto !== $new_auto) {
// Clear existing cron job
$timestamp = wp_next_scheduled(self::CRON_HOOK);
if ($timestamp) {
wp_unschedule_event($timestamp, self::CRON_HOOK);
}
// Reschedule if auto generation is enabled
if ($new_auto) {
wp_schedule_event(time(), 'king_addons_alt_text_interval', self::CRON_HOOK);
}
}
}
/**
* Gets queue status for debugging purposes.
*
* @return array Queue status information.
*/
public function get_queue_status(): array {
$queue = get_option(self::BATCH_OPTION_PENDING, []);
$processing = get_option(self::BATCH_OPTION_PROCESSING, false);
$next_scheduled = wp_next_scheduled(self::CRON_HOOK);
return array(
'pending_count' => count($queue),
'pending_ids' => $queue,
'is_processing' => $processing ? true : false,
'processing_since' => $processing ? date('Y-m-d H:i:s', $processing) : null,
'next_run' => $next_scheduled ? date('Y-m-d H:i:s', $next_scheduled) : null,
);
}
}