Plugin Directory

Changeset 3456920


Ignore:
Timestamp:
02/09/2026 10:07:53 AM (12 days ago)
Author:
gkanters
Message:

Update trunk for version 2.2.6

Location:
ai-translate/trunk
Files:
7 edited

Legend:

Unmodified
Added
Removed
  • ai-translate/trunk/README.md

    r3455458 r3456920  
    44Requires at least: 5.0 
    55Tested up to: 6.9 
    6 Stable tag: 2.2.5
     6Stable tag: 2.2.6
    77Requires PHP: 8.0
    88License: GPLv2 or later 
     
    185185## Changelog
    186186
    187 ### 2.2.5
     187### 2.2.6
    188188- Improve system prompt (role, what and how).
    189189- Improve website context generator (what is the website about).
     
    191191- All enabled and detectable languages added to Wordpress sitemap (/wp-sitemap.xml)
    192192- Code base refactoring. Improve performance and security, thighening code.
     193
    193194
    194195### 2.2.4
  • ai-translate/trunk/ai-translate.php

    r3455458 r3456920  
    66 * Author: NetCare
    77 * Author URI: https://netcare.nl/
    8  * Version: 2.2.5
     8 * Version: 2.2.6
    99 * Requires at least: 5.0
    1010 * Tested up to: 6.9
     
    729729    }
    730730
     731    // RULE 0: Strip explicit ?lang= query parameter from URL → redirect to clean path-based URL
     732    // The ?lang= parameter is only used internally by rewrite rules, never in actual visitor URLs.
     733    // Examples: /es?lang=fr → /es/  |  /?lang=es → /es/  |  /?lang={default} → /
     734    if (isset($_GET['lang']) && trim((string) $_GET['lang']) !== '') {
     735        $cleanParams = $_GET;
     736        unset($cleanParams['lang']);
     737
     738        $targetPath = $reqPath;
     739        if ($langFromUrl === null) {
     740            // No language in path yet — use the GET parameter to build the correct path
     741            $getLang = strtolower(sanitize_key((string) $_GET['lang']));
     742            if ($getLang !== '' && $defaultLang && strtolower($getLang) !== strtolower((string) $defaultLang)) {
     743                $targetPath = '/' . $getLang . '/';
     744            }
     745        }
     746
     747        // Ensure trailing slash (except bare root)
     748        if ($targetPath !== '/' && substr($targetPath, -1) !== '/') {
     749            $targetPath .= '/';
     750        }
     751
     752        $targetUrl = home_url($targetPath);
     753        if (!empty($cleanParams)) {
     754            $targetUrl = add_query_arg($cleanParams, $targetUrl);
     755        }
     756
     757        nocache_headers();
     758        wp_safe_redirect(esc_url_raw($targetUrl), 301);
     759        exit;
     760    }
     761
    731762    $cookieLang = isset($_COOKIE['ai_translate_lang']) ? strtolower(sanitize_key((string) $_COOKIE['ai_translate_lang'])) : '';
    732763
     
    767798
    768799    // RULE 4: If cookie exists and on root, ensure language is set to default
    769     // Skip this rule if there's a search parameter or explicit lang parameter (handled above)
    770     $hasExplicitLang = isset($_GET['lang']) && trim((string) $_GET['lang']) !== '';
    771     if ($reqPath === '/' && $cookieLang !== '' && !$hasSearchParam && !$hasExplicitLang) {
     800    // Skip this rule if there's a search parameter (explicit ?lang= is already handled by RULE 0)
     801    if ($reqPath === '/' && $cookieLang !== '' && !$hasSearchParam) {
    772802        \AITranslate\AI_Lang::set_cookie((string) $defaultLang);
    773803        // Set language and stop processing
     
    10981128function cS(r){function n(t){return t?t.trim().replace(/\s+/g," "):""}var s=new Set();var ns=r.querySelectorAll?r.querySelectorAll("input,textarea,select,button,[title],[aria-label],img[alt],.initial-greeting,.chatbot-bot-text"):[];ns.forEach(function(el){if(el.closest?el.closest("[data-ai-trans-skip]"):el.hasAttribute("data-ai-trans-skip"))return;var ph=n(el.getAttribute("placeholder"));if(ph)s.add(ph);var tl=n(el.getAttribute("title"));if(tl)s.add(tl);var al=n(el.getAttribute("aria-label"));if(al)s.add(al);var at=n(el.getAttribute("alt"));if(at)s.add(at);var tg=(el.tagName||"").toLowerCase();if(tg==="input"){var tp=(el.getAttribute("type")||"").toLowerCase();if(tp==="submit"||tp==="button"||tp==="reset"){var v=n(el.getAttribute("value"));if(v)s.add(v);}}var tc=el.textContent;if((el.classList.contains("initial-greeting")||el.classList.contains("chatbot-bot-text"))&&tc){var tcn=n(tc);if(tcn)s.add(tcn);}});return Array.from(s);}
    10991129 function aT(r,m){var ns=r.querySelectorAll?r.querySelectorAll("input,textarea,select,button,[title],[aria-label],img[alt],.initial-greeting,.chatbot-bot-text"):[];ns.forEach(function(el){if(el.closest?el.closest("[data-ai-trans-skip]"):el.hasAttribute("data-ai-trans-skip"))return;var ph=el.getAttribute("placeholder");if(ph){var pht=ph.trim();if(pht&&m[pht]!=null)el.setAttribute("placeholder",m[pht]);}var tl=el.getAttribute("title");if(tl){var tlt=tl.trim();if(tlt&&m[tlt]!=null)el.setAttribute("title",m[tlt]);}var al=el.getAttribute("aria-label");if(al){var alt=al.trim();if(alt&&m[alt]!=null)el.setAttribute("aria-label",m[alt]);}var at=el.getAttribute("alt");if(at){var att=at.trim();if(att&&m[att]!=null)el.setAttribute("alt",m[att]);}var tg=(el.tagName||"").toLowerCase();if(tg==="input"){var tp=(el.getAttribute("type")||"").toLowerCase();if(tp==="submit"||tp==="button"||tp==="reset"){var v=el.getAttribute("value");if(v){var vt=v.trim();if(vt&&m[vt]!=null)el.setAttribute("value",m[vt]);}}}var tc=el.textContent;if((el.classList.contains("initial-greeting")||el.classList.contains("chatbot-bot-text"))&&tc){var tct=tc.trim();if(tct&&m[tct]!=null)el.textContent=m[tct];}});}
    1100  function tA(r){if(tA.called)return;tA.called=true;var ss=cS(r);if(!ss.length){tA.called=false;return;}var x=new XMLHttpRequest();x.open("POST",AI_TA.u,true);x.setRequestHeader("Content-Type","application/json; charset=UTF-8");x.onreadystatechange=function(){if(x.readyState===4){tA.called=false;if(x.status===200){try{var resp=JSON.parse(x.responseText);if(resp&&resp.success&&resp.data&&resp.data.map){aT(r,resp.data.map);}}catch(e){}}}};x.send(JSON.stringify({nonce:AI_TA.n,lang:gL(),strings:ss}));}
     1130 function tA(r){if(tA.called)return;tA.called=true;var ua=(typeof navigator!=="undefined"&&navigator.userAgent)?navigator.userAgent:"";if(/googlebot|bingbot|yandexbot|baiduspider|duckduckbot|slurp|facebot|ia_archiver/i.test(ua)){tA.called=false;return;}var ss=cS(r);if(!ss.length){tA.called=false;return;}var x=new XMLHttpRequest();x.open("POST",AI_TA.u,true);x.setRequestHeader("Content-Type","application/json; charset=UTF-8");x.onreadystatechange=function(){if(x.readyState===4){tA.called=false;if(x.status===200){try{var resp=JSON.parse(x.responseText);if(resp&&resp.success&&resp.data&&resp.data.map){aT(r,resp.data.map);}}catch(e){}}}};x.send(JSON.stringify({nonce:AI_TA.n,lang:gL(),strings:ss}));}
    11011131document.addEventListener("DOMContentLoaded",function(){var checkPage=function(){if(document.readyState==="complete"){setTimeout(function(){tA(document);},3000);}else{setTimeout(checkPage,100);}};checkPage();var moT=null,sel="input,textarea,select,button,[title],[aria-label],img[alt],.initial-greeting,.chatbot-bot-text";if(typeof MutationObserver!=="undefined"&&gL()){var mo=new MutationObserver(function(muts){for(var f=false,i=0;i<muts.length&&!f;i++){var a=muts[i].addedNodes;for(var j=0;j<a.length&&!f;j++){var n=a[j];if(n.nodeType===1){if(n.matches&&n.matches(sel))f=true;else if(n.querySelector&&n.querySelector(sel))f=true;}}}if(f){clearTimeout(moT);moT=setTimeout(function(){tA(document);},500);}});mo.observe(document.body||document.documentElement,{childList:true,subtree:true});}});
    11021132})();</script>';
     
    11731203        'methods' => 'POST',
    11741204        'permission_callback' => function (\WP_REST_Request $request) {
     1205            // Bot check first: bots get empty response anyway, no need for referer/nonce
     1206            $ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
     1207            $is_bot = $ua !== '' && preg_match('/googlebot|googleother|bingbot|yandexbot|baiduspider|duckduckbot|slurp|facebot|ia_archiver/i', $ua);
     1208            if ($is_bot) {
     1209                $request->set_param('_ai_tr_bot_no_nonce', 1);
     1210                return true;
     1211            }
     1212            // Referer check: must originate from this site
     1213            $referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';
     1214            if ($referer === '' || strpos($referer, home_url()) !== 0) {
     1215                return new \WP_Error('rest_forbidden', 'Invalid referer', ['status' => 403]);
     1216            }
    11751217            $nonce = $request->get_header('X-WP-Nonce');
    11761218            if ($nonce && wp_verify_nonce($nonce, 'wp_rest')) {
     
    11851227        'args' => [],
    11861228        'callback' => function (\WP_REST_Request $request) {
     1229            $is_bot_request = (bool) $request->get_param('_ai_tr_bot_no_nonce');
    11871230            // Rate limiting: max 60 requests per minute per IP.
    11881231            // This endpoint is called by client-side JavaScript for UI attribute translation.
     
    11921235            $rate_key = 'ai_tr_rate_' . md5($ip);
    11931236            $rate_count = (int) get_transient($rate_key);
    1194             if ($rate_count >= 60) {
     1237            if ($rate_count >= 30) {
    11951238                return new \WP_REST_Response(['error' => 'Rate limit exceeded'], 429);
    11961239            }
     
    12011244                $arr = [];
    12021245            }
     1246            // Input limits: max 50 strings of max 150 chars each (UI attributes are short)
     1247            $arr = array_slice($arr, 0, 50);
     1248            $arr = array_filter($arr, function ($s) {
     1249                return is_string($s) && mb_strlen($s) <= 150;
     1250            });
    12031251            // Normalize texts: trim and collapse multiple whitespace to single space
    12041252            // Keep mapping between original and normalized for response
     
    12301278                // Fallback: try to detect from Referer header if available (more reliable than current() for REST calls)
    12311279                $referer = isset($_SERVER['HTTP_REFERER']) ? (string) $_SERVER['HTTP_REFERER'] : '';
     1280                // Normalize referrer: remove double slashes in path (e.g., /de// -> /de/)
     1281                if ($referer !== '') {
     1282                    $refererParsed = parse_url($referer);
     1283                    if (isset($refererParsed['path'])) {
     1284                        $refererParsed['path'] = preg_replace('#/+#', '/', $refererParsed['path']);
     1285                        $referer = '';
     1286                        if (isset($refererParsed['scheme'])) {
     1287                            $referer .= $refererParsed['scheme'] . '://';
     1288                        }
     1289                        if (isset($refererParsed['host'])) {
     1290                            $referer .= $refererParsed['host'];
     1291                            if (isset($refererParsed['port'])) {
     1292                                $referer .= ':' . $refererParsed['port'];
     1293                            }
     1294                        }
     1295                        $referer .= $refererParsed['path'];
     1296                        if (isset($refererParsed['query'])) {
     1297                            $referer .= '?' . $refererParsed['query'];
     1298                        }
     1299                        if (isset($refererParsed['fragment'])) {
     1300                            $referer .= '#' . $refererParsed['fragment'];
     1301                        }
     1302                    }
     1303                }
    12321304                if ($referer !== '' && preg_match('#/([a-z]{2})(?:/|$)#i', parse_url($referer, PHP_URL_PATH) ?: '', $m)) {
    12331305                    $lang = strtolower($m[1]);
     
    12581330            // Batch-strings are UI attributes that should not be translated if stop_translations is enabled
    12591331            $stop_translations = isset($settings['stop_translations_except_cache_invalidation']) ? (bool) $settings['stop_translations_except_cache_invalidation'] : false;
    1260            
    1261             if ($stop_translations) {
    1262             }
     1332            // Bots get cached translations but no API calls (same as stop_translations)
     1333            $skip_api = $stop_translations || $is_bot_request;
    12631334           
    12641335            $map = [];
     
    13151386                    $cacheInvalid = false;
    13161387
    1317                     // If stop_translations is enabled, always use cache (don't validate)
    1318                     if (!$stop_translations) {
     1388                    // If stop_translations is enabled or bot request, always use cache (don't validate)
     1389                    if (!$skip_api) {
    13191390                        // Validate cache: check if translation is exactly identical to source
    13201391                        // NOTE: Identical translations are now accepted as valid (text was already in target language)
     
    13421413                    }
    13431414
    1344                     if ($cacheInvalid && !$stop_translations) {
     1415                    if ($cacheInvalid && !$skip_api) {
    13451416                        // Cache entry is invalid - delete it and re-translate
    13461417                        ai_translate_delete_attr_transient($attrCacheKey);
     
    13571428                } else {
    13581429                    // Text not in cache
    1359                     if ($stop_translations) {
    1360                         // Stop translations enabled: use source text without API call
     1430                    if ($skip_api) {
     1431                        // Stop translations or bot: use source text without API call
    13611432                        $originalText = isset($textsOriginal[$normalized]) ? $textsOriginal[$normalized] : $normalized;
    13621433                        $map[$originalText] = $originalText;
     
    13821453            }
    13831454            if (!empty($toTranslate)) {
    1384                 if ($stop_translations) {
    1385                     // Stop translations enabled: block API calls, use source texts
     1455                if ($skip_api) {
     1456                    // Stop translations or bot: block API calls, use source texts
    13861457                    // Use source texts for all segments that would have been translated
    13871458                    foreach ($toTranslate as $id => $origNormalized) {
     
    13911462                    // Clear $toTranslate to prevent API call
    13921463                    $toTranslate = [];
     1464                } else {
     1465                    // Validate that strings actually exist on the page (security: prevent abuse)
     1466                    $referer = isset($_SERVER['HTTP_REFERER']) ? (string) $_SERVER['HTTP_REFERER'] : '';
     1467                    // Normalize referrer: remove double slashes in path (e.g., /de// -> /de/)
     1468                    if ($referer !== '') {
     1469                        $refererParsed = parse_url($referer);
     1470                        if (isset($refererParsed['path'])) {
     1471                            $refererParsed['path'] = preg_replace('#/+#', '/', $refererParsed['path']);
     1472                            $referer = '';
     1473                            if (isset($refererParsed['scheme'])) {
     1474                                $referer .= $refererParsed['scheme'] . '://';
     1475                            }
     1476                            if (isset($refererParsed['host'])) {
     1477                                $referer .= $refererParsed['host'];
     1478                                if (isset($refererParsed['port'])) {
     1479                                    $referer .= ':' . $refererParsed['port'];
     1480                                }
     1481                            }
     1482                            $referer .= $refererParsed['path'];
     1483                            if (isset($refererParsed['query'])) {
     1484                                $referer .= '?' . $refererParsed['query'];
     1485                            }
     1486                            if (isset($refererParsed['fragment'])) {
     1487                                $referer .= '#' . $refererParsed['fragment'];
     1488                            }
     1489                        }
     1490                    }
     1491                    if ($referer !== '' && strpos($referer, home_url()) === 0) {
     1492                        // Get page HTML (cached using same expiration as translations)
     1493                        $page_cache_key = 'ai_tr_page_html_' . md5($referer);
     1494                        $page_html = get_transient($page_cache_key);
     1495                        if ($page_html === false) {
     1496                            // Fetch page HTML
     1497                            $response = wp_remote_get($referer, array(
     1498                                'timeout' => 5,
     1499                                'sslverify' => false,
     1500                                'headers' => array(
     1501                                    'User-Agent' => 'AI-Translate-Validator/1.0'
     1502                                )
     1503                            ));
     1504                            if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
     1505                                $page_html = wp_remote_retrieve_body($response);
     1506                                // Cache using same expiration as translations
     1507                                $expiry_hours = isset($settings['cache_expiration']) ? (int) $settings['cache_expiration'] : (14 * 24);
     1508                                $expiry = max(1, $expiry_hours) * HOUR_IN_SECONDS;
     1509                                set_transient($page_cache_key, $page_html, $expiry);
     1510                            } else {
     1511                                // Security: if page HTML cannot be fetched, block translation to prevent abuse
     1512                                $toTranslate = [];
     1513                            }
     1514                        }
     1515                        // Validate each string exists on page (in attributes or content)
     1516                        if ($page_html !== '') {
     1517                            $validated = [];
     1518                            foreach ($toTranslate as $id => $text) {
     1519                                // Check if string exists in HTML (escape special regex chars)
     1520                                $text_escaped = preg_quote($text, '/');
     1521                                // Match in attributes (placeholder, title, aria-label, alt, value) or content
     1522                                if (preg_match('/' . $text_escaped . '/iu', $page_html)) {
     1523                                    $validated[$id] = $text;
     1524                                }
     1525                            }
     1526                            // Only allow translation of validated strings
     1527                            $toTranslate = $validated;
     1528                        } else {
     1529                            // Security: if page HTML is empty, block translation to prevent abuse
     1530                            $toTranslate = [];
     1531                        }
     1532                    } else {
     1533                        // Security: if referer is invalid or missing, block translation
     1534                        $toTranslate = [];
     1535                    }
    13931536                }
    13941537            }
     
    16391782    // Check if path contains a post type prefix (e.g., service/slug)
    16401783    $slug_used_for_lookup = null;
     1784    $detected_post_type = null;
    16411785    if (strpos($rest, '/') !== false) {
    16421786        $parts = explode('/', $rest, 2);
    16431787        if (count($parts) === 2) {
    16441788            $potential_post_type = $parts[0];
    1645             $potential_slug = $parts[1];
     1789            $potential_slug = trim($parts[1], '/');
    16461790            $slug_used_for_lookup = $potential_slug;
    16471791
    16481792            // Check if this is a registered post type
    16491793            if (post_type_exists($potential_post_type)) {
     1794                $detected_post_type = $potential_post_type;
     1795                // If slug is empty (only post type, e.g., /it/service/), redirect to language root
     1796                if ($potential_slug === '') {
     1797                    nocache_headers();
     1798                    wp_redirect(home_url('/' . $lang . '/'), 301);
     1799                    exit;
     1800                }
    16501801                $post_id = \AITranslate\AI_Slugs::resolve_path_to_post($lang, $potential_slug);
    16511802                if ($post_id) {
     
    16591810                }
    16601811            }
     1812        }
     1813    } else {
     1814        // Single segment: check if it's a post type without slug (e.g., /it/service)
     1815        if (post_type_exists($rest)) {
     1816            $detected_post_type = $rest;
     1817            // Post type without slug, redirect to language root
     1818            nocache_headers();
     1819            wp_redirect(home_url('/' . $lang . '/'), 301);
     1820            exit;
    16611821        }
    16621822    }
     
    18241984        }
    18251985    }
     1986
     1987    // No resolution found for this language-prefixed URL.
     1988    // Remove the rewrite-generated pagename (e.g. "es/bestaat-niet") to prevent
     1989    // WordPress from falling back to the blog index instead of properly triggering a 404.
     1990    unset($wp->query_vars['pagename']);
     1991    $wp->query_vars['error'] = '404';
    18261992});
    18271993
     
    22482414        return;
    22492415    }
     2416
     2417    // Rate limiting: max 10 search queries per minute per IP (prevent abuse/cost attacks)
     2418    // Normal users rarely exceed 1-2 searches per minute; 5 is generous but prevents abuse
     2419    $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field($_SERVER['REMOTE_ADDR']) : 'unknown';
     2420    $rate_key = 'ai_tr_search_rate_' . md5($ip);
     2421    $rate_count = (int) get_transient($rate_key);
     2422    if ($rate_count >= 10) {
     2423        // Rate limit exceeded: skip translation, use original query
     2424        return;
     2425    }
     2426    set_transient($rate_key, $rate_count + 1, 60);
    22502427
    22512428    $settings = get_option('ai_translate_settings', array());
  • ai-translate/trunk/includes/admin-page.php

    r3455458 r3456920  
    129129    $post_id = intval($raw_post_id);
    130130   
    131     if ($post_id < 0) {
     131    if ($post_id < -1) {
    132132        wp_send_json_error(['message' => __('Invalid post ID.', 'ai-translate')]);
    133133        return;
    134134    }
    135135   
    136     // For homepage (post_id = 0), skip post verification
     136    // For homepage (post_id = 0) and 404 page (post_id = -1), skip post verification
    137137    if ($post_id > 0) {
    138138        // Verify post exists and user has permission to access it
     
    318318        $front_page_id = (int) get_option('page_on_front');
    319319        return ($front_page_id > 0) ? ('post:' . $front_page_id) : ('path:' . md5('/'));
     320    }
     321    if ($post_id === -1) {
     322        return 'path:' . md5('/404');
    320323    }
    321324    return 'post:' . $post_id;
     
    416419        }
    417420       
    418         // Check if response looks like an error page (404, 403, etc.)
     421        // Check if response looks like an error page (403, 500, etc.)
    419422        $is_error_page = false;
    420         if ($http_code === 200 && !empty($response_body)) {
    421             // Check for common error indicators in HTML
     423        if ($http_code === 200 && !empty($response_body) && $post_id !== -1) {
     424            // Check for common error indicators in HTML (skip for 404 pages which are expected)
    422425            if (stripos($response_body, '<title>404') !== false ||
    423426                stripos($response_body, 'page not found') !== false ||
     
    428431        }
    429432       
    430         // Accept 200 as success (unless it's an error page)
    431         if ($http_code === 200 && !$is_error_page) {
     433        // Accept 200 as success (unless it's an error page), and 404 for 404 page warming
     434        $is_success = ($http_code === 200 && !$is_error_page) || ($post_id === -1 && $http_code === 404);
     435        if ($is_success) {
    432436            // Wait a moment for async cache writes to complete
    433437            usleep(500000); // 0.5 second
     
    658662    $post_id = intval($raw_post_id);
    659663   
    660     if ($post_id < 0) {
     664    if ($post_id < -1) {
    661665        wp_send_json_error(['message' => __('Invalid post ID.', 'ai-translate')]);
    662666        return;
    663667    }
    664668   
    665     // For homepage (post_id = 0), skip post verification
     669    // For homepage (post_id = 0) and 404 page (post_id = -1), skip post verification
    666670    if ($post_id > 0) {
    667671        // Verify post exists and user has permission to access it
     
    692696    }
    693697   
    694     // Get post URL (homepage uses home_url)
     698    // Get post URL (homepage uses home_url, 404 uses a non-existent path to trigger 404)
    695699    if ($post_id === 0) {
    696700        $post_url = home_url('/');
     701    } elseif ($post_id === -1) {
     702        // 404 page: use a non-existent path that will trigger a 404
     703        $post_url = home_url('/ait-404-cache-warm/');
    697704    } else {
    698705        $post_url = get_permalink($post_id);
  • ai-translate/trunk/includes/class-ai-cache-meta.php

    r3455458 r3456920  
    7777        $sql = "CREATE TABLE IF NOT EXISTS $table_name (
    7878            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    79             post_id BIGINT UNSIGNED NOT NULL,
     79            post_id BIGINT NOT NULL,
    8080            language_code VARCHAR(10) NOT NULL,
    8181            cache_file VARCHAR(255) NOT NULL,
     
    486486            array_unshift($results, $homepage_obj);
    487487        }
     488
     489        // Add 404 page row (always show on first page, even if no cache yet - similar to homepage)
     490        if ($offset === 0) {
     491            $error404_cached = (int) $wpdb->get_var($wpdb->prepare(
     492                "SELECT COUNT(DISTINCT language_code) FROM " . $table_name . " WHERE post_id = %d",
     493                -1
     494            ));
     495            $error404_obj = new \stdClass();
     496            $error404_obj->ID = -1;
     497            $error404_obj->post_type = '404';
     498            $error404_obj->post_title = __('404 Page', 'ai-translate');
     499            $error404_obj->cached_languages = $error404_cached;
     500            $error404_obj->total_languages = $total_languages;
     501            $error404_obj->percentage = $total_languages > 0
     502                ? round(($error404_cached / $total_languages) * 100)
     503                : 0;
     504            $error404_obj->url = home_url('/404');
     505            $results[] = $error404_obj;
     506        }
    488507       
    489508        // Add percentage and URL
     
    649668                   
    650669                    $post_id = (int) $row->post_id;
    651                     $title = $post_id === 0 ? __('Homepage', 'ai-translate') : get_the_title($post_id);
     670                    $title = $post_id === 0 ? __('Homepage', 'ai-translate') : ($post_id === -1 ? __('404 Page', 'ai-translate') : get_the_title($post_id));
    652671                    if ($title === '') {
    653672                        $title = sprintf(__('Post %d', 'ai-translate'), $post_id);
     
    779798        if ($post_id === 0) {
    780799            $path = '/';
     800        } elseif ($post_id === -1) {
     801            // 404 page - no real permalink
     802            return home_url('/' . $lang . '/404/');
    781803        } else {
    782804            $permalink = get_permalink($post_id);
  • ai-translate/trunk/includes/class-ai-cache.php

    r3455458 r3456920  
    134134                $post_id = 0;
    135135            }
     136            // Check if route_id is the 404 page (path:md5(/404))
     137            elseif ($route_id === ('path:' . md5('/404'))) {
     138                // Use post_id = -1 for 404 page
     139                $post_id = -1;
     140            }
    136141           
    137142            if ($post_id !== null) {
  • ai-translate/trunk/includes/class-ai-ob.php

    r3455577 r3456920  
    679679        }
    680680       
     681        // For 404 pages: use a single consistent route_id so all 404s share one cache entry per language
     682        if (function_exists('is_404') && is_404()) {
     683            return 'path:' . md5('/404');
     684        }
     685
    681686        // CRITICAL: For warm cache requests and translated URLs, resolve the post ID from the URL FIRST
    682687        // This is needed because WordPress query functions (is_singular, get_queried_object_id)
     
    878883    private function should_skip_content_type($html)
    879884    {
    880         // Skip 404 pages - multiple detection methods
     885        // Skip 404 pages - but allow translation when a non-default language is active
     886        // Users encountering a 404 in another language should see translated error messages
    881887        if ($this->is_404_page($html)) {
    882             return true;
     888            $lang = AI_Lang::current();
     889            if ($lang === null || !AI_Lang::should_translate($lang)) {
     890                return true;
     891            }
     892            // Non-default language active: allow 404 through for translation
    883893        }
    884894
     
    907917    private function should_skip_page_structure()
    908918    {
    909         // Only translate singular posts/pages, with exceptions for search/homepage
     919        // Only translate singular posts/pages, with exceptions for search/homepage/404
    910920        if (function_exists('is_singular') && !is_singular()) {
    911921            $is_search = function_exists('is_search') && is_search();
    912922            $is_front_page = function_exists('is_front_page') && is_front_page();
    913             if (!$is_search && !$is_front_page) {
     923            $is_404 = function_exists('is_404') && is_404();
     924            if (!$is_search && !$is_front_page && !$is_404) {
    914925                return true;
    915926            }
     
    12221233            }
    12231234            if (function_exists('is_front_page') && is_front_page()) {
     1235                return true;
     1236            }
     1237            // Cache translated 404 pages - always same content, avoids repeated API calls
     1238            if (function_exists('is_404') && is_404()) {
    12241239                return true;
    12251240            }
  • ai-translate/trunk/includes/class-ai-seo.php

    r3455458 r3456920  
    745745        }
    746746
     747        // 404 page: use a non-existent path to trigger a 404 response
     748        if ((int) $post_id === -1) {
     749            return home_url('/' . $lang . '/ait-404-cache-warm/');
     750        }
     751
    747752        $translatedSlug = AI_Slugs::get_or_generate((int) $post_id, $lang);
    748753        if ($translatedSlug !== null && $translatedSlug !== '') {
Note: See TracChangeset for help on using the changeset viewer.