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, '') !== 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 '';
echo '
';
echo '
' . esc_html__('Note:', 'ai-translate') . ' ' . 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') . '
';
echo '
';
// Custom URL field
echo '
';
echo '';
// Known OpenAI-compatible endpoints to pick from; the script shows them in a menu (the field still takes any URL).
echo '';
$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 = 'https://openrouter.ai/api/v1/';
// 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 '
';
},
'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 '';
echo '';
echo ' ';
},
'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 ' ';
if ($unavailable_notice !== '') {
echo '
' . esc_html($unavailable_notice) . '
';
}
echo '
';
echo '';
echo '
';
echo '
';
echo '';
echo '
';
},
'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 '
' . 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')) . '
';
echo '';
},
'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 '';
},
'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 '
' . 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')) . '
';
},
'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 '';
echo '
' . 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')) . '
';
echo '' . esc_html__('Note:', 'ai-translate') . ' ';
echo esc_html__('Menu translation caches (transients) are ALWAYS cleared when you update menus, regardless of this setting.', 'ai-translate');
echo ' ';
echo '' . esc_html__('When enabled:', 'ai-translate') . ' ';
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 ' ';
echo '' . esc_html__('When disabled:', 'ai-translate') . ' ';
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 '
';
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 '
';
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 '
';
},
'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 '';
echo '
';
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 '
';
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 '
';
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 '
';
},
'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 '
' . 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')) . '
';
} else {
echo '
' . 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')) . '
';
}
echo '';
echo '';
},
'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 '
';
} 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 '';
if ($multi_domain) {
echo '
' . 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')) . '
';
} else {
echo '
' . esc_html(__('Provide context about your website or business to help the AI generate more accurate and contextually appropriate translations. ', 'ai-translate')) . '
';
}
echo '';
echo '';
},
'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 = '
' . sprintf(
/* translators: %s: string value */ __('Cache for language %s cleared.', 'ai-translate'),
'' . esc_html($lang_name) . ' (' . esc_html($lang_code) . ')'
) . '
' . sprintf(/* translators: %d: number value */ __('Files removed: %d', 'ai-translate'), intval($result['count'])) . '
' . sprintf(/* translators: %d: number value */ __('Remaining files: %d', 'ai-translate'), intval($after_count)) . '
';
if (isset($result['warning'])) {
$cache_language_message .= '
';
}
}
return $cache_language_message;
}
/**
* Section: clear all language caches (handles its own form post).
*
* @return void
*/
function render_clear_all_caches_section()
{
?>
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 '
' . esc_html__('All language caches and translation transients cleared. Menu and slug cache preserved.', 'ai-translate') . '
$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');
?>
MB
0): ?>
MB
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 '
';
echo esc_html(safe_sprintf(/* translators: %d: number value */ __('Cache metadata rescanned: %d cache records added or updated.', 'ai-translate'), $count));
echo '
';
} else {
echo '
';
echo esc_html__('No cache records added. There are currently no cache files available.', 'ai-translate');
echo '
';
}
}
// 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 '
';
echo esc_html(sprintf(/* translators: %d: number value */ __('Cache metadata initialized: %d existing cache records added.', 'ai-translate'), $count));
echo '
';
}
}
// 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 '
';
echo esc_html__('Cache directory found but no metadata. Click "Scan Cache Files" to generate metadata.', 'ai-translate');
echo '