json_error('forbidden', 403); } check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); $lang = isset($_POST['lang']) ? sanitize_text_field(wp_unslash($_POST['lang'])) : ''; $provider = isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : ''; if ($provider === '') { $provider = AIGUDE_Tools_Plugin::get_translation_provider(); } if ($lang === '') { $this->json_error(__('Invalid request', 'aigude-tools')); } $norm = AIGUDE_Tools_Plugin::resolve_target_lang_code($lang, $provider !== '' ? $provider : null); update_option('aigude_target_language', $norm); AIGUDE_Tools_Plugin::push_recent_lang('target', $norm, $provider); $this->json_ok('Language saved: ' . $norm); } /** * Return IDs of attachments matching the list filter context (used by “select all”). */ public function ajax_list_ids(): void { if (!current_user_can('upload_files')) { $this->json_error('forbidden', 403); } check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); $search = isset($_POST['s']) ? sanitize_text_field(wp_unslash($_POST['s'])) : ''; $skipExisting = !empty($_POST['skipExisting']); $args = [ 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => AIGUDE_Tools_Plugin::get_image_mime_types(), 'fields' => 'ids', 'posts_per_page' => -1, 's' => $search, 'orderby' => 'date', 'order' => 'DESC', 'ai_tools_list' => 1, ]; if ($skipExisting) { $args['meta_query'] = [[ 'relation' => 'OR', ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], ]]; } $ids = get_posts($args); $this->json_ok([ 'ids' => array_map('intval', is_array($ids) ? $ids : []), 'count' => is_array($ids) ? count($ids) : 0, ]); } /** * Generate alt text for a single attachment via the Img2Desc API. */ public function ajax_generate_single(): void { if (!current_user_can('upload_files')) { $this->json_error('forbidden', 403); } check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); $id = isset($_POST['id']) ? absint($_POST['id']) : 0; $prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : ''; $prompt_lang = isset($_POST['prompt_lang']) ? sanitize_text_field(wp_unslash($_POST['prompt_lang'])) : 'auto'; $tpl_src_lang = isset($_POST['tpl_src_lang']) ? sanitize_text_field(wp_unslash($_POST['tpl_src_lang'])) : 'auto'; $langCode = isset($_POST['lang']) ? sanitize_text_field(wp_unslash($_POST['lang'])) : get_option('aigude_target_language', 'default'); $provider_override = isset($_POST['translation_provider']) ? sanitize_text_field(wp_unslash($_POST['translation_provider'])) : ''; // The Attachment details panel posts an ID only, with no prompt picker. // Fill every prompt parameter from the stored default template. This runs before // the DEFAULT_PROVIDER fallback below, so the template's provider wins over it. if ($prompt === '') { $tpl = AIGUDE_Tools_Plugin::get_default_prompt_template(); $prompt = (string) ($tpl['prompt'] ?? ''); $prompt_lang = (string) ($tpl['prompt_lang'] ?? 'auto'); $tpl_src_lang = (string) ($tpl['src_lang'] ?? 'auto'); if (!isset($_POST['lang'])) { $langCode = (string) ($tpl['target_lang'] ?? $langCode); } if ($provider_override === '') { $provider_override = (string) ($tpl['target_provider'] ?? ''); } } if (!$id || $prompt === '') { $this->json_error(__('Missing parameters.', 'aigude-tools')); } [$api_key, $api_url] = $this->get_active_server_credentials(AIGUDE_Tools_Plugin::get_img2desc_url()); if (empty($api_key)) { $this->json_error(['message' => __('API key missing!', 'aigude-tools')]); } $translation_provider = $provider_override !== '' ? AIGUDE_Translation_Service::normalize_translation_provider($provider_override) : AIGUDE_Translation_Service::DEFAULT_PROVIDER; $prompt = wp_unslash($prompt); $prompt_spec = $this->build_prompt_spec($prompt, $prompt_lang, $tpl_src_lang, $id, $translation_provider); AIGUDE_Tools_Plugin::log('prompt_spec(single) id=' . $id . ': ' . wp_json_encode($prompt_spec)); $orig = get_attached_file($id); if (!$orig || !file_exists($orig)) { $this->json_error(__('File not found.', 'aigude-tools')); } $file = $this->resize_temp_image($orig) ?: $orig; $target_lang = AIGUDE_Tools_Plugin::resolve_target_lang_code($langCode, $translation_provider); AIGUDE_Tools_Plugin::push_recent_lang('target', $target_lang, $translation_provider); $url = add_query_arg([ 'target_lang' => $target_lang, 'api_version' => 2, 'model' => AIGUDE_Native_Language_Service::MODEL, 'translation_provider' => $translation_provider, ], $api_url); $fields = ['prompt_spec' => wp_json_encode($prompt_spec)]; $context = AIGUDE_Context_Service::for_attachment($id); if ($context) { $fields['context_chunks'] = wp_json_encode($context); } $resp = $this->curl_upload($url, $api_key, $file, $fields); if (is_wp_error($resp)) { $this->json_error($resp->get_error_message()); } $http = (int) wp_remote_retrieve_response_code($resp); $body = wp_remote_retrieve_body($resp); if ($http === 401 || $http === 403) { $api_msg = ''; $decoded = json_decode($body, true); if (is_array($decoded)) { $api_msg = $decoded['message'] ?? $decoded['error'] ?? $decoded['detail'] ?? ''; } $this->json_error([ 'message' => $api_msg !== '' ? $api_msg : __('Invalid or unauthorized API key.', 'aigude-tools'), 'code' => 'invalid_api_key', ], $http); } if ($http !== 200) { $this->json_error([ /* translators: %d = HTTP status code returned by the AiGude API. */ 'message' => sprintf(__('API returned HTTP %d', 'aigude-tools'), $http), 'code' => 'http_error', ], $http); } $data = json_decode($body, true); if (!is_array($data) || empty($data['success']) || empty($data['generated_text'])) { $this->json_error(__('Invalid or incomplete API response.', 'aigude-tools')); } $this->json_ok([ 'text' => $data['generated_text'], 'creditsUsed' => $data['credits_used'] ?? null, 'provider' => $data['provider'] ?? null, ]); } /** * Save a provided alt text for an attachment (used after manual review). */ public function ajax_apply_alt(): void { if (!current_user_can('upload_files')) { $this->json_error('forbidden', 403); } check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); $id = isset($_POST['id']) ? absint($_POST['id']) : 0; $alt = isset($_POST['alt']) ? sanitize_text_field(wp_unslash($_POST['alt'])) : ''; if ($id <= 0) { $this->json_error(__('Missing ID', 'aigude-tools')); } update_post_meta($id, '_wp_attachment_image_alt', $alt); update_post_meta($id, '_aigude_alt_suggestion', $alt); $this->json_ok(); } /** * Run generation for multiple IDs in sequence, returning per-item results. */ public function ajax_generate_bulk(): void { if (!current_user_can('upload_files')) { $this->json_error('forbidden', 403); } check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); $ids = array_map('absint', (array) ($_POST['ids'] ?? [])); $prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : ''; $skipExisting = !empty($_POST['skipExisting']); $langCode = isset($_POST['lang']) ? sanitize_text_field(wp_unslash($_POST['lang'])) : 'default'; $provider_override = isset($_POST['translation_provider']) ? sanitize_text_field(wp_unslash($_POST['translation_provider'])) : ''; $translation_provider = $provider_override !== '' ? AIGUDE_Translation_Service::normalize_translation_provider($provider_override) : AIGUDE_Translation_Service::DEFAULT_PROVIDER; $targetLang = AIGUDE_Tools_Plugin::resolve_target_lang_code($langCode, $translation_provider); $prompt_lang = isset($_POST['prompt_lang']) ? sanitize_text_field(wp_unslash($_POST['prompt_lang'])) : 'auto'; $tpl_src_lang = isset($_POST['tpl_src_lang']) ? sanitize_text_field(wp_unslash($_POST['tpl_src_lang'])) : 'auto'; AIGUDE_Tools_Plugin::push_recent_lang('target', $targetLang, $translation_provider); if (empty($ids) || $prompt === '') { $this->json_error(__('Missing parameters.', 'aigude-tools')); } [$apiKey, $apiUrl] = $this->get_active_server_credentials(AIGUDE_Tools_Plugin::get_img2desc_url()); if (empty($apiKey)) { $this->json_error(['message' => __('API key missing!', 'aigude-tools')]); } $prompt = wp_unslash($prompt); $tpl_for_expansion = $prompt; $creditsTotal = 0; $results = []; // Prime once so the whole run costs two queries rather than two per image. AIGUDE_Context_Service::for_attachments($ids); foreach ($ids as $id) { $prompt_spec = $this->build_prompt_spec($tpl_for_expansion, $prompt_lang, $tpl_src_lang, $id, $translation_provider); AIGUDE_Tools_Plugin::log('prompt_spec(bulk) id=' . $id . ': ' . wp_json_encode($prompt_spec)); if ($skipExisting) { $existing = (string) get_post_meta($id, '_wp_attachment_image_alt', true); if (trim($existing) !== '') { $results[$id] = [ 'status' => 'skipped', 'existing_alt' => $existing, ]; continue; } } $orig = get_attached_file($id); if (!$orig || !file_exists($orig)) { $results[$id] = ['status' => 'error', 'message' => 'File not found']; continue; } $file = $this->resize_temp_image($orig) ?: $orig; $url = add_query_arg([ 'target_lang' => $targetLang, 'api_version' => 2, 'model' => AIGUDE_Native_Language_Service::MODEL, 'translation_provider' => $translation_provider, ], $apiUrl); $fields = ['prompt_spec' => wp_json_encode($prompt_spec)]; $context = AIGUDE_Context_Service::for_attachment($id); if ($context) { $fields['context_chunks'] = wp_json_encode($context); } $resp = $this->curl_upload($url, $apiKey, $file, $fields); if (is_wp_error($resp)) { $results[$id] = ['status' => 'error', 'message' => 'cURL: ' . $resp->get_error_message()]; continue; } $http = (int) wp_remote_retrieve_response_code($resp); $body = wp_remote_retrieve_body($resp); if ($http === 401 || $http === 403) { $api_msg = ''; $decoded = json_decode($body, true); if (is_array($decoded)) { $api_msg = $decoded['message'] ?? $decoded['error'] ?? $decoded['detail'] ?? ''; } $this->json_error([ 'message' => $api_msg !== '' ? $api_msg : __('Invalid or unauthorized API key.', 'aigude-tools'), 'code' => 'invalid_api_key', ], $http); } if ($http !== 200) { $this->json_error([ /* translators: %d = HTTP status code returned by the AiGude API. */ 'message' => sprintf(__('API returned HTTP %d', 'aigude-tools'), $http), 'code' => 'http_error', ], $http); } $data = json_decode($body, true); if (!is_array($data) || empty($data['success']) || empty($data['generated_text'])) { $this->json_error([ 'message' => __('Invalid or incomplete API response.', 'aigude-tools'), 'code' => 'bad_api_payload', ], 502); } $alt = sanitize_text_field($data['generated_text']); update_post_meta($id, '_wp_attachment_image_alt', $alt); update_post_meta($id, '_aigude_alt_suggestion', $alt); $credits = isset($data['credits_used']) ? (int) $data['credits_used'] : 0; $provider = $data['provider'] ?? null; $creditsTotal += $credits; $results[$id] = [ 'status' => 'ok', 'alt' => $alt, 'credits' => $credits, 'provider' => $provider, ]; } $this->json_ok([ 'results' => $results, 'creditsUsed' => $creditsTotal, ]); } /** * Generate alt text for multiple attachments using the batch API endpoint. * * Sends up to BATCH_SIZE images per API request. Falls back to single-image * processing on server/timeout errors. Auth errors abort immediately. */ public function ajax_generate_batch(): void { if (!current_user_can('upload_files')) { $this->json_error('forbidden', 403); } check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); $ids = array_map('absint', (array) ($_POST['ids'] ?? [])); $prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : ''; $skipExisting = !empty($_POST['skipExisting']); $langCode = isset($_POST['lang']) ? sanitize_text_field(wp_unslash($_POST['lang'])) : 'default'; $provider_override = isset($_POST['translation_provider']) ? sanitize_text_field(wp_unslash($_POST['translation_provider'])) : ''; $translation_provider = $provider_override !== '' ? AIGUDE_Translation_Service::normalize_translation_provider($provider_override) : AIGUDE_Translation_Service::DEFAULT_PROVIDER; $targetLang = AIGUDE_Tools_Plugin::resolve_target_lang_code($langCode, $translation_provider); $prompt_lang = isset($_POST['prompt_lang']) ? sanitize_text_field(wp_unslash($_POST['prompt_lang'])) : 'auto'; $tpl_src_lang = isset($_POST['tpl_src_lang']) ? sanitize_text_field(wp_unslash($_POST['tpl_src_lang'])) : 'auto'; AIGUDE_Tools_Plugin::push_recent_lang('target', $targetLang, $translation_provider); if (empty($ids) || $prompt === '') { $this->json_error(__('Missing parameters.', 'aigude-tools')); } [$apiKey, $apiUrlSingle] = $this->get_active_server_credentials(AIGUDE_Tools_Plugin::get_img2desc_url()); if (empty($apiKey)) { $this->json_error(['message' => __('API key missing!', 'aigude-tools')]); } $prompt = wp_unslash($prompt); $batchApiUrl = AIGUDE_Tools_Plugin::get_img2desc_batch_url(); $batchSize = AIGUDE_Tools_Plugin::BATCH_SIZE; $creditsTotal = 0; $results = []; // --- Collect eligible images --- $eligible = []; // [{id, orig_path, file_path (resized), upload_name}] foreach ($ids as $id) { if ($skipExisting) { $existing = (string) get_post_meta($id, '_wp_attachment_image_alt', true); if (trim($existing) !== '') { $results[$id] = [ 'status' => 'skipped', 'existing_alt' => $existing, ]; continue; } } $orig = get_attached_file($id); if (!$orig || !file_exists($orig)) { $results[$id] = ['status' => 'error', 'message' => 'File not found']; continue; } $file = $this->resize_temp_image($orig) ?: $orig; $basename = wp_basename($orig); // Prefix with ID to guarantee uniqueness across attachments sharing the same filename $upload_name = $id . '-' . $basename; $eligible[] = [ 'id' => $id, 'orig_path' => $orig, 'file_path' => $file, 'upload_name' => $upload_name, ]; } // --- Process in batches --- $chunks = array_chunk($eligible, $batchSize); foreach ($chunks as $chunk) { $files = []; foreach ($chunk as $att) { $files[] = ['path' => $att['file_path'], 'name' => $att['upload_name']]; } // Resolve context for the whole chunk in one pass, before both the // batch attempt and any single-image fallback. AIGUDE_Context_Service::for_attachments(array_column($chunk, 'id')); // Build filename-keyed prompt_spec $prompt_spec = $this->build_prompt_spec_batch( $prompt, $prompt_lang, $tpl_src_lang, $chunk, $translation_provider ); AIGUDE_Tools_Plugin::log('prompt_spec(batch) ids=' . implode(',', array_column($chunk, 'id')) . ': ' . wp_json_encode($prompt_spec)); $url = add_query_arg([ 'target_lang' => $targetLang, 'api_version' => 2, 'model' => AIGUDE_Native_Language_Service::MODEL, 'translation_provider' => $translation_provider, ], $batchApiUrl); $fields = ['prompt_spec' => wp_json_encode($prompt_spec)]; $context_by_name = []; foreach ($chunk as $att) { $chunks_for_att = AIGUDE_Context_Service::for_attachment((int) $att['id']); if ($chunks_for_att) { $context_by_name[$att['upload_name']] = $chunks_for_att; } } if ($context_by_name) { // Keyed object form; the API falls back to a shared list when given an array. $fields['context_chunks'] = wp_json_encode($context_by_name); } $resp = $this->curl_upload_batch($url, $apiKey, $files, $fields); // --- Error handling --- if (is_wp_error($resp)) { // Network/timeout error → fallback to single for this chunk $this->process_chunk_single($chunk, $results, $creditsTotal, $prompt, $prompt_lang, $tpl_src_lang, $targetLang, $translation_provider, $apiKey, $apiUrlSingle); continue; } $http = (int) wp_remote_retrieve_response_code($resp); $body = wp_remote_retrieve_body($resp); // Auth/credit errors → abort entirely if ($http === 401 || $http === 403) { $api_msg = ''; $decoded = json_decode($body, true); if (is_array($decoded)) { $api_msg = $decoded['message'] ?? $decoded['error'] ?? $decoded['detail'] ?? ''; } $this->json_error([ 'message' => $api_msg !== '' ? $api_msg : __('Invalid or unauthorized API key.', 'aigude-tools'), 'code' => 'invalid_api_key', ], $http); } // Server errors → fallback to single for this chunk if ($http !== 200) { $this->process_chunk_single($chunk, $results, $creditsTotal, $prompt, $prompt_lang, $tpl_src_lang, $targetLang, $translation_provider, $apiKey, $apiUrlSingle); continue; } $data = json_decode($body, true); if (!is_array($data) || empty($data['success']) || !is_array($data['items'] ?? null)) { $this->process_chunk_single($chunk, $results, $creditsTotal, $prompt, $prompt_lang, $tpl_src_lang, $targetLang, $translation_provider, $apiKey, $apiUrlSingle); continue; } // --- Map response items back to attachment IDs --- $name_to_id = []; foreach ($chunk as $att) { $name_to_id[$att['upload_name']] = $att['id']; } $matched_ids = []; foreach ($data['items'] as $item) { $fname = $item['filename'] ?? ''; $generated = $item['generated_text'] ?? ''; $provider_used = $item['provider'] ?? null; if ($fname === '' || !isset($name_to_id[$fname])) { continue; } $att_id = $name_to_id[$fname]; $matched_ids[$att_id] = true; if ($generated === '') { $results[$att_id] = ['status' => 'error', 'message' => 'Empty generated text']; continue; } $alt = sanitize_text_field($generated); update_post_meta($att_id, '_wp_attachment_image_alt', $alt); update_post_meta($att_id, '_aigude_alt_suggestion', $alt); $results[$att_id] = [ 'status' => 'ok', 'alt' => $alt, 'credits' => 0, // per-image credits not available in batch response 'provider' => $provider_used, ]; } // Any chunk items not matched in response → error foreach ($chunk as $att) { if (!isset($matched_ids[$att['id']])) { $results[$att['id']] = ['status' => 'error', 'message' => 'Missing in batch response']; } } $creditsTotal += (int) ($data['credits_used'] ?? 0); } $this->json_ok([ 'results' => $results, 'creditsUsed' => $creditsTotal, ]); } /** * Fallback: process a chunk of attachments one-by-one via the single-image endpoint. * * Mutates $results and $creditsTotal by reference. */ private function process_chunk_single(array $chunk, array &$results, int &$creditsTotal, string $prompt, string $prompt_lang, string $tpl_src_lang, string $targetLang, string $translation_provider, string $apiKey, string $apiUrl): void { foreach ($chunk as $att) { $id = $att['id']; $file = $att['file_path']; $prompt_spec = $this->build_prompt_spec($prompt, $prompt_lang, $tpl_src_lang, $id, $translation_provider); $url = add_query_arg([ 'target_lang' => $targetLang, 'api_version' => 2, 'model' => AIGUDE_Native_Language_Service::MODEL, 'translation_provider' => $translation_provider, ], $apiUrl); // This path rebuilds rather than reuses the batch prompt_spec, so it // needs its own lookup. The chunk-level prime makes it cache-only. $fields = ['prompt_spec' => wp_json_encode($prompt_spec)]; $context = AIGUDE_Context_Service::for_attachment((int) $id); if ($context) { $fields['context_chunks'] = wp_json_encode($context); } $resp = $this->curl_upload($url, $apiKey, $file, $fields); if (is_wp_error($resp)) { $results[$id] = ['status' => 'error', 'message' => 'cURL: ' . $resp->get_error_message()]; continue; } $http = (int) wp_remote_retrieve_response_code($resp); $body = wp_remote_retrieve_body($resp); if ($http === 401 || $http === 403) { $results[$id] = ['status' => 'error', 'message' => 'Auth error']; return; // Stop processing — fatal } if ($http !== 200) { $results[$id] = ['status' => 'error', 'message' => sprintf('HTTP %d', $http)]; continue; } $data = json_decode($body, true); if (!is_array($data) || empty($data['success']) || empty($data['generated_text'])) { $results[$id] = ['status' => 'error', 'message' => 'Invalid API response']; continue; } $alt = sanitize_text_field($data['generated_text']); update_post_meta($id, '_wp_attachment_image_alt', $alt); update_post_meta($id, '_aigude_alt_suggestion', $alt); $credits = isset($data['credits_used']) ? (int) $data['credits_used'] : 0; $creditsTotal += $credits; $results[$id] = [ 'status' => 'ok', 'alt' => $alt, 'credits' => $credits, 'provider' => $data['provider'] ?? null, ]; } } /** * Fetch remaining credit counts for each configured AiGude server entry. */ public function ajax_get_all_credits(): void { if (!current_user_can('manage_options')) { $this->json_error('forbidden', 403); } check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); $servers = get_option('aigude_alt_servers', []); $results = []; foreach ((array) $servers as $index => $srv) { if (!empty($srv['enabled']) && !empty($srv['api_key'])) { $response = wp_remote_get(AIGUDE_Tools_Plugin::get_credits_url(), [ 'headers' => ['apikey' => $srv['api_key']], 'timeout' => 30, ]); if (!is_wp_error($response)) { $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); $results[$index] = $data['remaining_credits'] ?? __('Error', 'aigude-tools'); } else { $results[$index] = __('Error', 'aigude-tools'); } } else { $results[$index] = __('Disabled', 'aigude-tools'); } } $this->json_ok($results); } /** * Store the user’s preference for skipping images that already have alt text. */ public function ajax_set_skip_mode(): void { check_ajax_referer(AIGUDE_Tools_Plugin::NONCE_ACTION); if (!current_user_can('upload_files')) { $this->json_error('forbidden', 403); } $mode = isset($_POST['mode']) ? absint($_POST['mode']) : 0; update_user_meta(get_current_user_id(), '_ai_skip_existing_mode', $mode); $this->json_ok(); } /** * Return the preferred API credentials tuple (API key + URL) from settings. */ public function get_active_server_credentials(?string $fallbackUrl = null): array { $servers = get_option('aigude_alt_servers', []); $api_key = ''; if (is_array($servers)) { foreach ($servers as $srv) { if (!empty($srv['enabled']) && !empty($srv['api_key']) && !empty($srv['is_default'])) { $api_key = $srv['api_key']; break; } } if ($api_key === '') { foreach ($servers as $srv) { if (!empty($srv['enabled']) && !empty($srv['api_key'])) { $api_key = $srv['api_key']; break; } } } } return [$api_key, $fallbackUrl ?? AIGUDE_Tools_Plugin::get_img2desc_url()]; } /** * Resize the original attachment into a temporary, lower resolution file for upload. */ private function resize_temp_image(string $orig): ?string { $editor = wp_get_image_editor($orig); if (is_wp_error($editor)) { return null; } $editor->resize(1920, 1080, false); $editor->set_quality(75); $tmp = wp_tempnam('', 'ai_'); if (!$tmp) { return null; } $saved = $editor->save($tmp); return (isset($saved['path']) && file_exists($saved['path'])) ? $saved['path'] : null; } /** * Post a multipart request containing the resized image and additional payload fields. */ private function curl_upload(string $url, string $api_key, string $file_path, array $fields = []) { if (!file_exists($file_path)) { return new WP_Error('file_missing', __('Temporary image file missing', 'aigude-tools')); } $boundary = wp_generate_password(24, false); $headers = [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, 'apikey' => $api_key, 'X-AIGUDE-Client' => 'wordpress', ]; $body = ''; foreach ($fields as $name => $value) { $body .= "--$boundary\r\n"; $body .= "Content-Disposition: form-data; name=\"" . $name . "\"\r\n\r\n"; $body .= (string) $value . "\r\n"; } $body .= "--$boundary\r\n"; $body .= "Content-Disposition: form-data; name=\"image_file\"; filename=\"" . basename($file_path) . "\"\r\n"; $body .= "Content-Type: " . (wp_check_filetype($file_path)['type'] ?: 'application/octet-stream') . "\r\n\r\n"; $body .= file_get_contents($file_path) . "\r\n"; $body .= "--$boundary--\r\n"; return wp_remote_post($url, [ 'headers' => $headers, 'body' => $body, 'timeout' => 120, ]); } /** * Post a multipart request containing multiple image files and additional payload fields. * * @param string $url The API endpoint URL. * @param string $api_key The API key for authentication. * @param array $files Array of ['path' => string, 'name' => string] for each image. * @param array $fields Additional form fields (e.g., prompt_spec). * @return array|WP_Error */ private function curl_upload_batch(string $url, string $api_key, array $files, array $fields = []) { foreach ($files as $file) { if (!file_exists($file['path'])) { return new WP_Error('file_missing', __('Temporary image file missing', 'aigude-tools')); } } $boundary = wp_generate_password(24, false); $headers = [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, 'apikey' => $api_key, 'X-AIGUDE-Client' => 'wordpress', ]; $body = ''; foreach ($fields as $name => $value) { $body .= "--$boundary\r\n"; $body .= "Content-Disposition: form-data; name=\"" . $name . "\"\r\n\r\n"; $body .= (string) $value . "\r\n"; } foreach ($files as $file) { $body .= "--$boundary\r\n"; $body .= "Content-Disposition: form-data; name=\"image_files\"; filename=\"" . $file['name'] . "\"\r\n"; $body .= "Content-Type: " . (wp_check_filetype($file['path'])['type'] ?: 'application/octet-stream') . "\r\n\r\n"; $body .= file_get_contents($file['path']) . "\r\n"; } $body .= "--$boundary--\r\n"; return wp_remote_post($url, [ 'headers' => $headers, 'body' => $body, 'timeout' => 300, ]); } /** * Convenience wrapper for wp_send_json_success(). */ private function json_ok($data = null, int $status = 200): void { wp_send_json_success($data, $status); } /** * Convenience wrapper for wp_send_json_error(). */ private function json_error($data = null, int $status = 400): void { wp_send_json_error($data, $status); } /** * Build the prompt_spec payload that tells the API how to treat each placeholder token. */ private function build_prompt_spec(string $prompt_template, string $prompt_lang, string $placeholder_lang, int $attachment_id, string $provider = AIGUDE_Translation_Service::DEFAULT_PROVIDER): array { // prompt_lang is the language the template is written in, not a translation target. // Send the native base code (e.g. "de"); the API accepts bare 2-letter codes, and // resolving it against the translation provider's list would drop languages that // AiGude generates natively but the provider does not carry. $base = AIGUDE_Native_Language_Service::base_code($prompt_lang); $norm_prompt_lang = $base !== '' ? $base : 'auto'; $spec_tokens = $this->build_tokens_for_attachment($prompt_template, $placeholder_lang, $attachment_id, $provider); return [ 'prompt_template' => $prompt_template, 'prompt_lang' => $norm_prompt_lang, 'tokens' => $spec_tokens, ]; } /** * Build a batch prompt_spec with tokens keyed by filename. * * @param string $prompt_template The prompt template string. * @param string $prompt_lang Language of the prompt template. * @param string $placeholder_lang Language of the placeholder values. * @param array $attachments Array of ['id' => int, 'upload_name' => string]. * @param string $provider Translation provider slug. * @return array */ private function build_prompt_spec_batch(string $prompt_template, string $prompt_lang, string $placeholder_lang, array $attachments, string $provider): array { // See build_prompt_spec(): prompt_lang is sent as a native base code, not a provider code. $base = AIGUDE_Native_Language_Service::base_code($prompt_lang); $norm_prompt_lang = $base !== '' ? $base : 'auto'; $tokens_by_file = []; foreach ($attachments as $att) { $tokens_by_file[$att['upload_name']] = $this->build_tokens_for_attachment( $prompt_template, $placeholder_lang, $att['id'], $provider ); } return [ 'prompt_template' => $prompt_template, 'prompt_lang' => $norm_prompt_lang, 'tokens' => $tokens_by_file, ]; } /** * Collect all placeholder tokens (title, caption, sizes, etc.) for an attachment. */ private function get_attachment_tokens(int $id): array { $post = get_post($id); $alt = (string) get_post_meta($id, '_wp_attachment_image_alt', true); $file = (string) get_post_meta($id, '_wp_attached_file', true); $meta = wp_get_attachment_metadata($id) ?: []; $original_rel = is_array($meta) && !empty($meta['original_image']) ? (string) $meta['original_image'] : ''; $basename = $original_rel ? wp_basename($original_rel) : ($file ? wp_basename($file) : ''); $basename_clean = $basename ? preg_replace('/-scaled(?=\\.[^.]+$)/i', '', $basename) : ''; $no_ext = $basename_clean ? preg_replace('/\\.[^.]+$/', '', $basename_clean) : ''; $mime = get_post_mime_type($id) ?: ''; $w = (int) ($meta['width'] ?? 0); $h = (int) ($meta['height'] ?? 0); return [ 'filename' => $basename_clean, 'filename_no_ext' => $no_ext, 'title' => is_object($post) ? (string) $post->post_title : '', 'current_alt' => $alt, 'current-alt' => $alt, 'caption' => is_object($post) ? (string) $post->post_excerpt : '', 'description' => is_object($post) ? (string) $post->post_content : '', 'mime' => $mime, 'width' => $w, 'height' => $h, ]; } /** * Build the tokens sub-array for a single attachment (reused by both single and batch prompt_spec). * * Returns only tokens that are actually used in the template, with language and translatability metadata. */ private function build_tokens_for_attachment(string $prompt_template, string $placeholder_lang, int $attachment_id, string $provider): array { $tokens_map = $this->get_attachment_tokens($attachment_id); $spec_tokens = []; $used = []; $token_mods = []; if ($prompt_template !== '' && preg_match_all('/%([a-z0-9_\\-]+)(?:\\|([^%]*))?%/i', $prompt_template, $m, PREG_SET_ORDER)) { foreach ($m as $match) { $tok = strtolower($match[1]); $used[$tok] = true; $mods_str = isset($match[2]) ? (string) $match[2] : ''; if ($mods_str !== '') { $mods = array_filter(array_map('trim', explode('|', $mods_str))); foreach ($mods as $mod) { $mod_l = strtolower($mod); if (in_array($mod_l, ['translatable', 'translate'], true)) { $token_mods[$tok]['translatable'] = true; } elseif (in_array($mod_l, ['untranslatable', 'no-translate', 'notranslate'], true)) { $token_mods[$tok]['translatable'] = false; } } } } } $norm_provider = AIGUDE_Translation_Service::normalize_translation_provider($provider); $norm_placeholder_lang = (strtolower($placeholder_lang) === 'auto') ? 'auto' : AIGUDE_Translation_Service::normalize_provider_lang_code_generic($placeholder_lang, $norm_provider, 'source'); if ($norm_placeholder_lang === '') { $norm_placeholder_lang = 'auto'; } foreach ($tokens_map as $key => $value) { if (!isset($used[$key])) { continue; } if (in_array($key, ['current_alt', 'current-alt', 'caption', 'description'], true)) { $str = trim((string) $value); if ($str === '') { continue; } $translatable = $token_mods[$key]['translatable'] ?? true; $spec_tokens[$key] = [ 'value' => $str, 'lang' => $norm_placeholder_lang ?: 'auto', 'translatable' => (bool) $translatable, ]; } elseif (in_array($key, ['title'], true)) { $str = trim((string) $value); if ($str === '') { continue; } $translatable = $token_mods[$key]['translatable'] ?? false; $spec_tokens[$key] = [ 'value' => $str, 'lang' => $norm_placeholder_lang ?: 'auto', 'translatable' => (bool) $translatable, ]; } elseif (in_array($key, ['filename', 'filename_no_ext'], true)) { $str = (string) $value; if ($str === '') { continue; } $translatable = $token_mods[$key]['translatable'] ?? false; $spec_tokens[$key] = [ 'value' => $str, 'lang' => $norm_placeholder_lang ?: 'auto', 'translatable' => (bool) $translatable, ]; } elseif (in_array($key, ['width', 'height'], true)) { $num = (int) $value; if ($num <= 0) { continue; } $spec_tokens[$key] = [ 'value' => $num, 'lang' => 'auto', 'translatable' => false, ]; } } return $spec_tokens; } }