Plugin Directory

Changeset 3461910


Ignore:
Timestamp:
02/15/2026 04:00:51 PM (6 days ago)
Author:
gkanters
Message:

Release 2.2.7: sync trunk from Git source

Location:
ai-translate/trunk
Files:
7 edited

Legend:

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

    r3456920 r3461910  
    44Requires at least: 5.0 
    55Tested up to: 6.9 
    6 Stable tag: 2.2.6
     6Stable tag: 2.2.7
    77Requires PHP: 8.0
    88License: GPLv2 or later 
     
    185185## Changelog
    186186
    187 ### 2.2.6
     187### 2.2.7
    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- Better detect usable models from OpenAI. 
     194- Change language switcher url to fixed urls instead of query param.
    193195
    194196
  • ai-translate/trunk/ai-translate.php

    r3456920 r3461910  
    66 * Author: NetCare
    77 * Author URI: https://netcare.nl/
    8  * Version: 2.2.6
     8 * Version: 2.2.7
    99 * Requires at least: 5.0
    1010 * Tested up to: 6.9
     
    10041004    foreach ($enabled as $code) {
    10051005        $code = sanitize_key($code);
    1006         $label = strtoupper($code === $default ? $default : $code);
    1007         // Use ?switch_lang= parameter to ensure cookie is set via init hook
     1006        $isDefaultLang = (strtolower($code) === strtolower((string) $default));
     1007        $label = strtoupper($isDefaultLang ? $default : $code);
     1008        // Use one canonical public URL format:
     1009        // - default language => /
     1010        // - non-default language => /{lang}/
    10081011        // Build relative URL to avoid host/home filters
    1009         $url = '/?switch_lang=' . $code;
     1012        $url = $isDefaultLang ? '/' : '/' . $code . '/';
    10101013        $url = esc_url($url);
    10111014        $flag = esc_url($flags_url . $code . '.png');
     
    11021105    foreach ($enabled as $code) {
    11031106        $code = sanitize_key($code);
     1107        $isDefaultLang = (strtolower($code) === strtolower((string) $default));
    11041108
    11051109        // Skip current language
     
    11081112        }
    11091113
    1110         $label = strtoupper($code === $default ? $default : $code);
    1111         // Use ?switch_lang= parameter to ensure cookie is set via init hook
     1114        $label = strtoupper($isDefaultLang ? $default : $code);
     1115        // Use one canonical public URL format:
     1116        // - default language => /
     1117        // - non-default language => /{lang}/
    11121118        // Build relative URL to avoid host/home filters
    1113         $url = '/?switch_lang=' . $code;
     1119        $url = $isDefaultLang ? '/' : '/' . $code . '/';
    11141120        $url = esc_url($url);
    11151121        $flag = esc_url($flags_url . $code . '.png');
  • ai-translate/trunk/includes/admin-page.php

    r3456920 r3461910  
    25282528    }, $data['data']);
    25292529    $models = array_filter($models);
    2530     // o1/o3 reasoning models don't support temperature; block from selection to avoid API errors
     2530    // Filter model families known to be incompatible with text translation chat/completions.
     2531    // Keep only models that are likely usable for this plugin's translation pipeline.
    25312532    $models = array_filter($models, function ($model) {
    2532         return !preg_match('/^(o1-|o3-)/i', $model);
     2533        if (preg_match('/^(o1-|o3-)/i', $model)) {
     2534            return false;
     2535        }
     2536        // Block non-chat capabilities even when they appear later in a GPT model ID.
     2537        return !preg_match('/(dall-e|whisper|audio|image|realtime|transcribe|tts|embedding|moderation|codex|instruct)/i', $model);
    25332538    });
    25342539    sort($models);
  • ai-translate/trunk/includes/class-ai-ob.php

    r3456920 r3461910  
    457457            }
    458458        }
     459
     460        // Translate Twitter Card and JSON-LD text that Jetpack/plugins inject via wp_head.
     461        // These run as regex on the final HTML because DOMDocument placeholder divs break
     462        // the <head> structure, causing these meta/script tags to be inaccessible in the DOM pass.
     463        $html3 = AI_SEO::translateTwitterCards($html3, $lang);
     464        $html3 = AI_SEO::translateJsonLd($html3, $lang);
    459465
    460466        // Translate chatbot greeting strings embedded in inline script JSON (e.g. Kognetiks kchat_settings).
  • ai-translate/trunk/includes/class-ai-seo.php

    r3456920 r3461910  
    256256       
    257257        if ($ogLocale !== '') {
    258             $existingOgLocale = $xpath->query('//head/meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:locale"]');
     258            $existingOgLocale = $xpath->query('//meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:locale"]');
    259259            if ($existingOgLocale && $existingOgLocale->length > 0) {
    260260                for ($i = $existingOgLocale->length - 1; $i >= 0; $i--) {
     
    377377            }
    378378            if ($shouldReplaceOgDesc && $ogDesc !== '') {
    379                 $existingOgDesc = $xpath->query('//head/meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:description"]');
     379                $existingOgDesc = $xpath->query('//meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:description"]');
    380380                if ($existingOgDesc && $existingOgDesc->length > 0) {
    381381                    $ogDescElem = $existingOgDesc->item(0);
     
    391391                }
    392392            } elseif ($isTranslatedLang && $shouldReplaceOgDesc && $ogDesc === '') {
    393                 $existingOgDesc = $xpath->query('//head/meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:description"]');
     393                $existingOgDesc = $xpath->query('//meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:description"]');
    394394                if ($existingOgDesc && $existingOgDesc->length > 0) {
    395395                    $ogDescElem = $existingOgDesc->item(0);
     
    407407            }
    408408            if (($ogImageMissing || $shouldReplaceOgImage) && $ogImage !== '') {
    409                 $existingOgImage = $xpath->query('//head/meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:image"]');
     409                $existingOgImage = $xpath->query('//meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:image"]');
    410410                if ($existingOgImage && $existingOgImage->length > 0) {
    411411                    $ogImageElem = $existingOgImage->item(0);
     
    422422            }
    423423            if ($isTranslatedLang) {
    424                 $existingOgImageAlt = $xpath->query('//head/meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:image:alt"]');
     424                $existingOgImageAlt = $xpath->query('//meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="og:image:alt"]');
    425425                if ($existingOgImageAlt && $existingOgImageAlt->length > 0) {
    426426                    $ogImageAltElem = $existingOgImageAlt->item(0);
     
    778778    private static function isOgMissing($xpath, $prop)
    779779    {
    780         $q = sprintf('//head/meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="%s"]', strtolower($prop));
     780        // Search entire document, not just <head>. DOMDocument may move OG tags from
     781        // Jetpack/Yoast/RankMath to <body> when placeholder <div>s break <head> structure.
     782        $q = sprintf('//meta[translate(@property, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="%s"]', strtolower($prop));
    781783        $nodes = $xpath->query($q);
    782784        return !($nodes && $nodes->length > 0);
     
    843845        return home_url($path);
    844846    }
     847
     848    /**
     849     * Translate Twitter Card meta tags in final HTML.
     850     * Runs on the fully assembled HTML (after DOMDocument + placeholder restore)
     851     * because DOMDocument may move Jetpack meta tags out of <head> when
     852     * preceding script/style placeholders break the head structure.
     853     *
     854     * @param string $html Full HTML output.
     855     * @param string $lang Target language code.
     856     * @return string HTML with translated Twitter Card meta tags.
     857     */
     858    public static function translateTwitterCards($html, $lang)
     859    {
     860        $default = AI_Lang::default();
     861        if ($default === null || strtolower((string) $lang) === strtolower((string) $default)) {
     862            return $html;
     863        }
     864
     865        $twitterNames = ['twitter:text:title', 'twitter:title', 'twitter:description', 'twitter:image:alt'];
     866        $names = implode('|', array_map(function ($n) { return preg_quote($n, '#'); }, $twitterNames));
     867        $pattern = '#(<meta\s+name="(?:' . $names . ')"\s+content=")([^"]+)("[^>]*>)#i';
     868
     869        return preg_replace_callback($pattern, function ($m) use ($default, $lang) {
     870            $original = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
     871            if (trim($original) === '') {
     872                return $m[0];
     873            }
     874            $translated = self::maybeTranslateMeta($original, $default, $lang);
     875            if (!is_string($translated) || $translated === '' || $translated === $original) {
     876                return $m[0];
     877            }
     878            return $m[1] . esc_attr($translated) . $m[3];
     879        }, $html);
     880    }
     881
     882    /**
     883     * Translate JSON-LD BreadcrumbList name fields in final HTML.
     884     * Called after script placeholders have been restored.
     885     *
     886     * @param string $html Full HTML output.
     887     * @param string $lang Target language code.
     888     * @return string HTML with translated JSON-LD.
     889     */
     890    public static function translateJsonLd($html, $lang)
     891    {
     892        $default = AI_Lang::default();
     893        if ($default === null || strtolower((string) $lang) === strtolower((string) $default)) {
     894            return $html;
     895        }
     896
     897        return preg_replace_callback(
     898            '#(<script\b[^>]*type=["\']application/ld\+json["\'][^>]*>)(.*?)(</script>)#is',
     899            function ($m) use ($default, $lang) {
     900                $json = json_decode($m[2], true);
     901                if (!is_array($json)) {
     902                    return $m[0];
     903                }
     904
     905                $changed = self::translateJsonLdNode($json, $default, $lang);
     906                if (!$changed) {
     907                    return $m[0];
     908                }
     909
     910                $encoded = wp_json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
     911                if (!is_string($encoded) || $encoded === '') {
     912                    return $m[0];
     913                }
     914                return $m[1] . $encoded . $m[3];
     915            },
     916            $html
     917        );
     918    }
     919
     920    /**
     921     * Recursively translate name/description fields in JSON-LD BreadcrumbList nodes.
     922     *
     923     * @param array  &$node   JSON-LD node (modified in place).
     924     * @param string $default Default language.
     925     * @param string $lang    Target language.
     926     * @return bool Whether any field was translated.
     927     */
     928    private static function translateJsonLdNode(array &$node, $default, $lang)
     929    {
     930        $type = $node['@type'] ?? '';
     931        $changed = false;
     932
     933        if ($type === 'BreadcrumbList' && isset($node['itemListElement']) && is_array($node['itemListElement'])) {
     934            foreach ($node['itemListElement'] as &$item) {
     935                if (isset($item['name']) && is_string($item['name']) && trim($item['name']) !== '') {
     936                    $translated = self::maybeTranslateMeta($item['name'], $default, $lang);
     937                    if (is_string($translated) && $translated !== '' && $translated !== $item['name']) {
     938                        $item['name'] = $translated;
     939                        $changed = true;
     940                    }
     941                }
     942            }
     943            unset($item);
     944        }
     945
     946        // Handle @graph arrays (used by Yoast and others)
     947        if (isset($node['@graph']) && is_array($node['@graph'])) {
     948            foreach ($node['@graph'] as &$graphNode) {
     949                if (is_array($graphNode)) {
     950                    if (self::translateJsonLdNode($graphNode, $default, $lang)) {
     951                        $changed = true;
     952                    }
     953                }
     954            }
     955            unset($graphNode);
     956        }        return $changed;
     957    }
    845958}
    846 
    847 
  • ai-translate/trunk/includes/class-ai-sitemap.php

    r3455468 r3461910  
    4040    public static function register_providers()
    4141    {
    42         $provider = new AI_Sitemap_Provider_Languages();
    43         wp_register_sitemap_provider('languages', $provider);
     42        // Language homepages (/{lang}/)
     43        wp_register_sitemap_provider('languages', new AI_Sitemap_Provider_Languages());
     44
     45        // Translated posts/pages/CPT (/{lang}/{translated-slug}/)
     46        wp_register_sitemap_provider('ai-translate-posts', new AI_Sitemap_Provider_Translated());
    4447    }
    4548
     
    8790
    8891    // ──────────────────────────────────────────────
     92    //  Translated post/page/CPT entries
     93    // ──────────────────────────────────────────────
     94
     95    /**
     96     * Get translated post entries for a specific language.
     97     *
     98     * Only includes posts that already have a translated slug in the slugs table.
     99     * Does NOT trigger API calls.
     100     *
     101     * @param string $lang   Language code (e.g. 'de', 'it').
     102     * @param int    $limit  Max entries to return.
     103     * @param int    $offset Offset for pagination.
     104     * @return array[] Each entry has 'loc' (string) and optional 'lastmod' (string ISO 8601).
     105     */
     106    public static function get_translated_entries($lang, $limit = 2000, $offset = 0)
     107    {
     108        global $wpdb;
     109        $table    = $wpdb->prefix . 'ai_translate_slugs';
     110        $col_lang = self::slug_lang_column();
     111
     112        $public_types = get_post_types(['public' => true], 'names');
     113        $public_types = array_diff($public_types, ['attachment']);
     114        if (empty($public_types)) {
     115            $public_types = ['post', 'page'];
     116        }
     117        $placeholders = implode(', ', array_fill(0, count($public_types), '%s'));
     118
     119        $rows = $wpdb->get_results($wpdb->prepare(
     120            "SELECT s.translated_slug, p.post_modified_gmt
     121             FROM `{$table}` s
     122             JOIN `{$wpdb->posts}` p ON p.ID = s.post_id
     123             WHERE s.`{$col_lang}` = %s
     124               AND p.post_status = 'publish'
     125               AND p.post_type IN ({$placeholders})
     126               AND s.translated_slug != ''
     127             ORDER BY s.post_id ASC
     128             LIMIT %d OFFSET %d",
     129            array_merge([$lang], array_values($public_types), [$limit, $offset])
     130        ), ARRAY_A);
     131
     132        if (!is_array($rows) || empty($rows)) {
     133            return [];
     134        }
     135
     136        $home = rtrim(home_url('/'), '/');
     137        $entries = [];
     138        foreach ($rows as $row) {
     139            $slug = ltrim($row['translated_slug'], '/');
     140            if ($slug === '') {
     141                continue;
     142            }
     143            $entry = [
     144                'loc' => $home . '/' . $lang . '/' . $slug . '/',
     145            ];
     146            if (!empty($row['post_modified_gmt']) && $row['post_modified_gmt'] !== '0000-00-00 00:00:00') {
     147                $entry['lastmod'] = strtotime($row['post_modified_gmt']);
     148            }
     149            $entries[] = $entry;
     150        }
     151
     152        return $entries;
     153    }
     154
     155    /**
     156     * Count translated post entries for a specific language.
     157     *
     158     * @param string $lang Language code.
     159     * @return int
     160     */
     161    public static function count_translated_entries($lang)
     162    {
     163        global $wpdb;
     164        $table    = $wpdb->prefix . 'ai_translate_slugs';
     165        $col_lang = self::slug_lang_column();
     166
     167        $public_types = get_post_types(['public' => true], 'names');
     168        $public_types = array_diff($public_types, ['attachment']);
     169        if (empty($public_types)) {
     170            $public_types = ['post', 'page'];
     171        }
     172        $placeholders = implode(', ', array_fill(0, count($public_types), '%s'));
     173
     174        return (int) $wpdb->get_var($wpdb->prepare(
     175            "SELECT COUNT(*)
     176             FROM `{$table}` s
     177             JOIN `{$wpdb->posts}` p ON p.ID = s.post_id
     178             WHERE s.`{$col_lang}` = %s
     179               AND p.post_status = 'publish'
     180               AND p.post_type IN ({$placeholders})
     181               AND s.translated_slug != ''",
     182            array_merge([$lang], array_values($public_types))
     183        ));
     184    }
     185
     186    /**
     187     * Get non-default languages that have at least one translated entry.
     188     *
     189     * @return string[]
     190     */
     191    public static function get_sitemap_languages()
     192    {
     193        $default    = AI_Lang::default();
     194        $enabled    = AI_Lang::enabled();
     195        $detectable = AI_Lang::detectable();
     196        $langs      = array_values(array_unique(array_merge($enabled, $detectable)));
     197
     198        $result = [];
     199        foreach ($langs as $lang) {
     200            $lang = strtolower((string) $lang);
     201            if ($default !== null && $lang === strtolower((string) $default)) {
     202                continue;
     203            }
     204            $result[] = $lang;
     205        }
     206        return $result;
     207    }
     208
     209    /**
     210     * Detect slug table language column name.
     211     *
     212     * Uses the transient set by AI_Slugs::detect_schema() to avoid extra SHOW COLUMNS queries.
     213     *
     214     * @return string Column name ('lang' or 'language_code').
     215     */
     216    private static function slug_lang_column()
     217    {
     218        $schema = get_transient('ai_tr_slugs_schema');
     219        return ($schema === 'original') ? 'language_code' : 'lang';
     220    }
     221
     222    // ──────────────────────────────────────────────
    89223    //  Google XML Sitemaps plugin integration
    90224    // ──────────────────────────────────────────────
    91225
    92226    /**
    93      * Hook: sm_build_index – add our sitemap to the Google XML Sitemaps index.
     227     * Hook: sm_build_index – add our sitemaps to the Google XML Sitemaps index.
    94228     *
    95229     * @param \GoogleSitemapGenerator $gsg
     
    97231    public static function gsm_build_index($gsg)
    98232    {
    99         $entries = self::get_language_entries();
    100         if (empty($entries)) {
    101             return;
    102         }
    103 
    104         $lastmod = 0;
    105         foreach ($entries as $e) {
    106             if (!empty($e['lastmod']) && $e['lastmod'] > $lastmod) {
    107                 $lastmod = $e['lastmod'];
    108             }
    109         }
    110 
    111         $gsg->add_sitemap('languages-sitemap', null, $lastmod);
    112     }
    113 
    114     /**
    115      * Hook: sm_build_content – output language homepage URLs when our type is requested.
    116      * Google XML Sitemaps routes languages-sitemap.xml as type='pt', params='languages-p...'
     233        // Language homepages
     234        $lang_entries = self::get_language_entries();
     235        if (!empty($lang_entries)) {
     236            $lastmod = 0;
     237            foreach ($lang_entries as $e) {
     238                if (!empty($e['lastmod']) && $e['lastmod'] > $lastmod) {
     239                    $lastmod = $e['lastmod'];
     240                }
     241            }
     242            $gsg->add_sitemap('languages-sitemap', null, $lastmod);
     243        }
     244
     245        // Translated posts per language
     246        foreach (self::get_sitemap_languages() as $lang) {
     247            $entries = self::get_translated_entries($lang, 1, 0);
     248            if (!empty($entries)) {
     249                $lastmod = !empty($entries[0]['lastmod']) ? $entries[0]['lastmod'] : 0;
     250                $gsg->add_sitemap('translated-' . $lang . '-sitemap', null, $lastmod);
     251            }
     252        }
     253    }
     254
     255    /**
     256     * Hook: sm_build_content – output language homepage + translated post URLs.
    117257     *
    118258     * @param \GoogleSitemapGenerator $gsg
     
    122262    public static function gsm_build_content($gsg, $type, $params)
    123263    {
    124         // The plugin routes unknown sitemap types as pt-{name}-p{page}-...
    125         $is_ours = ($type === 'languages')
     264        // Language homepages sitemap
     265        $is_lang = ($type === 'languages')
    126266            || ($type === 'pt' && is_string($params) && strpos($params, 'languages') === 0);
    127267
    128         if (!$is_ours) {
     268        if ($is_lang) {
     269            foreach (self::get_language_entries() as $e) {
     270                $gsg->add_url($e['loc'], $e['lastmod'], 'weekly', 0.5);
     271            }
    129272            return;
    130273        }
    131274
    132         $entries = self::get_language_entries();
    133         foreach ($entries as $e) {
    134             $gsg->add_url(
    135                 $e['loc'],
    136                 $e['lastmod'],
    137                 'weekly',
    138                 0.5
    139             );
     275        // Translated posts sitemaps: translated-{lang}-sitemap
     276        $is_translated = ($type === 'pt' && is_string($params) && strpos($params, 'translated-') === 0);
     277        if (!$is_translated) {
     278            return;
     279        }
     280
     281        // Extract language code from params: "translated-{lang}-sitemap..."
     282        if (preg_match('/^translated-([a-z]{2,5})-/', $params, $m)) {
     283            $lang    = $m[1];
     284            $entries = self::get_translated_entries($lang, 50000, 0);
     285            foreach ($entries as $e) {
     286                $lastmod = !empty($e['lastmod']) ? $e['lastmod'] : 0;
     287                $gsg->add_url($e['loc'], $lastmod, 'weekly', 0.5);
     288            }
    140289        }
    141290    }
     
    184333    }
    185334}
     335
     336/**
     337 * WP native sitemap provider for translated posts/pages/CPT.
     338 *
     339 * Registers one subtype per non-default language. Each subtype sitemap contains
     340 * all translated posts for that language, using the translated slugs from the
     341 * ai_translate_slugs table.
     342 *
     343 * Sitemap URLs look like: /wp-sitemap-ai-translate-posts-{lang}-{page}.xml
     344 */
     345class AI_Sitemap_Provider_Translated extends \WP_Sitemaps_Provider
     346{
     347    public function __construct()
     348    {
     349        $this->name        = 'ai-translate-posts';
     350        $this->object_type = 'ai-translate-posts';
     351    }
     352
     353    /**
     354     * Return one subtype per non-default language.
     355     *
     356     * @return \stdClass[]
     357     */
     358    public function get_object_subtypes()
     359    {
     360        $subtypes = [];
     361        foreach (AI_Sitemap::get_sitemap_languages() as $lang) {
     362            $obj       = new \stdClass();
     363            $obj->name = $lang;
     364            $subtypes[$lang] = $obj;
     365        }
     366        return $subtypes;
     367    }
     368
     369    /**
     370     * @param int    $page_num
     371     * @param string $object_subtype Language code.
     372     * @return array[]
     373     */
     374    public function get_url_list($page_num, $object_subtype = '')
     375    {
     376        $lang = sanitize_key($object_subtype);
     377        if ($lang === '') {
     378            return [];
     379        }
     380
     381        $max_urls = wp_sitemaps_get_max_urls($this->object_type);
     382        $offset   = ($page_num - 1) * $max_urls;
     383
     384        $entries = AI_Sitemap::get_translated_entries($lang, $max_urls, $offset);
     385
     386        $urls = [];
     387        foreach ($entries as $e) {
     388            $entry = ['loc' => $e['loc']];
     389            if (!empty($e['lastmod'])) {
     390                $entry['lastmod'] = wp_date('c', $e['lastmod']);
     391            }
     392            $urls[] = $entry;
     393        }
     394        return $urls;
     395    }
     396
     397    /**
     398     * @param string $object_subtype Language code.
     399     * @return int
     400     */
     401    public function get_max_num_pages($object_subtype = '')
     402    {
     403        $lang = sanitize_key($object_subtype);
     404        if ($lang === '') {
     405            return 0;
     406        }
     407
     408        $count    = AI_Sitemap::count_translated_entries($lang);
     409        $max_urls = wp_sitemaps_get_max_urls($this->object_type);
     410
     411        return (int) ceil($count / $max_urls);
     412    }
     413}
  • ai-translate/trunk/includes/class-ai-translate-core.php

    r3455695 r3461910  
    351351        // If model is provided, test the API endpoint to ensure model is actually usable
    352352        if ($model !== '') {
     353            // Early guard for model families that are known to be non-chat/non-translation models.
     354            if (preg_match('/(dall-e|whisper|audio|image|realtime|transcribe|tts|embedding|moderation|codex|instruct)/i', $model)) {
     355                throw new \Exception(
     356                    sprintf(
     357                        /* translators: %s is the selected model ID */
     358                        __('Selected model "%s" is not a chat/completions model and cannot be used for translations. Choose a text chat model (for example: gpt-4o-mini, gpt-4.1-mini, deepseek-chat).', 'ai-translate'),
     359                        $model
     360                    )
     361                );
     362            }
     363
    353364            $endpointPath = self::get_model_endpoint_path($model);
    354365            $chatEndpoint = rtrim($base, '/') . $endpointPath;
     
    383394            if ($chatCode !== 200) {
    384395                $chatBodyText = (string) wp_remote_retrieve_body($chatResp);
     396                $chatErrorMessage = '';
     397                $chatErrorData = json_decode($chatBodyText, true);
     398                if (
     399                    is_array($chatErrorData)
     400                    && isset($chatErrorData['error'])
     401                    && is_array($chatErrorData['error'])
     402                    && isset($chatErrorData['error']['message'])
     403                ) {
     404                    $chatErrorMessage = (string) $chatErrorData['error']['message'];
     405                }
     406                $haystack = strtolower($chatErrorMessage . ' ' . $chatBodyText);
     407                $nonChatModelError = (
     408                    strpos($haystack, 'not allowed to sample from this model') !== false
     409                    || strpos($haystack, 'not a chat model') !== false
     410                    || strpos($haystack, 'v1/completions') !== false
     411                    || strpos($haystack, 'completions endpoint') !== false
     412                    || strpos($haystack, 'does not support') !== false
     413                    || strpos($haystack, 'image generation') !== false
     414                    || strpos($haystack, 'embedding') !== false
     415                    || strpos($haystack, 'moderation') !== false
     416                    || strpos($haystack, 'audio') !== false
     417                    || strpos($haystack, 'codex') !== false
     418                );
     419                if ($nonChatModelError) {
     420                    throw new \Exception(
     421                        sprintf(
     422                            /* translators: %s is the selected model ID */
     423                            __('Selected model "%s" is not supported for chat-based translations. Choose a chat/completions model instead.', 'ai-translate'),
     424                            $model
     425                        )
     426                    );
     427                }
    385428                // Auto-detect /responses API requirement and retry once
    386429                if ($chatCode === 404 && self::is_responses_api_error($chatBodyText)) {
Note: See TracChangeset for help on using the changeset viewer.