Core class not found. Make sure the file exists at: ' . esc_html($ai_translate_core_class_file) . '

'; }); } } /** * Safe sprintf function that handles corrupted translations with wrong number of placeholders * * @param string $format The format string * @param mixed ...$args The arguments to format * @return string The formatted string */ function safe_sprintf($format, ...$args) { // Count printf-style placeholders (%s, %d, %u, %i, %f) in the format string $placeholder_count = preg_match_all('/%(?:\d+\$)?[sduif]/', $format); // If no placeholders, return the format as-is if ($placeholder_count === 0) { return $format; } // If correct number of placeholders, use normal sprintf if ($placeholder_count === count($args)) { return sprintf($format, ...$args); } // Handle corrupted translations if ($placeholder_count < count($args)) { // Too many arguments - use only the needed ones $used_args = array_slice($args, 0, $placeholder_count); return sprintf($format, ...$used_args); } else { // Too few arguments - replace placeholders with available args, strip the rest $result = $format; foreach ($args as $arg) { $result = preg_replace('/%(?:\d+\$)?[sduif]/', (string) $arg, $result, 1); } $result = preg_replace('/%(?:\d+\$)?[sduif]/', '', $result); return $result; } } /** * Handle AJAX request to clear cache for a specific language. * * Security: requires manage_options capability and a valid nonce. * Output is JSON via wp_send_json_*. */ function ajax_clear_cache_language() { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions to perform this action.', 'ai-translate')]); return; } // Validate nonce $nonce_value = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : null; if (!$nonce_value || !wp_verify_nonce($nonce_value, 'clear_cache_language_action')) { wp_send_json_error(['message' => __('Security check failed (nonce). Refresh the page and try again.', 'ai-translate')]); return; } // Validate language code if (!isset($_POST['lang_code']) || empty($_POST['lang_code'])) { wp_send_json_error(['message' => __('No language selected.', 'ai-translate')]); return; } $lang_code = sanitize_text_field(wp_unslash($_POST['lang_code'])); // Clear the cache for this language $translator = AI_Translate_Core::get_instance(); try { $result = $translator->clear_cache_for_language($lang_code); $count = isset($result['count']) ? (int) $result['count'] : 0; wp_send_json_success([ 'message' => safe_sprintf(/* translators: %1$s: string value, %2$s: number value */ __('Cache for language "%1$s" cleared. %2$d files removed.', 'ai-translate'), $lang_code, $count), 'count' => $count ]); } catch (\Exception $e) { wp_send_json_error([ 'message' => __('Error clearing cache:', 'ai-translate') . ' ' . $e->getMessage() ]); } } // Register the AJAX handlers add_action('wp_ajax_ai_translate_clear_cache_language', __NAMESPACE__ . '\\ajax_clear_cache_language'); /** * Handle AJAX request to delete cache for a specific post in all languages. * * Security: requires manage_options capability and a valid nonce. */ function ajax_delete_post_cache() { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions to perform this action.', 'ai-translate')]); return; } // Validate nonce $nonce_value = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : null; if (!$nonce_value || !wp_verify_nonce($nonce_value, 'ai_translate_delete_cache')) { wp_send_json_error(['message' => __('Security check failed (nonce). Refresh the page and try again.', 'ai-translate')]); return; } // Validate post ID $raw_post_id = isset($_POST['post_id']) ? sanitize_text_field(wp_unslash((string) $_POST['post_id'])) : null; if ($raw_post_id === null || trim((string) $raw_post_id) === '') { wp_send_json_error(['message' => __('No post ID provided.', 'ai-translate')]); return; } $post_id = intval($raw_post_id); if ($post_id < -1) { wp_send_json_error(['message' => __('Invalid post ID.', 'ai-translate')]); return; } // For homepage (post_id = 0) and 404 page (post_id = -1), skip post verification if ($post_id > 0) { // Verify post exists and user has permission to access it $post = get_post($post_id); if (!$post) { wp_send_json_error(['message' => __('Post not found.', 'ai-translate')]); return; } } // Ensure table exists AI_Cache_Meta::ensure_table_exists(); // Get cache records from database $records = AI_Cache_Meta::get($post_id); // Get allowed cache directory for path validation $uploads = wp_upload_dir(); $allowed_base = trailingslashit($uploads['basedir']) . 'ai-translate/cache/'; $allowed_base = wp_normalize_path($allowed_base); $deleted = 0; $errors = []; if (!empty($records)) { foreach ($records as $record) { if (!isset($record->cache_file) || empty($record->cache_file)) { continue; } $cache_file = wp_normalize_path($record->cache_file); // Security: Ensure file is within allowed cache directory (prevent path traversal) if (strpos($cache_file, $allowed_base) !== 0) { $errors[] = safe_sprintf(/* translators: %s: string value */ __('Invalid file path: %s', 'ai-translate'), basename($cache_file)); continue; } // Security: Ensure file exists and is a regular file (not directory or symlink) if (!file_exists($cache_file) || !is_file($cache_file)) { continue; } // Security: Additional check - file must have .html extension if (substr($cache_file, -5) !== '.html') { $errors[] = safe_sprintf(/* translators: %s: string value */ __('Invalid file type: %s', 'ai-translate'), basename($cache_file)); continue; } wp_delete_file($cache_file); if (!file_exists($cache_file)) { $deleted++; } else { $errors[] = safe_sprintf(/* translators: %s: string value */ __('Could not delete file: %s', 'ai-translate'), basename($cache_file)); } } } // Delete metadata records $meta_deleted = AI_Cache_Meta::delete($post_id); // Clear any transients delete_transient('ai_translate_cache_table_data'); $message = safe_sprintf(/* translators: %d: number value */ __('Cache deleted for %d languages', 'ai-translate'), $deleted); if (!empty($errors)) { $message .= '. ' . __('Warnings:', 'ai-translate') . ' ' . implode(', ', $errors); } wp_send_json_success([ 'message' => $message, 'deleted' => $deleted, 'meta_deleted' => $meta_deleted ]); } add_action('wp_ajax_ai_translate_delete_cache', __NAMESPACE__ . '\\ajax_delete_post_cache'); /** * Handle AJAX request to fetch cached URLs for a language (lazy-loaded in admin). * * Security: requires manage_options and dedicated nonce. * * @return void */ function ajax_get_cache_urls_by_language() { if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions to perform this action.', 'ai-translate')]); return; } $nonce_value = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : null; if (!$nonce_value || !wp_verify_nonce($nonce_value, 'ai_translate_cache_urls_nonce')) { wp_send_json_error(['message' => __('Security check failed (nonce). Refresh the page and try again.', 'ai-translate')]); return; } $lang_code = isset($_POST['lang_code']) ? sanitize_key(wp_unslash($_POST['lang_code'])) : ''; if ($lang_code === '') { wp_send_json_error(['message' => __('No language selected.', 'ai-translate')]); return; } AI_Cache_Meta::ensure_table_exists(); $result = AI_Cache_Meta::get_cached_urls_for_language($lang_code, 500); wp_send_json_success($result); } add_action('wp_ajax_ai_translate_get_cache_urls_by_language', __NAMESPACE__ . '\\ajax_get_cache_urls_by_language'); /** * AJAX handler: Delete a single cache file * * @return void (sends JSON response) */ function ajax_delete_cache_file() { // Verify nonce $nonce_value = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (!$nonce_value || !wp_verify_nonce($nonce_value, 'ai_translate_delete_cache_file_nonce')) { wp_send_json_error(array('message' => __('Security check failed', 'ai-translate'))); return; } // Check capabilities if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Insufficient permissions', 'ai-translate'))); return; } // Get parameters $cache_file = isset($_POST['cache_file']) ? wp_normalize_path(sanitize_text_field(wp_unslash($_POST['cache_file']))) : ''; $language = isset($_POST['language']) ? sanitize_text_field(wp_unslash($_POST['language'])) : ''; $post_id = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0; if (empty($cache_file)) { wp_send_json_error(array('message' => __('No cache file specified', 'ai-translate'))); return; } // Verify file exists and is in allowed cache directory $uploads = wp_upload_dir(); $cache_base = trailingslashit($uploads['basedir']) . 'ai-translate/cache/'; $cache_file_normalized = wp_normalize_path($cache_file); // Security check: ensure the file really is inside the cache directory. A prefix comparison of // the given string is not enough: "…/cache/../wp-config.php" starts with the cache path but // points outside it, so both sides are resolved first (also resolves symlinks). if (!path_is_inside($cache_file_normalized, $cache_base)) { wp_send_json_error(array('message' => __('Invalid cache file path', 'ai-translate'))); return; } if (!is_file($cache_file_normalized)) { wp_send_json_error(array('message' => __('Cache file not found', 'ai-translate'))); return; } // Delete the file wp_delete_file($cache_file_normalized); if (file_exists($cache_file_normalized)) { wp_send_json_error(array('message' => __('Failed to delete cache file', 'ai-translate'))); return; } // Delete from database metadata if it exists if (class_exists('\\AITranslate\\AI_Cache_Meta')) { \AITranslate\AI_Cache_Meta::delete_by_file($cache_file_normalized); } wp_send_json_success(array('message' => __('Cache file deleted successfully', 'ai-translate'))); } add_action('wp_ajax_ai_translate_delete_cache_file', __NAMESPACE__ . '\\ajax_delete_cache_file'); /** * Return route_id used for cache key when warming cache. Must match class-ai-ob current_route_id() for the same URL. * * @param int $post_id Post ID (0 = homepage) * @return string route_id e.g. 'post:123' or 'path:' . md5('/') */ function warm_cache_route_id($post_id) { if ($post_id === 0) { $front_page_id = (int) get_option('page_on_front'); return ($front_page_id > 0) ? ('post:' . $front_page_id) : ('path:' . md5('/')); } if ($post_id === -1) { return 'path:' . md5('/404'); } return 'post:' . $post_id; } /** * Warm cache for multiple languages in parallel using curl_multi. * * @param int $post_id Post ID to warm cache for * @param string $base_path Base path (e.g., '/page-slug/') * @param array $lang_codes Array of language codes to warm * @return array Associative array with lang_code as key and result array as value */ function warm_cache_batch($post_id, $base_path, $lang_codes) { $results = []; if (empty($lang_codes)) { return $results; } // Build per-language request descriptors for Requests::request_multiple(). // Falls back to sequential wp_remote_get() if the multi-request layer is unavailable. $requests = []; $request_meta = []; foreach ($lang_codes as $lang_code) { try { $translated_url = \AITranslate\AI_SEO::get_translated_url($post_id, $lang_code); if ($translated_url === '') { $results[$lang_code] = array('success' => false, 'error' => __('Could not generate post URL.', 'ai-translate')); continue; } $requests[$lang_code] = [ 'url' => $translated_url, 'headers' => [ 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language' => $lang_code . ',' . $lang_code . ';q=0.9,en;q=0.8', 'Connection' => 'keep-alive', 'Upgrade-Insecure-Requests' => '1', 'Cookie' => 'ai_translate_lang=' . rawurlencode($lang_code), ], 'options' => [ 'timeout' => 100, // below the 150s set_time_limit of the AJAX request 'connect_timeout' => 10, 'redirects' => 5, 'verify' => true, ], ]; $request_meta[$lang_code] = $translated_url; } catch (\Throwable $e) { $results[$lang_code] = array('success' => false, 'error' => $e->getMessage()); } } if (empty($requests)) { return $results; } // Use the bundled Requests library (shipped with WordPress core) for parallel HTTP. $responses = []; if (class_exists('\\WpOrg\\Requests\\Requests')) { try { $responses = \WpOrg\Requests\Requests::request_multiple($requests); } catch (\Throwable $e) { $responses = []; } } foreach ($lang_codes as $lang_code) { if (!isset($request_meta[$lang_code])) { continue; // already errored above } $http_code = 0; $response_body = ''; $error = ''; if (isset($responses[$lang_code])) { $resp = $responses[$lang_code]; if ($resp instanceof \WpOrg\Requests\Exception || $resp instanceof \Exception) { $error = $resp->getMessage(); } else { $http_code = (int) $resp->status_code; $response_body = (string) $resp->body; } } else { // Sequential fallback when request_multiple is unavailable. $wp_resp = wp_remote_get($request_meta[$lang_code], [ 'timeout' => 90, 'redirection'=> 5, 'sslverify' => true, 'headers' => $requests[$lang_code]['headers'], ]); if (is_wp_error($wp_resp)) { $error = $wp_resp->get_error_message(); } else { $http_code = (int) wp_remote_retrieve_response_code($wp_resp); $response_body = (string) wp_remote_retrieve_body($wp_resp); } } if ($error !== '') { $results[$lang_code] = array('success' => false, 'error' => $error); continue; } // Check if response looks like an error page (403, 500, etc.) $is_error_page = false; if ($http_code === 200 && !empty($response_body) && $post_id !== -1) { // Check for common error indicators in HTML (skip for 404 pages which are expected) if (stripos($response_body, '404') !== false || stripos($response_body, 'page not found') !== false || stripos($response_body, 'Fatal error') !== false || stripos($response_body, 'Parse error') !== false) { $is_error_page = true; } } // Accept 200 as success (unless it's an error page), and 404 for 404 page warming $is_success = ($http_code === 200 && !$is_error_page) || ($post_id === -1 && $http_code === 404); if ($is_success) { // The cache file is written before the response ends, so check right away and only // wait (below) when it is not there yet. A fixed pause per language added 0.5s each. $route_id = warm_cache_route_id($post_id); $cache_key = \AITranslate\AI_Cache::key($lang_code, $route_id, ''); $cache_file = \AITranslate\AI_Cache::get_file_path($cache_key); if ($cache_file && file_exists($cache_file)) { // Ensure metadata is inserted/updated in database $cache_hash = md5($cache_key); AI_Cache_Meta::insert($post_id, $lang_code, $cache_file, $cache_hash); $results[$lang_code] = array('success' => true, 'error' => ''); } else { // Check again after longer delays (up to 3 seconds total) $max_attempts = 6; $found = false; for ($attempt = 1; $attempt <= $max_attempts; $attempt++) { usleep(500000); // 0.5 seconds if ($cache_file && file_exists($cache_file)) { $cache_hash = md5($cache_key); AI_Cache_Meta::insert($post_id, $lang_code, $cache_file, $cache_hash); $results[$lang_code] = array('success' => true, 'error' => ''); $found = true; break; } } if (!$found) { // Provide more detailed error message $error_detail = __('Cache file not generated', 'ai-translate'); $debug_info = ['post_id' => $post_id, 'lang_code' => $lang_code, 'route_id' => $route_id, 'cache_key' => $cache_key, 'cache_file' => $cache_file]; if ($cache_file) { // Check parent directories existence $cache_dir = dirname($cache_file); $parent_dir = dirname($cache_dir); if (!is_dir($parent_dir)) { $error_detail .= ' (' . __('lang directory missing', 'ai-translate') . ')'; $debug_info['parent_dir_exists'] = false; } elseif (!is_dir($cache_dir)) { // Hash subdirectory missing is normal - it gets created during write // This means the write never happened $error_detail .= ' (' . __('no write occurred', 'ai-translate') . ')'; $debug_info['cache_dir_exists'] = false; } elseif (!wp_is_writable($cache_dir)) { $error_detail .= ' (' . __('not writable', 'ai-translate') . ')'; $debug_info['cache_dir_writable'] = false; } else { $error_detail .= ' (' . __('check logs', 'ai-translate') . ')'; $debug_info['cache_dir_exists'] = true; $debug_info['cache_dir_writable'] = wp_is_writable($cache_dir); } } // Fallback: take the HTML response we just fetched and write it to cache directly // Only if it looks like valid, complete HTML (not binary/gzip/truncated). // CURLOPT_ENCODING already handles gzip decompression. if (!empty($response_body) && stripos($response_body, '<html') !== false && stripos($response_body, '</body>') !== false ) { \AITranslate\AI_Cache::set($cache_key, $response_body); if ($cache_file && file_exists($cache_file)) { $cache_hash = md5($cache_key); AI_Cache_Meta::insert($post_id, $lang_code, $cache_file, $cache_hash); $results[$lang_code] = array('success' => true, 'error' => ''); continue; } } $results[$lang_code] = array('success' => false, 'error' => $error_detail); } } } elseif ($is_error_page) { $results[$lang_code] = array('success' => false, 'error' => __('Error page (404/500)', 'ai-translate')); } elseif ($http_code === 301 || $http_code === 302) { // Redirect - might be normal, but we'll mark as success if cache exists $route_id = warm_cache_route_id($post_id); $cache_key = \AITranslate\AI_Cache::key($lang_code, $route_id, ''); $cache_file = \AITranslate\AI_Cache::get_file_path($cache_key); if ($cache_file && file_exists($cache_file)) { $cache_hash = md5($cache_key); AI_Cache_Meta::insert($post_id, $lang_code, $cache_file, $cache_hash); $results[$lang_code] = array('success' => true, 'error' => ''); } else { $results[$lang_code] = array('success' => false, 'error' => safe_sprintf(/* translators: %d: number value */ __('HTTP %d (redirect)', 'ai-translate'), $http_code)); } } else { $results[$lang_code] = array('success' => false, 'error' => safe_sprintf(/* translators: %d: number value */ __('HTTP %d', 'ai-translate'), $http_code)); } } return $results; } /** * Make an internal WordPress request that goes through the full stack to generate cache. * * This function makes an HTTP request to the site itself (like curl would), * which goes through the full WordPress stack including template_redirect, * triggering the output buffer callback that generates the cache. * * @param int $post_id Post ID to warm cache for * @param string $request_path Request path (e.g., '/en/page-slug/') * @param string $lang_code Language code * @return array Array with 'success' (bool) and 'error' (string) keys */ function warm_cache_internal_request($post_id, $request_path, $lang_code) { $lang_code = sanitize_key((string) $lang_code); $translated_url = \AITranslate\AI_SEO::get_translated_url($post_id, $lang_code); if ($translated_url === '') { return array('success' => false, 'error' => __('Could not generate post URL.', 'ai-translate')); } // Parse URL to get host and path $parsed = wp_parse_url($translated_url); $host = isset($parsed['host']) ? $parsed['host'] : ''; if (isset($parsed['port'])) { $host .= ':' . $parsed['port']; } // Create cookie jar for the request $cookies = array(); $cookies[] = new \WP_Http_Cookie(array( 'name' => 'ai_translate_lang', 'value' => $lang_code, 'domain' => $host, 'path' => '/' )); // Make HTTP request to the site itself // This goes through the full WordPress stack and triggers cache generation // Using same approach as curl: full HTTP request with proper headers and cookies $response = wp_remote_get($translated_url, array( 'timeout' => 90, 'sslverify' => false, 'cookies' => $cookies, 'headers' => array( 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language' => $lang_code . ',' . $lang_code . ';q=0.9,en;q=0.8', 'Accept-Encoding' => 'gzip, deflate, br', 'Connection' => 'keep-alive', 'Upgrade-Insecure-Requests' => '1' ), 'redirection' => 5, // Follow redirects 'httpversion' => '1.1' )); if (is_wp_error($response)) { return array('success' => false, 'error' => $response->get_error_message()); } $response_code = wp_remote_retrieve_response_code($response); // Accept 200 as success (redirects might indicate issues, but we'll check cache anyway) if ($response_code === 200) { // Verify cache was generated by checking if cache file exists // Wait a moment for async cache writes to complete usleep(500000); // 0.5 second $route_id = warm_cache_route_id((int) $post_id); $cache_key = \AITranslate\AI_Cache::key($lang_code, $route_id, ''); $cache_file = \AITranslate\AI_Cache::get_file_path($cache_key); if ($cache_file && file_exists($cache_file)) { // Ensure metadata is inserted/updated in database // The cache file exists, but metadata might not be in database yet $cache_hash = md5($cache_key); AI_Cache_Meta::insert($post_id, $lang_code, $cache_file, $cache_hash); return array('success' => true, 'error' => ''); } else { // Check again after a longer delay (some systems might be slower) sleep(1); if ($cache_file && file_exists($cache_file)) { // Ensure metadata is inserted/updated in database $cache_hash = md5($cache_key); AI_Cache_Meta::insert($post_id, $lang_code, $cache_file, $cache_hash); return array('success' => true, 'error' => ''); } // Request was successful but cache not found - might be bypassed or error in generation return array('success' => false, 'error' => __('Cache file not generated after successful request', 'ai-translate')); } } // Also accept redirects as they might be normal (language redirects) if ($response_code === 301 || $response_code === 302) { $location = wp_remote_retrieve_header($response, 'location'); if ($location) { // Try following the redirect $redirect_url = (strpos($location, 'http') === 0) ? $location : home_url($location); return warm_cache_internal_request($post_id, wp_parse_url($redirect_url, PHP_URL_PATH), $lang_code); } } return array('success' => false, 'error' => safe_sprintf(/* translators: %d: number value */ __('HTTP %d', 'ai-translate'), $response_code)); } /** * Handle AJAX request to warm cache for a specific post in all enabled languages. * * Security: requires manage_options capability and a valid nonce. */ function ajax_warm_post_cache() { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions to perform this action.', 'ai-translate')]); return; } // Validate nonce $nonce_value = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : null; if (!$nonce_value || !wp_verify_nonce($nonce_value, 'ai_translate_warm_cache')) { wp_send_json_error(['message' => __('Security check failed (nonce). Refresh the page and try again.', 'ai-translate')]); return; } // Validate post ID $raw_post_id = isset($_POST['post_id']) ? sanitize_text_field(wp_unslash((string) $_POST['post_id'])) : null; if ($raw_post_id === null || trim((string) $raw_post_id) === '') { wp_send_json_error(['message' => __('No post ID provided.', 'ai-translate')]); return; } $post_id = intval($raw_post_id); if ($post_id < -1) { wp_send_json_error(['message' => __('Invalid post ID.', 'ai-translate')]); return; } // For homepage (post_id = 0) and 404 page (post_id = -1), skip post verification if ($post_id > 0) { // Verify post exists and user has permission to access it $post = get_post($post_id); if (!$post) { wp_send_json_error(['message' => __('Post not found.', 'ai-translate')]); return; } // Security: Only allow published posts and pages (prevent access to drafts/private posts) if ($post->post_status !== 'publish') { wp_send_json_error(['message' => __('Only published posts can be cached.', 'ai-translate')]); return; } // Pages that are never page-cached (DONOTCACHEPAGE, e.g. WooCommerce // cart/checkout/my-account): warming would spend API calls while the // result is never stored. if (in_array($post_id, AI_Cache_Meta::get_never_cached_post_ids(), true)) { wp_send_json_error(['message' => __('This page is dynamic and is never cached.', 'ai-translate')]); return; } } // Ensure table exists AI_Cache_Meta::ensure_table_exists(); // Get enabled languages (selectable + detectable) $enabled = \AITranslate\AI_Lang::enabled(); $detectable = \AITranslate\AI_Lang::detectable(); $languages = array_unique(array_merge($enabled, $detectable)); if (empty($languages)) { wp_send_json_error(['message' => __('No languages configured.', 'ai-translate')]); return; } // Get post URL (homepage uses home_url, 404 uses a non-existent path to trigger 404) if ($post_id === 0) { $post_url = home_url('/'); } elseif ($post_id === -1) { // 404 page: use a non-existent path that will trigger a 404 $post_url = home_url('/ait-404-cache-warm/'); } else { $post_url = get_permalink($post_id); if (!$post_url) { wp_send_json_error(['message' => __('Could not generate post URL.', 'ai-translate')]); return; } } // Security: Validate URL is from same site (prevent SSRF) $parsed_url = wp_parse_url($post_url); $site_url = wp_parse_url(home_url()); if (!isset($parsed_url['host']) || !isset($site_url['host']) || $parsed_url['host'] !== $site_url['host']) { wp_send_json_error(['message' => __('Invalid post URL.', 'ai-translate')]); return; } // Get existing cache languages for this post $existing = AI_Cache_Meta::get_cached_languages($post_id); $warmed = 0; $skipped = 0; $errors = []; $default_lang = \AITranslate\AI_Lang::default(); // Stable list of target languages (default language excluded). The browser asks for one // batch at a time via 'offset', so a single request never outlasts PHP's max_execution_time // however many languages are enabled. $targets = []; foreach ($languages as $lang_code) { if ($default_lang && strtolower($lang_code) === strtolower($default_lang)) { continue; } $targets[] = $lang_code; } $total_targets = count($targets); $offset = isset($_POST['offset']) ? max(0, (int) $_POST['offset']) : 0; // The browser sizes batches to how long the previous one took (slow models get fewer // languages per request, fast models more), so no provider needs special handling. $batch_size = isset($_POST['limit']) ? max(1, min(6, (int) $_POST['limit'])) : 3; // Let this request outlast PHP's default limit; each HTTP request inside is capped lower (warm_cache_batch). if (function_exists('set_time_limit')) { @set_time_limit(150); } $slice = array_slice($targets, $offset, $batch_size); // Skip languages in this slice that are already cached $languages_to_warm = []; foreach ($slice as $lang_code) { if (in_array($lang_code, $existing, true)) { $skipped++; continue; } $languages_to_warm[] = $lang_code; } // Languages in this batch are requested in parallel $path = wp_parse_url($post_url, PHP_URL_PATH); if (!empty($languages_to_warm)) { $batch = $languages_to_warm; try { $results = warm_cache_batch($post_id, $path, $batch); foreach ($results as $lang_code => $result) { if ($result['success']) { $warmed++; } else { $errors[] = sprintf(/* translators: %1$s: string value, %2$s: string value */ __('Error for %1$s: %2$s', 'ai-translate'), $lang_code, $result['error']); } } } catch (\Throwable $e) { foreach ($batch as $lang_code) { $errors[] = sprintf(/* translators: %1$s: string value, %2$s: string value */ __('Error for %1$s: %2$s', 'ai-translate'), $lang_code, $e->getMessage()); } } } // Clear transient cache to force refresh of stats delete_transient('ai_translate_cache_table_data'); // Force refresh of cache metadata by ensuring all generated cache files have metadata // This ensures the database is in sync with the filesystem if ($warmed > 0) { $route_id = warm_cache_route_id($post_id); foreach ($languages_to_warm as $lang) { $cache_key = \AITranslate\AI_Cache::key($lang, $route_id, ''); $cache_file = \AITranslate\AI_Cache::get_file_path($cache_key); if ($cache_file && file_exists($cache_file)) { // Ensure metadata exists for this cache file $cache_hash = md5($cache_key); AI_Cache_Meta::insert($post_id, $lang, $cache_file, $cache_hash); } } } $message = sprintf(/* translators: %d: number value */ __('Cache warmed for %d languages', 'ai-translate'), $warmed); if ($skipped > 0) { $message .= sprintf(/* translators: %d: number value */ __(' (%d skipped)', 'ai-translate'), $skipped); } if (!empty($errors)) { $message .= '. ' . __('Warnings:', 'ai-translate') . ' ' . implode(', ', array_slice($errors, 0, 3)); if (count($errors) > 3) { $message .= sprintf(/* translators: %d: number value */ __(' and %d more', 'ai-translate'), count($errors) - 3); } } $next_offset = $offset + count($slice); wp_send_json_success([ 'message' => $message, 'warmed' => $warmed, 'skipped' => $skipped, 'errors' => $errors, 'total' => $total_targets, 'next_offset' => $next_offset, 'done' => $next_offset >= $total_targets, ]); } add_action('wp_ajax_ai_translate_warm_cache', __NAMESPACE__ . '\\ajax_warm_post_cache'); /** * Handle AJAX request to generate a website context suggestion. * * Security: requires manage_options capability and a valid nonce. * Clears prompt cache to ensure immediate use of new context. */ function ajax_generate_website_context() { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions to perform this action.', 'ai-translate')]); return; } // Validate nonce $nonce_value = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : null; if (!$nonce_value || !wp_verify_nonce($nonce_value, 'generate_website_context_nonce')) { wp_send_json_error(['message' => __('Security check failed (nonce). Refresh the page and try again.', 'ai-translate')]); return; } // Get domain from request (for multi-domain caching support) $requested_domain = isset($_POST['domain']) ? sanitize_text_field(wp_unslash($_POST['domain'])) : ''; // Als domain niet is meegestuurd maar multi-domain caching aan staat, bepaal actieve domain if (empty($requested_domain)) { $settings = AI_Translate_Core::settings(); $multi_domain = isset($settings['multi_domain_caching']) ? (bool) $settings['multi_domain_caching'] : false; if ($multi_domain) { // Bepaal actieve domain op dezelfde manier als in de UI if (isset($_SERVER['HTTP_HOST']) && !empty($_SERVER['HTTP_HOST'])) { $requested_domain = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); if (strpos($requested_domain, ':') !== false) { $requested_domain = strtok($requested_domain, ':'); } } if (empty($requested_domain) && isset($_SERVER['SERVER_NAME']) && !empty($_SERVER['SERVER_NAME'])) { $requested_domain = sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])); } if (empty($requested_domain)) { $requested_domain = wp_parse_url(home_url(), PHP_URL_HOST); if (empty($requested_domain)) { $requested_domain = 'default'; } } } } // Generate website context suggestion $translator = AI_Translate_Core::get_instance(); try { $context_suggestion = $translator->generate_website_context_suggestion($requested_domain); // Clear prompt cache to ensure new context is used immediately $translator->clear_prompt_cache(); wp_send_json_success([ 'context' => $context_suggestion ]); } catch (\Exception $e) { wp_send_json_error([ 'message' => __('Error generating context:', 'ai-translate') . ' ' . $e->getMessage() ]); } } // Register the new AJAX handler add_action('wp_ajax_ai_translate_generate_website_context', __NAMESPACE__ . '\\ajax_generate_website_context'); /** * Handle AJAX request to generate homepage meta description. */ function ajax_generate_homepage_meta() { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions to perform this action.', 'ai-translate')]); return; } // Validate nonce $nonce_value = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : null; if (!$nonce_value || !wp_verify_nonce($nonce_value, 'generate_homepage_meta_nonce')) { wp_send_json_error(['message' => __('Security check failed (nonce). Refresh the page and try again.', 'ai-translate')]); return; } // Get domain from request (for multi-domain caching support) $requested_domain = isset($_POST['domain']) ? sanitize_text_field(wp_unslash($_POST['domain'])) : ''; // Als domain niet is meegestuurd maar multi-domain caching aan staat, bepaal actieve domain if (empty($requested_domain)) { $settings = AI_Translate_Core::settings(); $multi_domain = isset($settings['multi_domain_caching']) ? (bool) $settings['multi_domain_caching'] : false; if ($multi_domain) { // Bepaal actieve domain op dezelfde manier als in de UI if (isset($_SERVER['HTTP_HOST']) && !empty($_SERVER['HTTP_HOST'])) { $requested_domain = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); if (strpos($requested_domain, ':') !== false) { $requested_domain = strtok($requested_domain, ':'); } } if (empty($requested_domain) && isset($_SERVER['SERVER_NAME']) && !empty($_SERVER['SERVER_NAME'])) { $requested_domain = sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])); } if (empty($requested_domain)) { $requested_domain = wp_parse_url(home_url(), PHP_URL_HOST); if (empty($requested_domain)) { $requested_domain = 'default'; } } } } // Generate meta description $translator = AI_Translate_Core::get_instance(); try { $meta_description = $translator->generate_homepage_meta_description($requested_domain); wp_send_json_success([ 'meta' => $meta_description ]); } catch (\Exception $e) { wp_send_json_error([ 'message' => __('Error generating meta description:', 'ai-translate') . ' ' . $e->getMessage() ]); } } add_action('wp_ajax_ai_translate_generate_homepage_meta', __NAMESPACE__ . '\\ajax_generate_homepage_meta'); /** * Clear prompt cache when settings are updated (specifically when website context changes) */ add_action('update_option_ai_translate_settings', function ($old_value, $value) { // Check if website context has changed $old_context = isset($old_value['website_context']) ? $old_value['website_context'] : ''; $new_context = isset($value['website_context']) ? $value['website_context'] : ''; if ($old_context !== $new_context) { // Clear prompt cache to force regeneration with new context if (class_exists('AI_Translate_Core')) { $core = AI_Translate_Core::get_instance(); $core->clear_prompt_cache(); } } }, 10, 2); /** * AJAX handlers and admin UI bootstrap below. */ // Add admin menu add_action('admin_menu', function () { add_menu_page( __('AI Translate', 'ai-translate'), __('AI Translate', 'ai-translate'), 'manage_options', 'ai-translate', __NAMESPACE__ . '\\render_admin_page', 'dashicons-translation', 100 ); }); // Enqueue admin scripts and styles add_action('admin_enqueue_scripts', function ($hook) { // Only load on our plugin's admin page if ($hook !== 'toplevel_page_ai-translate') { return; } // Asset versions follow the file's modification time: a hardcoded version string left // browsers on the old admin JS/CSS after a plugin update. $asset_version = static function ($file) { $mtime = @filemtime(plugin_dir_path(__DIR__) . 'assets/' . $file); return $mtime ? (string) $mtime : '1.0.0'; }; // Enqueue CSS wp_enqueue_style( 'ai-translate-admin-css', plugin_dir_url(__DIR__) . 'assets/admin-page.css', array(), $asset_version('admin-page.css') ); // Enqueue main admin JavaScript wp_enqueue_script( 'ai-translate-admin-js', plugin_dir_url(__DIR__) . 'assets/admin-page.js', array('jquery'), $asset_version('admin-page.js'), true ); // Enqueue cache table JavaScript wp_enqueue_script( 'ai-translate-cache-table-js', plugin_dir_url(__DIR__) . 'assets/admin-cache-table.js', array('jquery'), $asset_version('admin-cache-table.js'), true ); // Localize script with data needed by JavaScript $settings = AI_Translate_Core::settings(); wp_localize_script('ai-translate-admin-js', 'aiTranslateAdmin', array( 'adminUrl' => esc_url(admin_url('admin.php?page=ai-translate')), 'ajaxUrl' => admin_url('admin-ajax.php'), 'getModelsNonce' => wp_create_nonce('ai_translate_get_models_nonce'), 'validateApiNonce' => wp_create_nonce('ai_translate_validate_api_nonce'), 'getCustomUrlNonce' => wp_create_nonce('ai_translate_get_custom_url_nonce'), 'generateContextNonce' => wp_create_nonce('generate_website_context_nonce'), 'generateMetaNonce' => wp_create_nonce('generate_homepage_meta_nonce'), 'getApiKeyNonce' => wp_create_nonce('ai_translate_get_api_key_nonce'), 'apiKeyMask' => KEY_MASK, 'models' => isset($settings['models']) && is_array($settings['models']) ? $settings['models'] : [], 'customModel' => isset($settings['custom_model']) ? $settings['custom_model'] : '', 'languageSettingsNonce' => wp_create_nonce('ai_translate_language_settings_nonce'), 'languageSettingsStrings' => array( 'saving' => __('Saving language settings...', 'ai-translate'), 'saved' => __('Languages saved', 'ai-translate'), 'error' => __('Failed to save language settings', 'ai-translate'), ), 'strings' => array( 'enterApiKeyFirst' => __('Enter API Key first...', 'ai-translate'), 'enterApiKeyToLoadModels' => __('Enter API Key first to load models.', 'ai-translate'), 'loadingModels' => __('Loading models...', 'ai-translate'), 'modelsLoadedSuccessfully' => __('Models loaded successfully.', 'ai-translate'), 'noModelsFound' => __('No models found', 'ai-translate'), 'invalidKey' => __('Invalid key', 'ai-translate'), 'errorLoadingModels' => __('Error loading models', 'ai-translate'), 'selectApiProviderFirst' => __('Select API Provider first...', 'ai-translate'), 'selectModel' => __('Select a model...', 'ai-translate'), 'alwaysThinkingWarning' => __('This model always thinks and that cannot be switched off. Thinking models have little benefit for translation and are usually slower and more expensive.', 'ai-translate'), /* translators: 1: input price, 2: output price, both in dollars per million tokens */ 'modelPrice' => __('Price: $%1$s input / $%2$s output per million tokens.', 'ai-translate'), 'unknownError' => __('Unknown error', 'ai-translate'), 'saveSettingsFirst' => __('Save your settings first before opening the menu editor.', 'ai-translate'), 'requestKey' => __('Request Key', 'ai-translate'), 'validatingApi' => __('Validating API settings...', 'ai-translate'), 'validationSaved' => __('Connection and model OK. API settings saved.', 'ai-translate'), 'validationError' => __('Validation failed', 'ai-translate'), 'validationAjaxError' => __('Validation AJAX Error', 'ai-translate'), 'validateApiPrompt' => __('Validate API settings', 'ai-translate'), 'selectCustom' => __('Select...', 'ai-translate'), 'enterModelManually' => __('Enter model name manually', 'ai-translate'), 'noCacheFiles' => __('No cache files', 'ai-translate'), 'securityTokenMissing' => __('Security token not found. Refresh the page and try again.', 'ai-translate'), 'processing' => __('Processing...', 'ai-translate'), 'cacheClearError' => /* translators: %s: error detail */ __('An error occurred while clearing the cache: %s', 'ai-translate'), 'filesInCache' => /* translators: %s: number of cache files */ __('%s files in cache', 'ai-translate'), 'enterApiKeyNotice' => __('Note: Enter API Key to use translation functionality.', 'ai-translate'), 'configureApiKeyFirst' => __('Please configure API key first', 'ai-translate'), 'confirmReplaceContext' => __('The context field already has content. Do you want to replace it with a generated suggestion?', 'ai-translate'), 'generatingContext' => __('Generating context from homepage...', 'ai-translate'), 'contextGenerated' => __('Context generated successfully!', 'ai-translate'), 'failedGenerateContext' => __('Failed to generate context', 'ai-translate'), 'errorGeneratingContext' => __('Error generating context:', 'ai-translate'), 'confirmReplaceMeta' => __('The meta description field already has content. Do you want to replace it with a generated suggestion?', 'ai-translate'), 'generatingMeta' => __('Generating meta description from homepage...', 'ai-translate'), 'metaGenerated' => __('Meta description generated successfully!', 'ai-translate'), 'failedGenerateMeta' => __('Failed to generate meta description', 'ai-translate'), 'errorGeneratingMeta' => __('Error generating meta description:', 'ai-translate'), 'serverResponseNotOk' => /* translators: %s: HTTP status code */ __('Server response was not ok: %s', 'ai-translate'), ), )); // Localize cache table script with ajaxurl (WordPress provides ajaxurl in admin, but we make it explicit) wp_localize_script('ai-translate-cache-table-js', 'aiTranslateCacheTable', array( 'ajaxurl' => admin_url('admin-ajax.php'), 'listNonce' => wp_create_nonce('ai_translate_cache_urls_nonce'), 'delete_nonce' => wp_create_nonce('ai_translate_delete_cache_file_nonce'), 'strings' => array( 'loading' => __('Loading cached URLs...', 'ai-translate'), 'error' => __('Could not load cached URLs. Please try again.', 'ai-translate'), 'timeout' => __('Request timed out. The site may have many cache files. Please try again or use the rescan function.', 'ai-translate'), 'empty' => __('No cached pages for this language.', 'ai-translate'), 'show' => __('Show cached URLs', 'ai-translate'), 'hide' => __('Hide cached URLs', 'ai-translate'), 'truncated' => /* translators: %1$s: number of shown entries, %2$s: total number of entries */ __('Showing first %1$s of %2$s entries', 'ai-translate'), 'delete_file' => __('Delete file', 'ai-translate'), 'deleting' => __('Deleting...', 'ai-translate'), 'confirm_delete' => __('Are you sure you want to delete this cache file?', 'ai-translate'), 'delete_error' => __('Failed to delete cache file', 'ai-translate'), 'confirm_delete_page' => __('Are you sure you want to delete the cache for this page?', 'ai-translate'), 'ajaxUrlMissing' => __('Error: AJAX URL not found. Please refresh the page and try again.', 'ai-translate'), 'warming' => __('Warming...', 'ai-translate'), 'warmingProgress' => /* translators: %1$s: languages processed so far, %2$s: total languages */ __('Warming... %1$s / %2$s', 'ai-translate'), 'warmDone' => /* translators: %1$s: number of languages warmed, %2$s: number skipped because already cached */ __('Cache warmed for %1$s languages (%2$s skipped)', 'ai-translate'), 'warmWarnings' => __('Warnings:', 'ai-translate'), 'statusOf' => /* translators: %1$s: cached language count, %2$s: total languages */ __('%1$s of %2$s', 'ai-translate'), 'errorPrefix' => /* translators: %s: error detail */ __('Error: %s', 'ai-translate'), 'ajaxError' => /* translators: %s: error detail */ __('AJAX error: %s', 'ai-translate'), 'unknownError' => __('Unknown error', 'ai-translate'), ), )); }); /** * API settings section: provider, key and model. */ function register_api_settings_section() { // API Settings Section add_settings_section( 'ai_translate_api', __('API Settings', 'ai-translate'), null, 'ai-translate' ); add_settings_field( 'api_provider', __('API Provider', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $current_provider_key = isset($settings['api_provider']) ? $settings['api_provider'] : ''; $providers = AI_Translate_Core::get_api_providers(); echo '<select id="api_provider_select" name="ai_translate_settings[api_provider]">'; echo '<option value="" ' . selected($current_provider_key, '', false) . ' disabled hidden>' . esc_html__('— Select provider —', 'ai-translate') . '</option>'; foreach ($providers as $key => $provider_details) { echo '<option value="' . esc_attr($key) . '" ' . selected($current_provider_key, $key, false) . '>' . esc_html($provider_details['name']) . '</option>'; } echo '</select>'; echo '<div id="openai_gpt5_warning" class="ai-translate-provider-note" style="margin-top:10px; display:none;">'; echo '<p class="description"><strong>' . esc_html__('Note:', 'ai-translate') . '</strong> ' . esc_html__('Reasoning/thinking is turned off when the provider allows it (otherwise the lowest supported effort is used) to keep translations fast and cheap.', 'ai-translate') . '</p>'; echo '</div>'; // Custom URL field echo '<div id="custom_api_url_div" style="margin-top:10px; display:none;">'; echo '<input type="url" name="ai_translate_settings[custom_api_url]" value="' . esc_attr($settings['custom_api_url'] ?? '') . '" placeholder="https://api.perplexity.ai" class="regular-text" autocomplete="off" id="ai_translate_custom_api_url">'; // Known OpenAI-compatible endpoints to pick from; the script shows them in a menu (the field still takes any URL). echo '<script type="application/json" id="ai_translate_api_url_choices">' . wp_json_encode([ 'https://openrouter.ai/api/v1/', 'https://api.anthropic.com/v1/', 'https://api.openai.com/v1/', 'https://api.perplexity.ai', 'https://api.mistral.ai/v1/', 'https://api.together.xyz/v1/', 'https://api.x.ai/v1/', 'https://api.fireworks.ai/inference/v1/', ]) . '</script>'; $description_text = esc_html__('Enter the base URL of your API provider, or pick a known one with the arrow. The plugin appends /chat/completions automatically.', 'ai-translate'); $url_link = '<a href="https://openrouter.ai/api/v1/" target="_blank">https://openrouter.ai/api/v1/</a>'; // Count %s placeholders in the translated string to prevent sprintf errors $placeholder_count = substr_count($description_text, '%s'); if ($placeholder_count === 1) { $description_html = safe_sprintf($description_text, $url_link); } elseif ($placeholder_count === 0) { $description_html = $description_text; } else { // Fallback for corrupted translations with too many placeholders $description_html = str_replace('%s', $url_link, $description_text); // Remove extra %s placeholders that couldn't be replaced $description_html = preg_replace('/%s/', '', $description_html); } echo '<p class="description">' . wp_kses($description_html, array('a' => array('href' => array(), 'target' => array()))) . '</p>'; echo '</div>'; }, 'ai-translate', 'ai_translate_api' ); add_settings_field( 'api_key', __('API Key', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $current_provider_key = isset($settings['api_provider']) ? $settings['api_provider'] : ''; // Haal de API-sleutel op uit de nieuwe 'api_keys' array $api_keys = $settings['api_keys'] ?? []; // The saved key itself never goes into the page: show the mask when there is one $value = AI_Translate_Core::api_key_for($settings, (string) $current_provider_key) !== '' ? KEY_MASK : ''; // Not a login. An invisible dummy "username" field takes the username role, so the browser does not // treat the API URL field above as one (which gave username suggestions on that field). echo '<input type="text" name="ai_translate_dummy_user" value="" autocomplete="username" tabindex="-1" aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0;">'; echo '<input type="password" name="ai_translate_settings[api_key]" id="ai_translate_api_key" value="' . esc_attr($value) . '" class="regular-text" autocomplete="new-password" data-lpignore="true" data-1p-ignore="true">'; echo ' <span id="api-key-request-link-span" style="margin-left:10px;"></span>'; }, 'ai-translate', 'ai_translate_api' ); add_settings_field( 'selected_model', __('Translation Model', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $current_provider = isset($settings['api_provider']) ? $settings['api_provider'] : ''; $models = isset($settings['models']) ? $settings['models'] : []; $selected_model = $current_provider !== '' ? ($models[$current_provider] ?? '') : ''; $unavailable_notice = AI_Translate_Core::unavailable_model_notice((string) $current_provider, (string) $selected_model); $selected_model = $current_provider !== '' ? AI_Translate_Core::effective_model((string) $current_provider, (string) $selected_model) : ''; $custom_model = isset($settings['custom_model']) ? $settings['custom_model'] : ''; echo '<select name="ai_translate_settings[selected_model]" id="selected_model">'; echo '<option value="" ' . selected($selected_model, '', false) . ' disabled hidden>' . esc_html__('— Select model —', 'ai-translate') . '</option>'; if ($selected_model) { echo '<option value="' . esc_attr($selected_model) . '" selected>' . esc_html($selected_model) . '</option>'; } echo '<option value="custom">' . esc_html__('Select...', 'ai-translate') . '</option>'; echo '</select> '; if ($unavailable_notice !== '') { echo '<p class="notice notice-warning inline" style="margin:8px 0 0;padding:8px 12px;"><strong>' . esc_html($unavailable_notice) . '</strong></p>'; } echo '<div id="custom_model_div" style="margin-top:10px; display:none;">'; echo '<input type="text" name="ai_translate_settings[custom_model]" value="' . esc_attr($custom_model) . '" placeholder="' . esc_attr__('E.g.: deepseek-chat, gpt-4o, ...', 'ai-translate') . '" class="regular-text">'; echo '</div>'; echo '<div class="ai-translate-api-status-wrapper">'; echo '<span id="ai-translate-api-status"></span>'; echo '</div>'; }, 'ai-translate', 'ai_translate_api' ); } /** * Language settings section: default, enabled and detectable languages, switcher. */ function register_language_settings_section() { // Language Settings Section add_settings_section( 'ai_translate_languages', __('Language Settings', 'ai-translate'), function () { echo '<p>' . esc_html(__('Select the default language for your site and which languages should be available in the language switcher. Detectable languages will be used if a visitor\'s browser preference matches, but won\'t show in the switcher.', 'ai-translate')) . '</p>'; echo '<input type="hidden" id="ai-translate-language-settings-nonce" value="' . esc_attr(wp_create_nonce('ai_translate_language_settings_nonce')) . '">'; }, 'ai-translate' ); add_settings_field( 'default_language', __('Default Language', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $value = isset($settings['default_language']) ? $settings['default_language'] : ''; $core = AI_Translate_Core::get_instance(); $languages = $core->get_available_languages(); // Get all available languages echo '<select name="ai_translate_settings[default_language]">'; echo '<option value="" ' . selected($value, '', false) . ' disabled hidden>' . esc_html__('— Select default language —', 'ai-translate') . '</option>'; // Ensure current saved language stays visible if not in list if ($value !== '' && !isset($languages[$value])) { echo '<option value="' . esc_attr($value) . '" selected>' . esc_html(ucfirst($value)) . ' (' . esc_html__('Current', 'ai-translate') . ')</option>'; } foreach ($languages as $code => $name) { echo '<option value="' . esc_attr($code) . '" ' . selected($value, $code, false) . '>' . esc_html($name . ' (' . $code . ')') . '</option>'; } echo '</select>'; }, 'ai-translate', 'ai_translate_languages' ); add_settings_field( 'enabled_languages', __('Enabled Languages (in Switcher)', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $enabled = isset($settings['enabled_languages']) ? (array)$settings['enabled_languages'] : []; $core = AI_Translate_Core::get_instance(); $languages = $core->get_available_languages(); // Use all available languages $columns = array_chunk($languages, 13, true); if (count($columns) < 3) { while (count($columns) < 3) { $columns[] = []; } } if (count($columns) > 3) { $extra = array_slice($columns, 3); $columns = array_slice($columns, 0, 3); foreach ($extra as $additional) { foreach ($additional as $code => $name) { $columns[2][$code] = $name; } } } echo '<div class="ai-translate-language-section" data-language-section="enabled">'; echo '<div class="ai-translate-language-section__header">'; echo '<div class="ai-translate-language-section__header-label">' . esc_html__('Instant save', 'ai-translate') . '</div>'; echo '<div class="ai-translate-language-section__header-actions">'; echo '<button type="button" class="button button-secondary ai-translate-language-section__toggle" data-action="enable" data-section="enabled">' . esc_html__('Enable all', 'ai-translate') . '</button>'; echo '<button type="button" class="button button-secondary ai-translate-language-section__toggle" data-action="disable" data-section="enabled">' . esc_html__('Disable all', 'ai-translate') . '</button>'; echo '</div>'; echo '<span class="ai-translate-language-section__status" data-section="enabled"></span>'; echo '</div>'; echo '<div class="ai-translate-language-section__list" data-section="enabled">'; echo '<div class="ai-translate-language-section__columns">'; // Flags base URL $flags_url = plugin_dir_url(__DIR__) . 'assets/flags/'; foreach ($columns as $column) { echo '<div class="ai-translate-language-column">'; foreach ($column as $code => $name) { echo '<label>'; echo '<input type="checkbox" name="ai_translate_settings[enabled_languages][]" value="' . esc_attr($code) . '" ' . checked(in_array($code, $enabled), true, false) . '> ' . '<img src="' . esc_url($flags_url . $code . '.png') . '" alt="' . esc_attr(strtoupper($code)) . '" class="ai-translate-language-flag"> ' . esc_html($name . ' (' . $code . ')'); echo '</label>'; } echo '</div>'; } echo '</div>'; echo '</div>'; echo '</div>'; echo '<p class="description">' . esc_html(__('Languages selected here will appear in the language switcher.', 'ai-translate')) . '</p>'; }, 'ai-translate', 'ai_translate_languages' ); // --- Add Detectable Languages Field --- add_settings_field( 'detectable_languages', __('Detectable Languages (Auto-Translate)', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $detected_enabled = isset($settings['detectable_languages']) ? (array)$settings['detectable_languages'] : []; $core = AI_Translate_Core::get_instance(); // Retrieve ALL available languages from the core class $languages = $core->get_available_languages(); // Flags base URL $flags_url = plugin_dir_url(__DIR__) . 'assets/flags/'; $columns = array_chunk($languages, 13, true); if (count($columns) < 3) { while (count($columns) < 3) { $columns[] = []; } } if (count($columns) > 3) { $extra = array_slice($columns, 3); $columns = array_slice($columns, 0, 3); foreach ($extra as $additional) { foreach ($additional as $code => $name) { $columns[2][$code] = $name; } } } echo '<div class="ai-translate-language-section" data-language-section="detectable">'; echo '<div class="ai-translate-language-section__header">'; echo '<div class="ai-translate-language-section__header-label">' . esc_html__('Instant save', 'ai-translate') . '</div>'; echo '<div class="ai-translate-language-section__header-actions">'; echo '<button type="button" class="button button-secondary ai-translate-language-section__toggle" data-action="enable" data-section="detectable">' . esc_html__('Enable all', 'ai-translate') . '</button>'; echo '<button type="button" class="button button-secondary ai-translate-language-section__toggle" data-action="disable" data-section="detectable">' . esc_html__('Disable all', 'ai-translate') . '</button>'; echo '</div>'; echo '<span class="ai-translate-language-section__status" data-section="detectable"></span>'; echo '</div>'; echo '<div class="ai-translate-language-section__list" data-section="detectable">'; echo '<div class="ai-translate-language-section__columns">'; // Loop through ALL available languages from the core class foreach ($columns as $column) { echo '<div class="ai-translate-language-column">'; foreach ($column as $code => $name) { echo '<label>'; echo '<input type="checkbox" name="ai_translate_settings[detectable_languages][]" value="' . esc_attr($code) . '" ' . checked(in_array($code, $detected_enabled), true, false) . '> ' . '<img src="' . esc_url($flags_url . $code . '.png') . '" alt="' . esc_attr(strtoupper($code)) . '" class="ai-translate-language-flag"> ' . esc_html($name . ' (' . $code . ')'); echo '</label>'; } echo '</div>'; } echo '</div>'; echo '</div>'; echo '</div>'; echo '<p class="description">' . esc_html(__('If a visitor\'s browser language matches one of these, the site will be automatically translated (if enabled), but these languages won\'t show in the switcher.', 'ai-translate')) . '</p>'; }, 'ai-translate', 'ai_translate_languages' ); // --- End Detectable Languages Field --- add_settings_field( 'switcher_position', __('Language Switcher Position', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $position = isset($settings['switcher_position']) ? $settings['switcher_position'] : 'bottom-left'; $positions = array( 'bottom-left' => __('Bottom Left Corner', 'ai-translate'), 'bottom-right' => __('Bottom Right Corner', 'ai-translate'), 'top-left' => __('Top Left Corner', 'ai-translate'), 'top-right' => __('Top Right Corner', 'ai-translate'), 'none' => __('Hidden - Add manually via Appearance > Menus', 'ai-translate'), ); echo '<fieldset>'; foreach ($positions as $value => $label) { echo '<label style="display:block;margin-bottom:8px;">'; echo '<input type="radio" name="ai_translate_settings[switcher_position]" value="' . esc_attr($value) . '" ' . checked($position, $value, false) . '> '; echo esc_html($label); echo '</label>'; } echo '</fieldset>'; echo '<p class="description">' . esc_html(__('Choose where the language switcher appears on your website. A corner position shows a floating switcher; the "Hidden" option disables the floating switcher so you can add a language switcher to your navigation menu instead. Only one method is active at a time.', 'ai-translate')) . '</p>'; echo '<p><a href="' . esc_url(admin_url('nav-menus.php')) . '" target="_blank" class="button button-secondary">' . esc_html(__('Open Menu Editor', 'ai-translate')) . '</a> ' . esc_html(__('Add language switcher to your navigation menus', 'ai-translate')) . '</p>'; }, 'ai-translate', 'ai_translate_languages' ); } /** * Cache settings section: duration, menu updates, stopping translations, background warming. */ function register_cache_settings_section() { // Cache Settings Section add_settings_section( 'ai_translate_cache', __('Cache Settings', 'ai-translate'), null, 'ai-translate' ); add_settings_field( 'cache_expiration', __('Cache Duration (days)', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $raw = $settings['cache_expiration'] ?? null; $days = 14; // cache_expiration should be stored in HOURS, but some installs may still have legacy DAYS. if (is_numeric($raw)) { $num = (float) $raw; if ($num >= 14 && $num < (14 * 24)) { // Likely legacy days (e.g. "14") $days = (int) $num; } elseif ($num >= (14 * 24)) { // Hours (e.g. 336) $days = (int) floor($num / 24); } } if ($days < 14) { $days = 14; } echo '<input type="number" name="ai_translate_settings[cache_expiration]" value="' . esc_attr($days) . '" class="small-text" min="14"> ' . esc_html__('days', 'ai-translate'); echo ' <em style="margin-left:10px;">(' . esc_html__('minimum 14 days', 'ai-translate') . ')</em>'; }, 'ai-translate', 'ai_translate_cache' ); add_settings_field( 'keep_slugs_in_english', __('Keep URL Slugs in English', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $value = isset($settings['keep_slugs_in_english']) ? (bool)$settings['keep_slugs_in_english'] : false; echo '<input type="checkbox" name="ai_translate_settings[keep_slugs_in_english]" value="1" ' . checked($value, true, false) . '> '; echo '<label>' . esc_html__('Keep URL slugs in English instead of translating them to target languages', 'ai-translate') . '</label>'; echo '<p class="description">' . esc_html__('When enabled, URLs will remain consistent across languages (e.g., /zh/cv-tips/ instead of /zh/简历技巧/).', 'ai-translate') . '</p>'; }, 'ai-translate', 'ai_translate_cache' ); add_settings_field( 'auto_clear_pages_on_menu_update', __('Auto-Clear Pages on Menu Update', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $raw = $settings['auto_clear_pages_on_menu_update'] ?? true; $value = function_exists('wp_validate_boolean') ? wp_validate_boolean($raw) : (bool) $raw; echo '<label>'; echo '<input type="checkbox" name="ai_translate_settings[auto_clear_pages_on_menu_update]" value="1" ' . checked($value, true, false) . '> '; echo esc_html__('Automatically clear all page caches when menu items are updated', 'ai-translate'); echo '</label>'; echo '<p class="description">'; echo '<strong>' . esc_html__('Note:', 'ai-translate') . '</strong> '; echo esc_html__('Menu translation caches (transients) are ALWAYS cleared when you update menus, regardless of this setting.', 'ai-translate'); echo '<br>'; echo '<strong>' . esc_html__('When enabled:', 'ai-translate') . '</strong> '; echo esc_html__('All translated page HTML caches will also be cleared. Menu changes are visible immediately, but requires re-translating pages on next visit (expensive for large sites with many languages).', 'ai-translate'); echo '<br>'; echo '<strong>' . esc_html__('When disabled:', 'ai-translate') . '</strong> '; echo esc_html__('Only menu caches are cleared. Page HTML caches remain. Menu changes in already-cached pages will only be visible after cache expires or manual clearing via Cache tab.', 'ai-translate'); echo '</p>'; }, 'ai-translate', 'ai_translate_cache' ); add_settings_field( 'multi_domain_caching', __('Multi-Domain Caching', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $raw = $settings['multi_domain_caching'] ?? false; $value = function_exists('wp_validate_boolean') ? wp_validate_boolean($raw) : (bool) $raw; echo '<label>'; echo '<input type="checkbox" name="ai_translate_settings[multi_domain_caching]" value="1" ' . checked($value, true, false) . '> '; echo esc_html__('Enable separate cache per domain', 'ai-translate'); echo '</label>'; echo '<p class="description">'; echo esc_html__('When enabled, each domain will have its own cache directory named after the site name. This prevents cache conflicts when multiple domains share the same WordPress installation.', 'ai-translate'); echo '</p>'; }, 'ai-translate', 'ai_translate_cache' ); add_settings_field( 'stop_translations_except_cache_invalidation', __('Stop translations (except cache invalidation)', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $raw = $settings['stop_translations_except_cache_invalidation'] ?? false; $value = function_exists('wp_validate_boolean') ? wp_validate_boolean($raw) : (bool) $raw; echo '<label>'; echo '<input type="checkbox" name="ai_translate_settings[stop_translations_except_cache_invalidation]" value="1" ' . checked($value, true, false) . '> '; echo esc_html__('Stop translations (except cache invalidation)', 'ai-translate'); echo '</label>'; echo '<p class="description">'; echo esc_html__('When enabled, new translations via API will be blocked. Translations will only occur when cached pages expire and need to be refreshed.', 'ai-translate'); echo '</p>'; }, 'ai-translate', 'ai_translate_cache' ); add_settings_field( 'prewarm_on_publish', __('Translate new pages in the background', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $raw = $settings['prewarm_on_publish'] ?? false; $value = function_exists('wp_validate_boolean') ? wp_validate_boolean($raw) : (bool) $raw; echo '<label>'; echo '<input type="checkbox" name="ai_translate_settings[prewarm_on_publish]" value="1" ' . checked($value, true, false) . '> '; echo esc_html__('Translate a page in all languages right after it is published or updated', 'ai-translate'); echo '</label>'; echo '<p class="description">'; echo esc_html__('Visitors then get the translated page straight away instead of waiting for the first translation. This uses your translation service for every enabled language after each save.', 'ai-translate'); echo '</p>'; }, 'ai-translate', 'ai_translate_cache' ); } /** * WooCommerce settings section. */ function register_woocommerce_settings_section() { // WooCommerce Section add_settings_section( 'ai_translate_woocommerce', __('WooCommerce', 'ai-translate'), null, 'ai-translate' ); add_settings_field( 'woocommerce_keep_product_names', __('Product Names', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $value = !empty($settings['woocommerce_keep_product_names']); echo '<label>'; echo '<input type="checkbox" name="ai_translate_settings[woocommerce_keep_product_names]" value="1" ' . checked($value, true, false) . '> '; echo esc_html__('Do not translate product names', 'ai-translate'); echo '</label>'; echo '<p class="description">'; echo esc_html__('When enabled, product names stay in the original language on product pages, listings and cart tables — useful for brand names and SKUs. Clear the page cache after changing this setting.', 'ai-translate'); echo '</p>'; }, 'ai-translate', 'ai_translate_woocommerce' ); } /** * Advanced settings section: website context, glossary, homepage meta, slugs. */ function register_advanced_settings_section() { // Advanced Settings Section add_settings_section( 'ai_translate_advanced', __('Advanced Settings', 'ai-translate'), null, 'ai-translate' ); // --- Add Glossary Field --- add_settings_field( 'glossary', __('Glossary (forced term translations)', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $value = isset($settings['glossary']) ? (string) $settings['glossary'] : ''; echo '<textarea name="ai_translate_settings[glossary]" rows="6" class="large-text code" placeholder="Bike = de: Fahrrad, fr: Vélo, nl: Fiets">' . esc_textarea($value) . '</textarea>'; echo '<p class="description">'; echo esc_html__('One term per line: source term = lang: translation, lang: translation. The source term is matched against your default language; the given translation is then enforced in the translation prompt for that target language. Example: Bike = de: Fahrrad, nl: Fiets. Clear the page cache after changing the glossary.', 'ai-translate'); echo '</p>'; }, 'ai-translate', 'ai_translate_advanced' ); // --- End Glossary Field --- // --- Add Homepage Meta Description Field --- add_settings_field( 'homepage_meta_description', __('Homepage Meta Description', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $multi_domain = isset($settings['multi_domain_caching']) ? (bool) $settings['multi_domain_caching'] : false; // Determine active domain $active_domain = ''; if (isset($_SERVER['HTTP_HOST']) && !empty($_SERVER['HTTP_HOST'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); if (strpos($active_domain, ':') !== false) { $active_domain = strtok($active_domain, ':'); } } if (empty($active_domain) && isset($_SERVER['SERVER_NAME']) && !empty($_SERVER['SERVER_NAME'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])); } if (empty($active_domain)) { $active_domain = wp_parse_url(home_url(), PHP_URL_HOST); if (empty($active_domain)) { $active_domain = 'default'; } } // Get value based on multi-domain setting if ($multi_domain) { $domain_meta = isset($settings['homepage_meta_description_per_domain']) && is_array($settings['homepage_meta_description_per_domain']) ? $settings['homepage_meta_description_per_domain'] : []; $value = isset($domain_meta[$active_domain]) ? $domain_meta[$active_domain] : ''; echo '<p class="description" style="margin-bottom: 10px;"><strong>' . esc_html__('Active domain:', 'ai-translate') . '</strong> <code>' . esc_html($active_domain) . '</code></p>'; } else { $value = isset($settings['homepage_meta_description']) ? $settings['homepage_meta_description'] : ''; } echo '<textarea name="ai_translate_settings[homepage_meta_description]" id="homepage_meta_description_field" rows="3" class="large-text">' . esc_textarea($value) . '</textarea>'; if ($multi_domain) { echo '<p class="description">' . esc_html(__('Enter the specific meta description for the homepage for the current domain (in the default language). This will override the site tagline or generated excerpt on the homepage.', 'ai-translate')) . '</p>'; } else { echo '<p class="description">' . esc_html(__('Enter the specific meta description for the homepage (in the default language). This will override the site tagline or generated excerpt on the homepage.', 'ai-translate')) . '</p>'; } echo '<button type="button" class="button" id="generate-meta-btn" style="margin-top: 10px;">' . esc_html__('Generate Meta Description', 'ai-translate') . '</button>'; echo '<span id="generate-meta-status" style="margin-left: 10px;"></span>'; }, 'ai-translate', 'ai_translate_advanced' // Add to Advanced section ); // --- End Homepage Meta Description Field --- // --- Add Website Context Field --- add_settings_field( 'website_context', __('Website Context', 'ai-translate'), function () { $settings = AI_Translate_Core::settings(); $multi_domain = isset($settings['multi_domain_caching']) ? (bool) $settings['multi_domain_caching'] : false; // Determine active domain $active_domain = ''; if (isset($_SERVER['HTTP_HOST']) && !empty($_SERVER['HTTP_HOST'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); if (strpos($active_domain, ':') !== false) { $active_domain = strtok($active_domain, ':'); } } if (empty($active_domain) && isset($_SERVER['SERVER_NAME']) && !empty($_SERVER['SERVER_NAME'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])); } if (empty($active_domain)) { $active_domain = wp_parse_url(home_url(), PHP_URL_HOST); if (empty($active_domain)) { $active_domain = 'default'; } } // Get value based on multi-domain setting if ($multi_domain) { $domain_context = isset($settings['website_context_per_domain']) && is_array($settings['website_context_per_domain']) ? $settings['website_context_per_domain'] : []; $value = isset($domain_context[$active_domain]) ? $domain_context[$active_domain] : ''; echo '<p class="description" style="margin-bottom: 10px;"><strong>' . esc_html__('Active domain:', 'ai-translate') . '</strong> <code>' . esc_html($active_domain) . '</code></p>'; } else { $value = isset($settings['website_context']) ? $settings['website_context'] : ''; } $placeholder = __('Describe your website, business, or organization. For example:', 'ai-translate') . "\n\n" . __('We are a healthcare technology company specializing in patient management systems. Our services include electronic health records, appointment scheduling, and telemedicine solutions. We serve hospitals, clinics, and healthcare providers across Europe.', 'ai-translate') . "\n\n" . __('Or:', 'ai-translate') . "\n\n" . __('This is a personal blog about sustainable gardening and organic farming techniques. I share tips, tutorials, and experiences from my own garden.', 'ai-translate'); echo '<textarea name="ai_translate_settings[website_context]" id="website_context_field" rows="5" class="large-text" placeholder="' . esc_attr($placeholder) . '">' . esc_textarea($value) . '</textarea>'; if ($multi_domain) { echo '<p class="description">' . esc_html(__('Provide context about your website or business for the current domain to help the AI generate more accurate and contextually appropriate translations.', 'ai-translate')) . '</p>'; } else { echo '<p class="description">' . esc_html(__('Provide context about your website or business to help the AI generate more accurate and contextually appropriate translations. ', 'ai-translate')) . '</p>'; } echo '<button type="button" class="button" id="generate-context-btn" style="margin-top: 10px;">' . esc_html__('Generate Context from Homepage', 'ai-translate') . '</button>'; echo '<span id="generate-context-status" style="margin-left: 10px;"></span>'; }, 'ai-translate', 'ai_translate_advanced' ); // --- End Website Context Field --- } // Register settings (without the shortcodes section) add_action('admin_init', function () { register_setting('ai_translate', 'ai_translate_settings', [ 'sanitize_callback' => [AI_Settings_Sanitizer::class, 'sanitize'], ]); register_api_settings_section(); register_language_settings_section(); register_cache_settings_section(); register_woocommerce_settings_section(); register_advanced_settings_section(); }); /** * Load the plugin text domain for the user's locale (exact locale, then base locale, then language), * so the admin page follows the user language and not the site language. * * @return void */ function load_admin_textdomain() { if (is_user_logged_in()) { $user_locale = get_user_locale(); $plugin_dir = plugin_dir_path(dirname(__FILE__)); $lang_dir = $plugin_dir . 'languages/'; // Build fallback chain: exact -> base -> language code $fallback_locales = array($user_locale); // If locale has underscores, extract base locale (e.g., nl_NL_formal -> nl_NL) if (strpos($user_locale, '_') !== false) { $parts = explode('_', $user_locale); if (count($parts) >= 2) { $base_locale = $parts[0] . '_' . $parts[1]; if (!in_array($base_locale, $fallback_locales, true)) { $fallback_locales[] = $base_locale; } } // Also try language code only (e.g., nl_NL -> nl) if (count($parts) >= 1) { $lang_only = $parts[0]; if (!in_array($lang_only, $fallback_locales, true)) { $fallback_locales[] = $lang_only; } } } // Unload existing textdomain first if (is_textdomain_loaded('ai-translate')) { unload_textdomain('ai-translate'); } // Try each locale in fallback chain foreach ($fallback_locales as $try_locale) { $mofile = $lang_dir . 'ai-translate-' . $try_locale . '.mo'; if (file_exists($mofile)) { load_textdomain('ai-translate', $mofile); break; // Stop at first successful load } } } } /** * Handle the "clear cache for one language" form and build the notice for the result. * * @return string HTML of the notice, empty when the form was not posted. */ function handle_clear_language_cache_form() { $cache_language_message = ''; if ( isset($_POST['clear_cache_language']) && check_admin_referer('clear_cache_language_action', 'clear_cache_language_nonce') && isset($_POST['cache_language']) && class_exists('AI_Translate_Core') ) { $lang_code = sanitize_text_field(wp_unslash($_POST['cache_language'])); $core = AI_Translate_Core::get_instance(); // Get the number of files before deletion $cache_stats_before = $core->get_cache_statistics(); $before_count = isset($cache_stats_before['languages'][$lang_code]) ? $cache_stats_before['languages'][$lang_code] : 0; // Clear cache files (now returns an array with more information) $result = $core->clear_cache_for_language($lang_code); // Get the number of files after deletion $cache_stats_after = $core->get_cache_statistics(); $after_count = isset($cache_stats_after['languages'][$lang_code]) ? $cache_stats_after['languages'][$lang_code] : 0; // Get the language code for display (nicer than the code) $languages = $core->get_available_languages(); $lang_name = isset($languages[$lang_code]) ? $languages[$lang_code] : $lang_code; // Ensure a clear message if ($result['success']) { if ($result['count'] > 0) { $notice_class = isset($result['warning']) ? 'notice-warning' : 'notice-success'; $cache_language_message = '<div class="notice ' . esc_attr($notice_class) . '" id="cache-cleared-message"> <p>' . sprintf( /* translators: %s: string value */ __('Cache for language %s cleared.', 'ai-translate'), '<strong>' . esc_html($lang_name) . ' (' . esc_html($lang_code) . ')</strong>' ) . ' <br>' . sprintf(/* translators: %d: number value */ __('Files removed: %d', 'ai-translate'), intval($result['count'])) . ' <br>' . sprintf(/* translators: %d: number value */ __('Remaining files: %d', 'ai-translate'), intval($after_count)) . '</p>'; if (isset($result['warning'])) { $cache_language_message .= '<p class="error-message">' . esc_html__('Note:', 'ai-translate') . ' ' . esc_html($result['warning']) . '</p>'; } $cache_language_message .= '</div>'; } else { $cache_language_message = '<div class="notice notice-info" id="cache-cleared-message"> <p>' . sprintf( /* translators: %s: string value */ __('No cache files found for language %s.', 'ai-translate'), '<strong>' . esc_html($lang_name) . ' (' . esc_html($lang_code) . ')</strong>' ) . '</p> </div>'; } } else { $error_message = isset($result['error']) ? $result['error'] : __('Unknown error', 'ai-translate'); $cache_language_message = '<div class="notice notice-error" id="cache-cleared-message"> <p>' . sprintf( /* translators: %1$s: string value, %2$s: string value */ __('Error clearing cache for language %1$s: %2$s', 'ai-translate'), '<strong>' . esc_html($lang_name) . ' (' . esc_html($lang_code) . ')</strong>', esc_html($error_message) ) . '</p> </div>'; } } return $cache_language_message; } /** * Section: clear all language caches (handles its own form post). * * @return void */ function render_clear_all_caches_section() { ?> <!-- Clear all caches --> <h3><?php echo esc_html__('Clear all language caches', 'ai-translate'); ?></h3> <p><?php echo esc_html__('Clear all language caches.', 'ai-translate'); ?> <strong><?php echo esc_html__('Menu and slug cache are preserved to maintain URL stability.', 'ai-translate'); ?></strong></p> <?php if (isset($_POST['clear_cache']) && check_admin_referer('clear_cache_action', 'clear_cache_nonce')) { if (!class_exists('AI_Translate_Core')) { require_once __DIR__ . '/class-ai-translate-core.php'; } $core = AI_Translate_Core::get_instance(); // Clear disk-based language caches $core->clear_language_disk_caches_only(); // Also clear segment translation transients global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_ai_tr_seg_%' OR option_name LIKE '_transient_timeout_ai_tr_seg_%'"); \AITranslate\AI_Segment_Store::delete_all(); echo '<div class="notice notice-success"><p>' . esc_html__('All language caches and translation transients cleared. Menu and slug cache preserved.', 'ai-translate') . '</p></div>'; } ?> <form method="post"> <?php wp_nonce_field('clear_cache_action', 'clear_cache_nonce'); ?> <?php submit_button(__('Clear all language caches', 'ai-translate'), 'delete', 'clear_cache', false); ?> </form> <?php } /** * Section: clear the menu cache (handles its own form post). * * @return void */ function render_clear_menu_cache_section() { ?> <!-- Clear menu cache (including menu translation tables) --> <h3><?php echo esc_html__('Clear menu cache', 'ai-translate'); ?></h3> <p><?php echo esc_html__('Clear menu caches including all menu-item translations. This will force fresh translations for all menu items. Languages and Slug map are not affected.', 'ai-translate'); ?></p> <?php if (isset($_POST['clear_menu_cache']) && check_admin_referer('clear_menu_cache_action', 'clear_menu_cache_nonce')) { if (!current_user_can('manage_options')) { echo '<div class="notice notice-error"><p>' . esc_html__('Insufficient permissions.', 'ai-translate') . '</p></div>'; } else { if (!class_exists('AI_Translate_Core')) { require_once __DIR__ . '/class-ai-translate-core.php'; } $core = AI_Translate_Core::get_instance(); $res = $core->clear_menu_cache(); $tables = isset($res['tables_cleared']) && is_array($res['tables_cleared']) ? $res['tables_cleared'] : []; $transients = isset($res['transients_cleared']) ? (int) $res['transients_cleared'] : 0; $msg = __('Menu cache cleared', 'ai-translate'); if ($transients > 0) { $msg .= ' (' . sprintf(/* translators: %d: number of removed menu-item translations */ _n('%d menu-item translation removed', '%d menu-item translations removed', $transients, 'ai-translate'), $transients) . ')'; } if (!empty($tables)) { $safe = array_map('esc_html', $tables); $msg .= ' ' . sprintf(/* translators: %s: string value */ __('and tables truncated: %s', 'ai-translate'), implode(', ', $safe)); } echo '<div class="notice notice-success"><p>' . esc_html($msg) . '.</p></div>'; } } ?> <form method="post"> <?php wp_nonce_field('clear_menu_cache_action', 'clear_menu_cache_nonce'); ?> <?php submit_button(__('Clear menu cache', 'ai-translate'), 'delete', 'clear_menu_cache', false); ?> </form> <?php } /** * Section: clear the slug cache (handles its own form post). * * @return void */ function render_clear_slug_cache_section() { ?> <!-- Clear slug cache (truncate slug table) --> <h3><?php echo esc_html__('Clear slug cache', 'ai-translate'); ?></h3> <p><?php echo esc_html__('Clear the slug table used for translated URLs. Language and menu caches are not affected.', 'ai-translate'); ?></p> <?php if (isset($_POST['clear_slug_cache']) && check_admin_referer('clear_slug_cache_action', 'clear_slug_cache_nonce')) { if (!current_user_can('manage_options')) { echo '<div class="notice notice-error"><p>' . esc_html__('Insufficient permissions.', 'ai-translate') . '</p></div>'; } else { if (!class_exists('AI_Translate_Core')) { require_once __DIR__ . '/class-ai-translate-core.php'; } $core = AI_Translate_Core::get_instance(); $res = $core->clear_slug_map(); if (!empty($res['success'])) { $num = isset($res['cleared']) ? (int) $res['cleared'] : 0; echo '<div class="notice notice-success"><p>' . esc_html(sprintf(/* translators: %d: number value */ __('Slug cache cleared. Rows removed: %d.', 'ai-translate'), intval($num))) . '</p></div>'; } else { $msg = isset($res['message']) ? $res['message'] : __('Unknown error', 'ai-translate'); echo '<div class="notice notice-error"><p>' . sprintf(/* translators: %s: string value */ esc_html__('Failed to clear slug cache: %s.', 'ai-translate'), esc_html($msg)) . '</p></div>'; } } } ?> <form method="post"> <?php wp_nonce_field('clear_slug_cache_action', 'clear_slug_cache_nonce'); ?> <?php submit_button(__('Clear slug cache', 'ai-translate'), 'delete', 'clear_slug_cache', false); ?> </form> <?php } /** * Section: cache per language, with the result of a form post and the cache statistics. * * @return void */ function render_language_cache_section($active_tab, $cache_language_message) { ?> <h3><?php echo esc_html__('Clear cache per language', 'ai-translate'); ?></h3> <?php if (!empty($cache_language_message)) { echo wp_kses_post($cache_language_message); } $core = \AITranslate\AI_Translate_Core::get_instance(); $languages = $core->get_available_languages(); // Sort languages alphabetically by name (ascending) asort($languages); // Cache statistics are expensive (RecursiveIterator over all files) - only run when Cache tab is active // Avoids HTTP 500 / "upstream prematurely closed connection" on sites with large caches if ($active_tab === 'cache') { $cache_stats = $core->get_cache_statistics(); } else { $cache_stats = [ 'total_files' => 0, 'total_size' => 0, 'expired_files' => 0, 'last_modified' => 0, 'languages' => [], 'languages_details' => [], ]; } $language_counts = $cache_stats['languages'] ?? []; ?> <div class="cache-stats-section"> <h4><?php echo esc_html__('Cache overview per language', 'ai-translate'); ?></h4> <?php // Show cache directory info $settings = AI_Translate_Core::settings(); $multi_domain = isset($settings['multi_domain_caching']) ? (bool) $settings['multi_domain_caching'] : false; if ($multi_domain) { // Use the active domain from HTTP_HOST (the domain the user is actually visiting) // This ensures each domain gets its own cache directory $active_domain = ''; if (isset($_SERVER['HTTP_HOST']) && !empty($_SERVER['HTTP_HOST'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); // Remove port if present (e.g., "example.com:8080" -> "example.com") if (strpos($active_domain, ':') !== false) { $active_domain = strtok($active_domain, ':'); } } // Fallback to SERVER_NAME if HTTP_HOST is not available if (empty($active_domain) && isset($_SERVER['SERVER_NAME']) && !empty($_SERVER['SERVER_NAME'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])); } // Final fallback to home_url() host (should rarely be needed) if (empty($active_domain)) { $active_domain = wp_parse_url(home_url(), PHP_URL_HOST); if (empty($active_domain)) { $active_domain = 'default'; } } $sanitized = sanitize_file_name($active_domain); if (empty($sanitized)) { $sanitized = 'default'; } $uploads = wp_upload_dir(); $cache_dir = trailingslashit($uploads['basedir']) . 'ai-translate/cache/' . $sanitized . '/'; echo '<p class="description" style="margin-bottom: 15px;">'; echo '<strong>' . esc_html__('Cache directory:', 'ai-translate') . '</strong> '; echo '<code>' . esc_html($cache_dir) . '</code>'; echo '</p>'; } ?> <?php // Get detailed statistics $languages_details = isset($cache_stats['languages_details']) ? $cache_stats['languages_details'] : []; $total_expired = $cache_stats['expired_files'] ?? 0; // Calculate total size in MB $total_size_mb = isset($cache_stats['total_size']) ? number_format($cache_stats['total_size'] / (1024 * 1024), 2) : 0; // Last update timestamp $last_modified = !empty($cache_stats['last_modified']) ? wp_date('d-m-Y H:i:s', $cache_stats['last_modified']) : __('Unknown', 'ai-translate'); ?> <div class="cache-summary" style="margin-bottom: 15px; display: flex; gap: 20px;"> <div class="summary-item"> <strong><?php echo esc_html__('Total number of files:', 'ai-translate'); ?></strong> <span id="total-cache-count"><?php echo intval($cache_stats['total_files'] ?? 0); ?></span> </div> <div class="summary-item"> <strong><?php echo esc_html__('Total size:', 'ai-translate'); ?></strong> <?php echo esc_html($total_size_mb); ?> MB </div> <div class="summary-item"> <strong><?php echo esc_html__('Expired files:', 'ai-translate'); ?></strong> <?php echo intval($total_expired); ?> </div> <div class="summary-item"> <strong><?php echo esc_html__('Last update:', 'ai-translate'); ?></strong> <?php echo esc_html($last_modified); ?> </div> </div> <table id="cache-language-table" class="widefat striped" style="width: 100%;"> <thead> <tr> <th class="sortable" data-sort="language" data-sort-type="text"> <?php echo esc_html__('Language', 'ai-translate'); ?> <span class="sort-indicator"></span> </th> <th class="sortable" data-sort="files" data-sort-type="number"> <?php echo esc_html__('Cache files', 'ai-translate'); ?> <span class="sort-indicator"></span> </th> <th class="sortable" data-sort="size" data-sort-type="number"> <?php echo esc_html__('Size', 'ai-translate'); ?> <span class="sort-indicator"></span> </th> <th class="sortable" data-sort="expired" data-sort-type="number"> <?php echo esc_html__('Expired', 'ai-translate'); ?> <span class="sort-indicator"></span> </th> <th class="sortable" data-sort="lastupdate" data-sort-type="date"> <?php echo esc_html__('Last update', 'ai-translate'); ?> <span class="sort-indicator"></span> </th> <th><?php echo esc_html__('Action', 'ai-translate'); ?></th> </tr> </thead> <tbody> <?php $total_files = 0; foreach ($languages as $code => $name): $count = isset($language_counts[$code]) ? $language_counts[$code] : 0; $total_files += $count; // Haal gedetailleerde info op $details = $languages_details[$code] ?? []; $size_mb = isset($details['size']) ? number_format($details['size'] / (1024 * 1024), 2) : '0.00'; $expired = isset($details['expired_count']) ? $details['expired_count'] : 0; // A cleared language reports last_modified 0 (shown as 01-01-1970); treat that as no data. $last_mod = !empty($details['last_modified']) ? wp_date('d-m-Y H:i:s', $details['last_modified']) : __('N/A', 'ai-translate'); ?> <tr id="cache-row-<?php echo esc_attr($code); ?>" class="cache-language-row <?php echo ($count > 0) ? 'has-cache' : 'no-cache'; ?>" data-language="<?php echo esc_attr(strtolower($name)); ?>" data-files="<?php echo intval($count); ?>" data-size="<?php echo esc_attr($details['size'] ?? 0); ?>" data-expired="<?php echo intval($expired); ?>" data-lastupdate="<?php echo esc_attr($details['last_modified'] ?? 0); ?>"> <td> <button type="button" class="button-link ai-cache-toggle-urls ai-cache-lang-trigger" data-lang="<?php echo esc_attr($code); ?>" aria-expanded="false"> <?php echo esc_html($name); ?> (<?php echo esc_html($code); ?>) </button> </td> <td><span class="cache-count" data-lang="<?php echo esc_attr($code); ?>"><?php echo intval($count); ?></span> <?php echo intval($count) === 1 ? esc_html__('file', 'ai-translate') : esc_html__('files', 'ai-translate'); ?></td> <td><?php echo esc_html($size_mb); ?> MB</td> <td><?php echo intval($expired); ?></td> <td><?php echo esc_html($last_mod); ?></td> <td> <?php if ($count > 0): ?> <button type="button" class="button button-small quick-clear-cache" data-lang="<?php echo esc_attr($code); ?>"> <?php echo esc_html__('Clear Cache', 'ai-translate'); ?> </button> <?php else: ?> <span class="dashicons dashicons-yes-alt" style="color: #46b450;" title="<?php echo esc_attr__('No cache files', 'ai-translate'); ?>"></span> <?php endif; ?> </td> </tr> <tr id="cache-details-<?php echo esc_attr($code); ?>" class="cache-language-details" style="display:none;"> <td colspan="6"> <div class="cache-language-details__content" aria-live="polite"></div> </td> </tr> <?php endforeach; ?> </tbody> <tfoot> <tr> <th><?php echo esc_html__('Total', 'ai-translate'); ?></th> <th><span id="table-total-count"><?php echo intval($total_files); ?></span> <?php echo intval($total_files) === 1 ? esc_html__('file', 'ai-translate') : esc_html__('files', 'ai-translate'); ?></th> <th><?php echo esc_html($total_size_mb); ?> MB</th> <th><?php echo intval($total_expired); ?></th> <th><?php echo esc_html($last_modified); ?></th> <th> </th> </tr> </tfoot> </table> </div> <?php } /** * Section: translated pages overview with per-page cache actions. * * @return void */ function render_pages_overview_section($active_tab) { ?> <h3><?php echo esc_html__('Translated Pages Overview', 'ai-translate'); ?></h3> <p><?php echo esc_html__('Manage cache per page or blog. Delete cache to retranslate, or warm cache to proactively generate translations.', 'ai-translate'); ?></p> <?php // Heavy AI_Cache_Meta operations only when Cache tab is active (avoids HTTP 500 on large caches) if ($active_tab === 'cache') { // Handle manual rescan request if (isset($_POST['rescan_cache_meta']) && check_admin_referer('rescan_cache_meta_action', 'rescan_cache_meta_nonce')) { AI_Cache_Meta::sync_from_filesystem(); $count = AI_Cache_Meta::populate_existing_cache(true); if ($count > 0) { // $count counts insert() calls from two overlapping passes (filesystem scan and // post-based pass), so one file is counted twice. A forced rescan rewrites every // record, so the table's row count is the number of records added or updated. $count = max(1, AI_Cache_Meta::get_total_cache_meta_count()); echo '<div class="notice notice-success is-dismissible"><p>'; echo esc_html(safe_sprintf(/* translators: %d: number value */ __('Cache metadata rescanned: %d cache records added or updated.', 'ai-translate'), $count)); echo '</p></div>'; } else { echo '<div class="notice notice-warning is-dismissible"><p>'; echo esc_html__('No cache records added. There are currently no cache files available.', 'ai-translate'); echo '</p></div>'; } } // One-time population of existing cache metadata (if not done yet) $populated = get_option('ai_translate_cache_meta_populated', false); if ($populated === false) { $count = AI_Cache_Meta::populate_existing_cache(); update_option('ai_translate_cache_meta_populated', true); if ($count > 0) { echo '<div class="notice notice-info is-dismissible"><p>'; echo esc_html(sprintf(/* translators: %d: number value */ __('Cache metadata initialized: %d existing cache records added.', 'ai-translate'), $count)); echo '</p></div>'; } } // Clean up orphaned cache metadata records (cache files that no longer exist) // Only run cleanup once per hour to avoid performance impact $last_cleanup = get_transient('ai_translate_cache_meta_last_cleanup'); if ($last_cleanup === false) { AI_Cache_Meta::sync_from_filesystem(); set_transient('ai_translate_cache_meta_last_cleanup', time(), HOUR_IN_SECONDS); } // Get posts with cache stats // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only paginator for admin listing, value cast to int $paged = isset($_GET['paged']) ? max(1, intval(wp_unslash($_GET['paged']))) : 1; $per_page = 50; $offset = ($paged - 1) * $per_page; $posts = AI_Cache_Meta::get_posts_with_cache_stats($offset, $per_page); $total_posts = AI_Cache_Meta::get_total_posts_count(); $total_pages = ceil($total_posts / $per_page); // Check if table is empty but cache files might exist $cache_meta_count = AI_Cache_Meta::get_total_cache_meta_count(); } else { $posts = []; $total_posts = 0; $total_pages = 0; $paged = 1; $cache_meta_count = 0; } if ($active_tab === 'cache' && $cache_meta_count === 0 && !empty($posts)) { // Check if cache directory exists (indicates cache files might exist) $uploads = wp_upload_dir(); $cache_dir = trailingslashit($uploads['basedir']) . 'ai-translate/cache/'; if (is_dir($cache_dir)) { // Cache directory exists, suggest rescan echo '<div class="notice notice-warning is-dismissible"><p>'; echo esc_html__('Cache directory found but no metadata. Click "Scan Cache Files" to generate metadata.', 'ai-translate'); echo '</p></div>'; } } ?> <?php if (!empty($posts)): ?> <form method="post" style="margin-bottom: 15px;"> <?php wp_nonce_field('rescan_cache_meta_action', 'rescan_cache_meta_nonce'); ?> <button type="submit" name="rescan_cache_meta" class="button button-secondary"> <span class="dashicons dashicons-update" style="vertical-align: middle;"></span> <?php echo esc_html__('Scan cache files', 'ai-translate'); ?> </button> <p class="description" style="margin-top: 5px;"> <?php echo esc_html__('Scan the filesystem for cache files and add metadata to the database.', 'ai-translate'); ?> </p> </form> <?php endif; ?> <table class="wp-list-table widefat fixed striped" style="margin-top: 15px;"> <thead> <tr> <th style="width: 8%;"><?php echo esc_html__('Type', 'ai-translate'); ?></th> <th style="width: 30%;"><?php echo esc_html__('Title', 'ai-translate'); ?></th> <th style="width: 25%;"><?php echo esc_html__('URL', 'ai-translate'); ?></th> <th style="width: 12%;"><?php echo esc_html__('Status', 'ai-translate'); ?></th> <th style="width: 25%;"><?php echo esc_html__('Actions', 'ai-translate'); ?></th> </tr> </thead> <tbody> <?php if (empty($posts)): ?> <tr> <td colspan="5" style="text-align: center; padding: 20px;"> <?php echo esc_html__('No published pages or posts found.', 'ai-translate'); ?> </td> </tr> <?php else: ?> <?php foreach ($posts as $post): ?> <tr> <td><?php echo esc_html(ucfirst($post->post_type)); ?></td> <td> <strong><?php echo esc_html($post->post_title); ?></strong> </td> <td> <a href="<?php echo esc_url($post->url); ?>" target="_blank" rel="noopener"> <?php $url_display = wp_parse_url($post->url, PHP_URL_PATH); if (strlen($url_display) > 40) { $url_display = substr($url_display, 0, 37) . '...'; } echo esc_html($url_display); ?> </a> </td> <td> <?php // Compare the counts, not $post->percentage: that value is not a strict int, // so === 100 / === 0 never matched and every row showed the partial colour. $cached_count = (int) $post->cached_languages; $total_count = (int) $post->total_languages; if ($total_count > 0 && $cached_count >= $total_count) { $status_class = 'ai-translate-status-100'; } elseif ($cached_count === 0) { $status_class = 'ai-translate-status-0'; } else { $status_class = 'ai-translate-status-partial'; } ?> <span class="ai-translate-status <?php echo esc_attr($status_class); ?>" data-cached="<?php echo esc_attr((string) $post->cached_languages); ?>" data-total="<?php echo esc_attr((string) $post->total_languages); ?>"> <?php echo esc_html(sprintf(/* translators: %1$s: cached language count, %2$s: total languages */ __('%1$s of %2$s', 'ai-translate'), $post->cached_languages, $post->total_languages)); ?> </span> </td> <td> <button type="button" class="button button-secondary ai-translate-delete-cache" data-post-id="<?php echo esc_attr($post->ID); ?>" data-nonce="<?php echo esc_attr(wp_create_nonce('ai_translate_delete_cache')); ?>" title="<?php echo esc_attr__('Delete cache for this page in all languages', 'ai-translate'); ?>"> <span class="dashicons dashicons-trash" style="vertical-align: middle;"></span> <?php echo esc_html__('Delete Cache', 'ai-translate'); ?> </button> <button type="button" class="button button-primary ai-translate-warm-cache" data-post-id="<?php echo esc_attr($post->ID); ?>" data-nonce="<?php echo esc_attr(wp_create_nonce('ai_translate_warm_cache')); ?>" title="<?php echo esc_attr__('Generate translations for all enabled languages', 'ai-translate'); ?>"> <span class="dashicons dashicons-update" style="vertical-align: middle;"></span> <?php echo esc_html__('Warm Cache', 'ai-translate'); ?> </button> </td> </tr> <?php endforeach; ?> <?php endif; ?> </tbody> </table> <?php if ($total_pages > 1): ?> <div class="tablenav" style="margin-top: 15px;"> <div class="tablenav-pages"> <?php echo wp_kses_post(paginate_links(array( 'base' => add_query_arg('paged', '%#%'), 'format' => '', 'prev_text' => __('« Previous', 'ai-translate'), 'next_text' => __('Next »', 'ai-translate'), 'total' => $total_pages, 'current' => $paged ))); ?> </div> </div> <?php endif; ?> <?php } /** * Render the AI Translate admin page (tabs: General, Cache). * * Contains WordPress settings API sections/fields and cache management UI. * Uses nonces and capability checks for all mutating actions. */ function render_admin_page() { load_admin_textdomain(); $cache_language_message = handle_clear_language_cache_form(); // Determine active tab $active_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'general'; ?> <div class="wrap"> <h1><?php echo esc_html(get_admin_page_title()); ?></h1> <?php settings_errors(); // Display admin notices, including those from add_settings_error ?> <!-- Tab navigation --> <h2 class="nav-tab-wrapper"> <a href="?page=ai-translate&tab=general" class="nav-tab <?php echo esc_attr($active_tab === 'general' ? 'nav-tab-active' : ''); ?>"><?php echo esc_html__('General', 'ai-translate'); ?></a> <a href="?page=ai-translate&tab=cache" class="nav-tab <?php echo esc_attr($active_tab === 'cache' ? 'nav-tab-active' : ''); ?>"><?php echo esc_html__('Cache', 'ai-translate'); ?></a> </h2> <div id="tab-content"> <div id="general" class="tab-panel" style="<?php echo esc_attr($active_tab === 'general' || $active_tab === 'logs' ? 'display:block;' : 'display:none;'); ?>"> <?php // Logs tab now refers to general ?> <form method="post" action="options.php"> <?php settings_fields('ai_translate'); do_settings_sections('ai-translate'); ?> <!-- Hidden field to indicate this is a full form submit (not AJAX) --> <input type="hidden" name="ai_translate_settings[_form_submit]" value="1"> <?php submit_button(); ?> </form> </div> <div id="cache" class="tab-panel" style="<?php echo esc_attr($active_tab === 'cache' ? 'display:block;' : 'display:none;'); ?>"> <h2><?php echo esc_html__('Cache Management', 'ai-translate'); ?></h2> <?php wp_nonce_field('clear_cache_language_action', 'clear_cache_language_nonce'); // Nonce for AJAX clear language cache ?> <?php render_clear_all_caches_section(); ?> <hr style="margin: 20px 0;"> <?php render_clear_menu_cache_section(); ?> <hr style="margin: 20px 0;"> <?php render_clear_slug_cache_section(); ?> <hr style="margin: 20px 0;"> <?php render_language_cache_section($active_tab, $cache_language_message); ?> <hr style="margin: 30px 0;"> <?php render_pages_overview_section($active_tab); ?> </div> </div> </div> <?php } // --- AJAX handler voor ophalen van API key per provider (nooit via page source) --- add_action('wp_ajax_ai_translate_get_api_key', function () { if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'ai-translate')]); } $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (!wp_verify_nonce($nonce, 'ai_translate_get_api_key_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'ai-translate')]); } $provider = isset($_POST['provider']) ? sanitize_key(wp_unslash($_POST['provider'])) : ''; if ($provider === '') { wp_send_json_error(['message' => __('Provider missing.', 'ai-translate')]); } $settings = AI_Translate_Core::settings(); $key_url = isset($_POST['custom_api_url_value']) ? esc_url_raw(trim(sanitize_text_field(wp_unslash($_POST['custom_api_url_value'])))) : null; // Only say whether a key exists: the page shows the mask and the server uses the stored key. $has_key = AI_Translate_Core::api_key_for($settings, $provider, $key_url) !== ''; wp_send_json_success(['api_key' => $has_key ? KEY_MASK : '']); }); // --- AJAX handler voor dynamisch ophalen van modellen --- add_action('wp_ajax_ai_translate_get_models', function () { check_ajax_referer('ai_translate_get_models_nonce', 'nonce'); // Add nonce verification if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'ai-translate')]); } // Haal actuele waarden uit POST als aanwezig $provider_key = isset($_POST['api_provider']) ? sanitize_text_field(wp_unslash($_POST['api_provider'])) : null; $api_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : ''; if (!$provider_key) { // Als provider niet in POST zit, haal uit settings (zonder default) $settings = AI_Translate_Core::settings(); $provider_key = $settings['api_provider'] ?? ''; if (empty($api_key) && $provider_key !== '') { // Als API key ook niet in POST zat, haal uit settings voor de gekozen provider $api_keys = $settings['api_keys'] ?? []; $api_key = $api_keys[$provider_key] ?? ''; } } if (empty($provider_key)) { wp_send_json_error(['message' => __('API provider is missing.', 'ai-translate')]); } $api_url = AI_Translate_Core::get_api_url_for_provider($provider_key); // Als de provider 'custom' is, gebruik dan de custom_api_url uit POST if ($provider_key === 'custom') { if (isset($_POST['custom_api_url_value'])) { $api_url = esc_url_raw(trim(sanitize_text_field(wp_unslash($_POST['custom_api_url_value'])))); } else { $settings = isset($settings) && is_array($settings) ? $settings : AI_Translate_Core::settings(); if (empty($api_url)) { $api_url = esc_url_raw(trim((string) ($settings['custom_api_url'] ?? ''))); } } } $api_key = resolve_posted_api_key($provider_key, $api_key, $provider_key === 'custom' ? $api_url : null); if (empty($api_url) || empty($api_key)) { wp_send_json_error(['message' => __('API Provider, API Key or Custom API URL is missing or invalid.', 'ai-translate')]); return; } $endpoint = rtrim($api_url, '/') . '/models'; $headers = [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ]; // OpenRouter requires Referer header if ($provider_key === 'openrouter' || ($provider_key === 'custom' && strpos($api_url, 'openrouter.ai') !== false)) { $headers['HTTP-Referer'] = 'https://github.com/gerard-kanters/ai-translate'; $headers['X-Title'] = 'AI Translate'; } $is_anthropic = $provider_key === 'custom' && AI_Translate_Core::is_anthropic_url($api_url); if ($is_anthropic) { $headers['x-api-key'] = $api_key; $headers['anthropic-version'] = '2023-06-01'; } $response = wp_remote_get($endpoint, [ 'headers' => $headers, 'timeout' => 20, 'sslverify' => true, ]); // Anthropic does not always return a model list on its OpenAI-compatible endpoint: offer known models. $anthropic_fallback = static function () { wp_send_json_success([ 'models' => AI_Translate_Core::ANTHROPIC_FALLBACK_MODELS, 'preferred' => AI_Translate_Core::ANTHROPIC_FALLBACK_MODELS[0], 'always_thinking' => (object) [], ]); }; if (is_wp_error($response)) { if ($is_anthropic) { $anthropic_fallback(); } wp_send_json_error(['message' => $response->get_error_message()]); } $code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($is_anthropic && $code !== 200 && $code !== 401 && $code !== 403) { $anthropic_fallback(); } if ($code !== 200) { // Check if error is due to invalid API key (401, 403) $isInvalidKey = ($code === 401 || $code === 403); if ($isInvalidKey) { $errorMessage = __('Invalid key', 'ai-translate') . ': ' . $body; wp_send_json_error(['message' => $errorMessage]); } elseif ($provider_key === 'custom') { // Custom providers may not support the /models endpoint. // This does not mean the API itself is broken — translation via /chat/completions may still work. wp_send_json_error([ 'message' => __('This provider does not support automatic model listing. Enter the model name manually.', 'ai-translate'), 'no_models_endpoint' => true, ]); } else { wp_send_json_error(['message' => __('API error:', 'ai-translate') . ' ' . $body]); } } $data = json_decode($body, true); if (!isset($data['data']) || !is_array($data['data'])) { wp_send_json_error(['message' => __('Invalid response from API', 'ai-translate')]); } AI_Translate_Core::store_live_models($provider_key, $data['data']); $models = AI_Translate_Core::filter_admin_models($data['data'], $provider_key); if ($is_anthropic && $models === []) { $anthropic_fallback(); } // Place preferred translation models first per provider. // Existing saved selections are kept by the UI; this only controls default-first ordering. // The preferred models come from includes/default-models.json (best first); a dated variant // such as claude-haiku-5-5-20260101 counts as the same model. $preferred_model_patterns = []; foreach (AI_Translate_Core::default_models_for($provider_key, (string) $api_url) as $preferred_id) { $preferred_model_patterns[] = '/^' . preg_quote($preferred_id, '/') . '(?:-\d+)?$/i'; } $preferred_model_found = null; if ($is_anthropic) { array_push($preferred_model_patterns, '/^claude-haiku/i', '/^claude-sonnet/i', '/^claude/i'); } if ($preferred_model_patterns !== []) { $preferred_index = false; foreach ($preferred_model_patterns as $pattern) { foreach ($models as $index => $model) { if (preg_match($pattern, $model)) { $preferred_index = $index; break 2; } } } if ($preferred_index !== false) { $preferred_model = $models[$preferred_index]; unset($models[$preferred_index]); array_unshift($models, $preferred_model); $models = array_values($models); $preferred_model_found = $preferred_model; } } // Models measured as always thinking (thinking on, and no parameter lowered it): the UI warns about these. $always_thinking = []; $wanted = array_flip($models); foreach ($data['data'] as $row) { if (!is_array($row) || !isset($row['id'], $wanted[$row['id']])) { continue; } // Only a measurement says a model always thinks. The provider's tags do not: of the DeepInfra // models tagged as thinking without an effort switch, none that was measured turned out to // think by default, and several accept a parameter that turns thinking off. if (AI_Translate_Core::measured_thinking_state($provider_key, (string) $row['id']) !== 'always') { continue; } $price = null; $always_thinking[(string) $row['id']] = $price !== null ? round($price, 2) : null; } // Prices per million tokens, only for models where the provider's list says what they cost. $prices = []; foreach ($data['data'] as $row) { if (!is_array($row) || !isset($row['id'], $wanted[$row['id']])) { continue; } $in = null; $out = null; if (isset($row['metadata']['pricing']['input_tokens'], $row['metadata']['pricing']['output_tokens']) && is_numeric($row['metadata']['pricing']['input_tokens']) && is_numeric($row['metadata']['pricing']['output_tokens'])) { $in = (float) $row['metadata']['pricing']['input_tokens']; $out = (float) $row['metadata']['pricing']['output_tokens']; } elseif (isset($row['pricing']['prompt'], $row['pricing']['completion']) && is_numeric($row['pricing']['prompt']) && is_numeric($row['pricing']['completion']) && (float) $row['pricing']['prompt'] >= 0 && (float) $row['pricing']['completion'] >= 0) { $in = (float) $row['pricing']['prompt'] * 1000000; $out = (float) $row['pricing']['completion'] * 1000000; } if ($in !== null && $out !== null) { $prices[(string) $row['id']] = ['in' => round($in, 3), 'out' => round($out, 3)]; } } wp_send_json_success([ 'prices' => (object) $prices, 'models' => $models, 'preferred' => $preferred_model_found, 'always_thinking' => (object) $always_thinking, ]); }); // --- AJAX handler voor dynamisch ophalen van custom URL --- add_action('wp_ajax_ai_translate_get_custom_url', function () { check_ajax_referer('ai_translate_get_custom_url_nonce', 'nonce'); // Nonce verification if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'ai-translate')]); } $settings = AI_Translate_Core::settings(); wp_send_json_success(['settings' => $settings]); }); // --- AJAX handler voor API validatie --- // The page asks for the background measurement of a thinking model until it is there. add_action('wp_ajax_ai_translate_thinking_state', function () { check_ajax_referer('ai_translate_validate_api_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'ai-translate')]); } $provider = isset($_POST['api_provider']) ? sanitize_text_field(wp_unslash($_POST['api_provider'])) : ''; $model = isset($_POST['model']) ? trim(sanitize_text_field(wp_unslash($_POST['model']))) : ''; $state = AI_Translate_Core::measured_thinking_state($provider, $model); wp_send_json_success([ 'pending' => AI_Translate_Core::reasoning_probe_pending($provider, $model), 'always_thinking' => $state === null ? null : ($state === 'always'), ]); }); add_action('wp_ajax_ai_translate_validate_api', function () { check_ajax_referer('ai_translate_validate_api_nonce', 'nonce'); // Add nonce verification if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'ai-translate')]); } // Haal actuele waarden uit POST als aanwezig $provider_key = isset($_POST['api_provider']) ? sanitize_text_field(wp_unslash($_POST['api_provider'])) : null; $api_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : ''; $model = isset($_POST['model']) ? trim(sanitize_text_field(wp_unslash($_POST['model']))) : ''; $custom_api_url_value = isset($_POST['custom_api_url_value']) ? esc_url_raw(trim(sanitize_text_field(wp_unslash($_POST['custom_api_url_value'])))) : ''; // Als provider niet in POST zit, haal uit settings if (!$provider_key) { $settings = AI_Translate_Core::settings(); $provider_key = $settings['api_provider'] ?? ''; if ($provider_key !== '') { if (empty($api_key)) { $api_key = AI_Translate_Core::api_key_for($settings, (string) $provider_key, $custom_api_url_value !== '' ? $custom_api_url_value : null); } if (empty($model)) { $model = $settings['selected_model'] ?? ''; } if (empty($custom_api_url_value)) { $custom_api_url_value = $settings['custom_api_url'] ?? ''; } } } if (empty($provider_key)) { wp_send_json_error(['message' => __('API provider is missing.', 'ai-translate')]); } $api_key = resolve_posted_api_key($provider_key, $api_key, $provider_key === 'custom' ? $custom_api_url_value : null); if (empty($api_key)) { wp_send_json_error(['message' => __('API key is missing.', 'ai-translate')]); } if ($provider_key === 'custom' && empty($custom_api_url_value)) { wp_send_json_error(['message' => __('Custom API URL is missing for the custom provider.', 'ai-translate')]); } $core = AI_Translate_Core::get_instance(); try { // Roep de validate_api_settings functie aan in de core class, inclusief model test $validation_result = $core->validate_api_settings($provider_key, $api_key, $custom_api_url_value, $model); // Als validatie succesvol is, sla settings op if (isset($_POST['save_settings']) && $_POST['save_settings'] === '1') { // Zorg dat we de meest recente settings uit de database lezen wp_cache_delete('ai_translate_settings', 'options'); wp_cache_delete('alloptions', 'options'); $current_settings = AI_Translate_Core::settings(true); if (!is_array($current_settings)) { $current_settings = []; } // Behoud alle bestaande instellingen - alleen API-gerelateerde velden worden geüpdatet // Dit voorkomt dat andere instellingen (zoals checkboxes) worden gewijzigd $updated_settings = $current_settings; // Zorg ervoor dat 'api_keys' array bestaat if (!isset($updated_settings['api_keys']) || !is_array($updated_settings['api_keys'])) { $updated_settings['api_keys'] = []; } // Update alleen de API-gerelateerde velden AI_Translate_Core::store_api_key($updated_settings, (string) $provider_key, (string) $api_key, (string) $custom_api_url_value); $updated_settings['api_provider'] = $provider_key; $updated_settings['selected_model'] = $model; // Zorg ervoor dat het model ook per provider wordt weggeschreven if (!isset($updated_settings['models']) || !is_array($updated_settings['models'])) { $updated_settings['models'] = []; } if (is_string($model) && $model !== '') { $updated_settings['models'][$provider_key] = $model; } if ( $model !== '' && isset($validation_result['temperature_supported']) && is_bool($validation_result['temperature_supported']) ) { if (!isset($updated_settings['temperature_supported']) || !is_array($updated_settings['temperature_supported'])) { $updated_settings['temperature_supported'] = []; } if (!isset($updated_settings['temperature_supported'][$provider_key]) || !is_array($updated_settings['temperature_supported'][$provider_key])) { $updated_settings['temperature_supported'][$provider_key] = []; } $updated_settings['temperature_supported'][$provider_key][$model] = $validation_result['temperature_supported']; } // Reasoning parameters measured by the activation probe (null: the model needs none) if ($model !== '' && array_key_exists('reasoning', $validation_result)) { if (!isset($updated_settings['reasoning_params']) || !is_array($updated_settings['reasoning_params'])) { $updated_settings['reasoning_params'] = []; } if (!isset($updated_settings['reasoning_params'][$provider_key]) || !is_array($updated_settings['reasoning_params'][$provider_key])) { $updated_settings['reasoning_params'][$provider_key] = []; } if (is_array($validation_result['reasoning'])) { $updated_settings['reasoning_params'][$provider_key][$model] = $validation_result['reasoning']; } else { unset($updated_settings['reasoning_params'][$provider_key][$model]); } } if (isset($_POST['custom_model_value'])) { $updated_settings['custom_model'] = trim(sanitize_text_field(wp_unslash($_POST['custom_model_value']))); } elseif ($model !== 'custom' && isset($updated_settings['custom_model'])) { $updated_settings['custom_model'] = ''; } // Sla custom_api_url op als deze is meegegeven if (!empty($custom_api_url_value)) { $updated_settings['custom_api_url'] = $custom_api_url_value; } // Website context opslaan tijdens validate (per domain bij multi-domain caching) if (isset($_POST['website_context'])) { $website_context = sanitize_textarea_field(wp_unslash($_POST['website_context'])); // Gebruik de waarde uit POST (formulier) in plaats van database, zodat we de meest actuele waarde hebben $multi_domain = false; if (isset($_POST['multi_domain_caching'])) { $multi_domain = $_POST['multi_domain_caching'] === '1'; // Update ook de setting in $updated_settings voor consistentie $updated_settings['multi_domain_caching'] = $multi_domain; } elseif (isset($updated_settings['multi_domain_caching'])) { // Fallback naar database waarde als niet in POST $multi_domain = (bool) $updated_settings['multi_domain_caching']; } if ($multi_domain) { $active_domain = ''; if (isset($_SERVER['HTTP_HOST']) && !empty($_SERVER['HTTP_HOST'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); if (strpos($active_domain, ':') !== false) { $active_domain = strtok($active_domain, ':'); } } if (empty($active_domain) && isset($_SERVER['SERVER_NAME']) && !empty($_SERVER['SERVER_NAME'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])); } if (empty($active_domain)) { $active_domain = wp_parse_url(home_url(), PHP_URL_HOST); if (empty($active_domain)) { $active_domain = 'default'; } } // Kopieer bestaande website_context_per_domain van current_settings als die niet bestaat in updated_settings if (!isset($updated_settings['website_context_per_domain']) || !is_array($updated_settings['website_context_per_domain'])) { $updated_settings['website_context_per_domain'] = isset($current_settings['website_context_per_domain']) && is_array($current_settings['website_context_per_domain']) ? $current_settings['website_context_per_domain'] : []; } $updated_settings['website_context_per_domain'][$active_domain] = $website_context; // BELANGRIJK: Verwijder de oude website_context key zodat de sanitize_callback // de nieuwe per-domain waarde niet overschrijft met de oude single-domain waarde unset($updated_settings['website_context']); } else { $updated_settings['website_context'] = $website_context; // Verwijder per-domain array als we terug naar single-domain gaan unset($updated_settings['website_context_per_domain']); } } // Homepage meta description opslaan tijdens validate (per domain bij multi-domain caching) if (isset($_POST['homepage_meta_description'])) { $homepage_meta_description = sanitize_textarea_field(wp_unslash($_POST['homepage_meta_description'])); // Gebruik de waarde uit POST (formulier) in plaats van database, zodat we de meest actuele waarde hebben $multi_domain = false; if (isset($_POST['multi_domain_caching'])) { $multi_domain = $_POST['multi_domain_caching'] === '1'; } elseif (isset($updated_settings['multi_domain_caching'])) { // Fallback naar database waarde als niet in POST $multi_domain = (bool) $updated_settings['multi_domain_caching']; } if ($multi_domain) { $active_domain = ''; if (isset($_SERVER['HTTP_HOST']) && !empty($_SERVER['HTTP_HOST'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); if (strpos($active_domain, ':') !== false) { $active_domain = strtok($active_domain, ':'); } } if (empty($active_domain) && isset($_SERVER['SERVER_NAME']) && !empty($_SERVER['SERVER_NAME'])) { $active_domain = sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])); } if (empty($active_domain)) { $active_domain = wp_parse_url(home_url(), PHP_URL_HOST); if (empty($active_domain)) { $active_domain = 'default'; } } // Kopieer bestaande homepage_meta_description_per_domain van current_settings als die niet bestaat in updated_settings if (!isset($updated_settings['homepage_meta_description_per_domain']) || !is_array($updated_settings['homepage_meta_description_per_domain'])) { $updated_settings['homepage_meta_description_per_domain'] = isset($current_settings['homepage_meta_description_per_domain']) && is_array($current_settings['homepage_meta_description_per_domain']) ? $current_settings['homepage_meta_description_per_domain'] : []; } $updated_settings['homepage_meta_description_per_domain'][$active_domain] = $homepage_meta_description; // BELANGRIJK: Verwijder de oude homepage_meta_description key zodat de sanitize_callback // de nieuwe per-domain waarde niet overschrijft met de oude single-domain waarde unset($updated_settings['homepage_meta_description']); } else { $updated_settings['homepage_meta_description'] = $homepage_meta_description; // Verwijder per-domain array als we terug naar single-domain gaan unset($updated_settings['homepage_meta_description_per_domain']); } } // Sla de instellingen op via update_option update_option('ai_translate_settings', $updated_settings); } // Tell the page what was measured, so its warning matches (true = always thinks, false = can be limited). $thinking_state = AI_Translate_Core::measured_thinking_state((string) $provider_key, (string) $model); wp_send_json_success([ 'message' => __('API and model are working. API settings have been saved.', 'ai-translate'), 'always_thinking' => $thinking_state === null ? null : ($thinking_state === 'always'), 'probe_pending' => AI_Translate_Core::reasoning_probe_pending((string) $provider_key, (string) $model), ]); } catch (\Exception $e) { wp_send_json_error(['message' => __('API validation failed:', 'ai-translate') . ' ' . $e->getMessage()]); } }); add_action('update_option_ai_translate_settings', 'AITranslate\\maybe_flush_rules_on_settings_update', 20, 2); /** * Flush rewrite rules when language-related settings change. * * @param array $old * @param array $new * @return void */ function maybe_flush_rules_on_settings_update($old, $new) { $keys = ['default_language', 'enabled_languages', 'detectable_languages']; foreach ($keys as $k) { $old_v = isset($old[$k]) ? $old[$k] : null; $new_v = isset($new[$k]) ? $new[$k] : null; if ($old_v !== $new_v) { AI_Translate_Core::settings(true); ai_translate_register_rewrite_rules(); flush_rewrite_rules(); return; } } } // --- AJAX handler voor language settings update --- add_action('wp_ajax_ai_translate_update_language_settings', function () { // Check nonce manually to return JSON instead of HTML error page $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (!wp_verify_nonce($nonce, 'ai_translate_language_settings_nonce')) { wp_send_json_error(['message' => __('Security check failed. Please refresh the page.', 'ai-translate')]); return; } if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'ai-translate')]); return; } $section = isset($_POST['section']) ? sanitize_text_field(wp_unslash($_POST['section'])) : ''; if (empty($section)) { wp_send_json_error(['message' => __('Section parameter missing.', 'ai-translate')]); return; } if (!in_array($section, ['enabled', 'detectable'])) { wp_send_json_error(['message' => sprintf(/* translators: %s: section identifier */ __('Invalid section: %s', 'ai-translate'), $section)]); return; } $field_name = $section === 'enabled' ? 'enabled_languages' : 'detectable_languages'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified via wp_verify_nonce() at top of handler $values = isset($_POST[$field_name]) ? array_map('sanitize_text_field', wp_unslash((array) $_POST[$field_name])) : []; $settings = AI_Translate_Core::settings(); $settings[$field_name] = $values; update_option('ai_translate_settings', $settings); wp_send_json_success(['message' => __('Language settings saved.', 'ai-translate')]); });