Plugin Directory


Ignore:
Timestamp:
10/25/2026 06:29:16 AM (3 days ago)
Author:
avakodeforge
Message:

Release 1.3.0 (trunk + tag; see readme changelog)

File:
1 edited

Legend:

Unmodified
Added
Removed
  • langforge/trunk/includes/class-post-translation.php

    r3752345 r3764005  
    1313require_once __DIR__ . '/class-translation-failures.php';
    1414require_once __DIR__ . '/class-part-cache.php';
     15require_once __DIR__ . '/class-ai-budget-gate.php';
     16// The copy of an Elementor tree gets its element IDs there (LANGFORGE_Elementor_Integration::with_fresh_element_ids).
     17require_once __DIR__ . '/class-elementor-integration.php';
    1518
    1619// IDE hints for autoloaded classes
    … …  
    8891        add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
    8992        add_action('save_post', [$this, 'save_post_language'], 10, 2);
     93        // BUG-LANG-GLOSS-02: the classic editor shows the glossary check at the top right after a save.
     94        add_action('admin_notices', [$this, 'maybe_show_glossary_notice']);
    9095
    9196        // Auto-assign default language to posts missing _lf_language
    … …  
    141146        $table = $wpdb->prefix . 'langforge_translations';
    142147
    143         $wpdb->delete($table, ['element_id' => $post_id]); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
     148        // A post is the pair (type, ID): a term with the same number keeps its translations (BUG-LANG-SEO-03).
     149        $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; the condition is a literal from post_row_sql; direct query intended.
     150            "DELETE FROM $table WHERE element_id = %d AND " . self::post_row_sql(), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table name from $wpdb->prefix, value is %-prepared; post_row_sql is a fixed fragment with no input.
     151            (int) $post_id
     152        ));
    144153
    145154        // sibling posts' cached translation arrays listed this
    … …  
    158167            'avl-gutenberg-sidebar',
    159168            LANGFORGE_PLUGIN_URL . 'assets/js/gutenberg-sidebar.js',
    160             ['wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n', 'wp-api-fetch'],
     169            ['wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-notices', 'wp-i18n', 'wp-api-fetch'],
    161170            LANGFORGE_VERSION,
    162171            true
    … …  
    168177            'ajaxUrl' => admin_url('admin-ajax.php'),
    169178            'nonce' => wp_create_nonce('langforge_admin_nonce'),
     179            // BUG-LANG-GLOSS-02: the heading of the notice shown after a save (the same string as the heading of the
     180            // Language box, so it has one translation). Lang does not load script translations, so the text goes
     181            // over from here, in the language of the admin.
     182            'glossaryHeading' => __('Glossary: this translation does not follow the glossary.', 'langforge'),
    170183        ]);
    171184    }
    … …  
    218231            return null; // untracked, or already the language being viewed
    219232        }
     233        if ($this->request_is_short_address_of($post_id)) {
     234            return null; // /?p=<id>: the address names no language, the post names its own
     235        }
    220236
    221237        $translation = $this->get_translation($post_id, $current);
    … …  
    232248            $this->localizing_permalink = false;
    233249        }
     250    }
     251
     252    /**
     253 * Is the request being served the numeric address of this very post (/?p=<id>, /?page_id=<id>,
     254 * /?attachment_id=<id>) with no language in it?
     255 *
     256 * The short address of a translation is what WordPress itself publishes for it (wp_get_shortlink,
     257 * <link rel="shortlink">). It names no language, so the language of the request is the default one, and
     258 * core's redirect_canonical asks get_permalink of the post for the address to send the visitor to:
     259 * the remap of localize_permalink_to_current answered the twin in the default language, and a shared
     260 * short link of a Spanish page opened the English one (BUG-LANG-SUITE-02). With no language in the
     261 * address the language is the post's own, so the post is not remapped. An address that does name a
     262 * language (/en/?p=<es id>) is the visitor's choice and is served as it was.
     263 *
     264 * Only the post the address names is spared: a link to any other post of another language, the
     265 * menu of the same page included, is still remapped to the language being viewed.
     266 *
     267 * @param int $post_id
     268 * @return bool
     269 */
     270    private function request_is_short_address_of($post_id) {
     271        $post_id = (int) $post_id;
     272        if ($post_id <= 0) {
     273            return false;
     274        }
     275        $named = false;
     276        foreach (['p', 'page_id', 'attachment_id'] as $var) {
     277            if ((int) get_query_var($var) === $post_id) {
     278                $named = true;
     279                break;
     280            }
     281        }
     282        return $named && !LANGFORGE_Core::instance()->language_known_from_start();
    234283    }
    235284
    … …  
    280329        $hide_default = get_option('langforge_hide_default_language', false);
    281330
    282         // Don't modify URL for default language if hide option is enabled
    283         if ($hide_default && $post_lang === $default_lang) {
    284             return $permalink;
    285         }
    286 
    287331        // Check if this post is a translation of the Front Page (fix)
    288332        $front_page_id = (int) get_option('page_on_front');
    … …  
    295339        }
    296340
     341        // Don't modify URL for default language if hide option is enabled. The one page this does not hold for
     342        // is the front page: WordPress gives it the address of the site root only while page_on_front is that
     343        // very page, and on a page of another language page_on_front is its translation here (the option filter
     344        // translate_front_page_id), so the core permalink of the default-language front page is its slug
     345        // (/home/), a second address of the home page. Its address is the home of its language: the site root
     346        // (BUG-LANG-SEO-01). The same address for hreflang, canonical and the language switcher.
     347        if ($hide_default && $post_lang === $default_lang) {
     348            return $is_front_page_translation ? LANGFORGE_Core::instance()->get_raw_home_url('/') : $permalink;
     349        }
     350
    297351        // Always the *unlocalized* site root: on an /es/ page home_url('/')
    298352        // returns `.../es/`, which no default-language permalink starts with —
    … …  
    300354        // never be applied to any link rendered on a non-default page.
    301355        $home_url = LANGFORGE_Core::instance()->get_raw_home_url('/');
     356
     357        // Subdomain and domain formats: a post lives on the host of its own language (es.<host>, the
     358        // domain mapped to es); the translation of the front page is the home of its language. The host
     359        // comes from the one rule of the plugin, LANGFORGE_Core::host_url_for_language, the same that
     360        // the switcher and the URL localizer use. The path is the plain permalink: the language is in the host.
     361        if ($url_format === 'subdomain' || $url_format === 'domain') {
     362            return LANGFORGE_Core::instance()->host_url_for_language($is_front_page_translation ? $home_url : $permalink, $post_lang);
     363        }
    302364
    303365        if ($url_format === 'parameter') {
    … …  
    639701                </div>
    640702                <?php endif; ?>
     703
     704                <?php
     705                // BUG-LANG-GLOSS-02: a translation typed by hand is checked against the glossary; it only warns.
     706                if (!$is_original && $source_post_id) {
     707                    echo $this->glossary_warnings_box($this->glossary_warnings($post->ID)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in glossary_warnings_inner.
     708                }
     709                ?>
    641710
    642711                <?php
    … …  
    749818                </ul>
    750819            </div>
     820            <?php
     821            /**
     822 * Fires in the Language box, after the list of translations.
     823 *
     824 * Add-ons hang their markup here: the hint of a WooCommerce product, the Live Preview buttons of Lang Forge Pro.
     825 * The callbacks print their own (escaped) HTML.
     826 *
     827 * @param WP_Post $post The post being edited.
     828 */
     829            do_action('langforge_meta_box_after_translations', $post);
     830            ?>
    751831        </div>
    752832
    … …  
    9941074        }
    9951075        $label = __('Retry AI translation', 'langforge');
    996         $why   = LANGFORGE_Translation_Failures::reason_text($failure['code']);
     1076        $why   = LANGFORGE_Translation_Failures::reason_text($failure['code'], !empty($failure['trial']));
    9971077        $title = $compact ? $label . ' — ' . $why : $why;
    9981078
    … …  
    10391119                if ($failure) {
    10401120                    // A draft kept after a failed machine translation: still a link to it, with the reason on its mark.
    1041                     $title .= ' — ' . LANGFORGE_Translation_Failures::reason_text($failure['code']);
     1121                    $title .= ' — ' . LANGFORGE_Translation_Failures::reason_text($failure['code'], !empty($failure['trial']));
    10421122                }
    1043                 echo wp_kses_post(self::edit_link_html($translations[$code], esc_html(strtoupper($code)) . ($failure ? LANGFORGE_Translation_Failures::mark_html($failure['code'], true) : ''), [
     1123                echo wp_kses_post(self::edit_link_html($translations[$code], esc_html(strtoupper($code)) . ($failure ? LANGFORGE_Translation_Failures::mark_html($failure['code'], true, !empty($failure['trial'])) : ''), [
    10441124                    'class' => 'lf-col-trans lf-col-trans--exists' . ($failure ? ' lf-col-trans--failed' : ''),
    10451125                    'title' => $title,
    … …  
    10521132                // Not translated, and it is known why: a mark with the reason, not a link. There is nothing to open;
    10531133                // the create button of the Translations box of the post makes the draft.
    1054                 $why = LANGFORGE_Translation_Failures::reason_text_without_draft($failures[$code]['code']);
    1055                 echo '<span class="lf-col-trans lf-col-trans--failed" title="' . esc_attr($info['native_name'] . ': ' . $why) . '">' . esc_html(strtoupper($code)) . wp_kses_post(LANGFORGE_Translation_Failures::mark_html($failures[$code]['code'])) . '</span>';
     1134                $why = LANGFORGE_Translation_Failures::reason_text_without_draft($failures[$code]['code'], !empty($failures[$code]['trial']));
     1135                echo '<span class="lf-col-trans lf-col-trans--failed" title="' . esc_attr($info['native_name'] . ': ' . $why) . '">' . esc_html(strtoupper($code)) . wp_kses_post(LANGFORGE_Translation_Failures::mark_html($failures[$code]['code'], false, !empty($failures[$code]['trial']))) . '</span>';
    10561136            } else {
    10571137                $nonce = wp_create_nonce('langforge_admin_nonce');
    … …  
    11991279        // WHERE translation_group IN) with one round-trip, halving the preload
    12001280        // cost for both preload_visible_translations and preload_menu_translations.
    1201         // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval.
     1281        // The table holds posts AND terms under one unique key (element_id, element_type, language_code), so a post
     1282        // is the pair (type, ID): both sides of the join are post rows, or a post whose number equals the ID of a
     1283        // translated category would take that category's group (BUG-LANG-SEO-03).
     1284        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval; post_row_sql is a fixed fragment with no input.
    12021285        $all_translations = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; integer IDs cast via intval.
    12031286            "SELECT t2.element_id, t2.language_code, t2.translation_group
    12041287             FROM $table t2
    12051288             INNER JOIN $table t1 ON t1.translation_group = t2.translation_group
    1206              WHERE t1.element_id IN ($ids_str)"
     1289             WHERE t1.element_id IN ($ids_str)
     1290               AND " . self::post_row_sql('t1') . "
     1291               AND " . self::post_row_sql('t2')
    12071292        );
    1208         // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     1293        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
    12091294
    12101295        if (empty($all_translations)) {
    … …  
    13011386        // Get all translations in this group regardless of element_type
    13021387        $results = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
    1303             "SELECT element_id, language_code FROM $table WHERE translation_group = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, value is %-prepared.
     1388            "SELECT element_id, language_code FROM $table WHERE translation_group = %d AND " . self::post_row_sql(), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table name from $wpdb->prefix, value is %-prepared; post_row_sql is a fixed fragment with no input.
    13041389            $group
    13051390        ), ARRAY_A);
    … …  
    16321717
    16331718    /**
     1719 * How a term row of wp_langforge_translations is told from a post row: the element_type of a term is
     1720 * "tax_<taxonomy>" (LANGFORGE_Taxonomy_Translation), the element_type of a post is its post type.
     1721 */
     1722    const TERM_ELEMENT_PREFIX = 'tax_';
     1723
     1724    /**
     1725 * SQL condition: the row is a post's, not a term's.
     1726 *
     1727 * The table keeps posts and terms together, and its unique key is (element_id, element_type,
     1728 * language_code): post 7 and the category with term ID 7 are different elements. Every lookup that
     1729 * starts from a post ID, and every join of the table on a post ID, has to carry this condition, or it
     1730 * reads the translations of a term that has the same number (BUG-LANG-SEO-03). It has no "%" and no
     1731 * backslash, so it can sit inside a string given to $wpdb->prepare.
     1732 *
     1733 * @param string $alias Alias of the table in the statement; empty for none.
     1734 * @return string e.g. "t1.element_type" -> "LEFT(t1.element_type, 4) <> 'tax_'".
     1735 */
     1736    public static function post_row_sql($alias = '') {
     1737        $column = ($alias === '' ? '' : $alias . '.') . 'element_type';
     1738        return 'LEFT(' . $column . ', ' . strlen(self::TERM_ELEMENT_PREFIX) . ") <> '" . self::TERM_ELEMENT_PREFIX . "'";
     1739    }
     1740
     1741    /**
    16341742 * Is this WPML element type a taxonomy term (category, post_tag, a custom
    16351743 * taxonomy, "tax_*", a nav menu) rather than a post?
    … …  
    16461754    }
    16471755
     1756    /**
     1757 * The translation of a term in a language, from the term pairs of Lang (LANGFORGE_Taxonomy_Translation).
     1758 *
     1759 * The pairs of terms are kept in wp_langforge_translations under the element_type "tax_<taxonomy>", apart from
     1760 * the posts (post_row_sql), so the lookup is strictly by taxonomy: the same number under another taxonomy, or a
     1761 * post with that number, is another element and is never answered (the "wrong translation" the WPML shim once
     1762 * read from a table that is not told by type).
     1763 *
     1764 * @param int $term_id Term ID.
     1765 * @param string $element_type A taxonomy ("category") or its WPML element type ("tax_category").
     1766 * @param string|null $language Language code; the current one when empty.
     1767 * @return int|null The ID of the term in that language, or null when there is none.
     1768 */
     1769    public function get_term_translation_id($term_id, $element_type, $language = null) {
     1770        if (!$language) {
     1771            $language = LANGFORGE_Core::instance()->get_current_language();
     1772        }
     1773        $taxonomy = (strpos($element_type, self::TERM_ELEMENT_PREFIX) === 0)
     1774            ? substr($element_type, strlen(self::TERM_ELEMENT_PREFIX))
     1775            : $element_type;
     1776        if ($taxonomy === '') {
     1777            return null;
     1778        }
     1779        $found = LANGFORGE_Taxonomy_Translation::instance()->get_term_translation((int) $term_id, $taxonomy, $language);
     1780        return $found ? (int) $found : null;
     1781    }
     1782
    16481783    public function get_translation_id($element_id, $element_type = 'post', $language = null, $return_original = false) {
    16491784        if (!$language) {
    16501785            $language = LANGFORGE_Core::instance()->get_current_language();
    16511786        }
    1652        
     1787
     1788        // A term is not a post: its pairs are the term pairs, not the group of the post that has its number.
     1789        // 'post' is the default of the callers and never names a taxonomy: the common case asks nothing.
     1790        if ($element_type !== 'post' && self::is_term_element_type($element_type)) {
     1791            $term_translation = $this->get_term_translation_id($element_id, $element_type, $language);
     1792            if ($term_translation !== null) {
     1793                return $term_translation;
     1794            }
     1795            return $return_original ? $element_id : null;
     1796        }
     1797
    16531798        $translations = $this->get_translations($element_id);
    16541799       
    … …  
    17321877        $group2 = $this->get_translation_group($post_id_2);
    17331878
    1734         if ($group1 && $group2 && $group1 !== $group2) {
     1879        if ($group1 && $group2 && (int) $group1 === (int) $group2) {
     1880            // BUG-LANG-CLI-03: already translations of each other. Nothing to write: falling through to the
     1881            // "no group on either post" branch made a second group and a second row for each post.
     1882            // The cache drop and the failure log below still run, as after any link.
     1883        } elseif ($group1 && $group2 && $group1 !== $group2) {
    17351884            // Merge groups - update all items in group2 to group1
    17361885            $wpdb->update( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; direct query intended.
    … …  
    19692118    }
    19702119
     2120    /** Meta key of the map "element ID in the original -> element ID in the copy" of a translation's Elementor tree. */
     2121    const ELEMENTOR_ID_MAP_META = '_langforge_elementor_id_map';
     2122
     2123    /**
     2124 * The map "ID of an element of $source -> ID of the element at the same place in $copy" for two Elementor trees
     2125 * of the same shape (the copy is the original with fresh IDs). Trees are decoded `_elementor_data`.
     2126 *
     2127 * @param mixed $source Tree of the original.
     2128 * @param mixed $copy Tree of the copy.
     2129 * @return array<string,string>
     2130 */
     2131    public static function elementor_id_map($source, $copy): array {
     2132        $map  = [];
     2133        $walk = function ($from, $to) use (&$walk, &$map) {
     2134            if (!is_array($from) || !is_array($to)) {
     2135                return;
     2136            }
     2137            foreach ($from as $i => $element) {
     2138                $twin = $to[$i] ?? null;
     2139                if (!is_array($element) || !is_array($twin)) {
     2140                    continue;
     2141                }
     2142                if (isset($element['id'], $twin['id']) && is_scalar($element['id']) && is_scalar($twin['id'])) {
     2143                    $map[(string) $element['id']] = (string) $twin['id'];
     2144                }
     2145                $walk($element['elements'] ?? null, $twin['elements'] ?? null);
     2146            }
     2147        };
     2148        $walk($source, $copy);
     2149        return $map;
     2150    }
     2151
     2152    /**
     2153 * Write the map of the Elementor element IDs next to the copy of the tree (BUG-LANG-VEDIT-01): the visual
     2154 * editor names a text by the original's element IDs and finds the element of the translation by this map. Called
     2155 * wherever the copy of `_elementor_data` is (re)written, so the map is always the one of the tree that is there.
     2156 *
     2157 * @param int $translation_id The translation.
     2158 * @param string|array $source `_elementor_data` of the original, as stored or decoded.
     2159 * @param string|array $copy `_elementor_data` written to the translation, as stored or decoded.
     2160 */
     2161    public static function record_elementor_id_map(int $translation_id, $source, $copy): void {
     2162        $source = is_string($source) ? json_decode($source, true) : $source;
     2163        $copy   = is_string($copy) ? json_decode($copy, true) : $copy;
     2164        $map    = self::elementor_id_map($source, $copy);
     2165        if ($map) {
     2166            update_post_meta($translation_id, self::ELEMENTOR_ID_MAP_META, wp_slash(wp_json_encode($map)));
     2167        } else {
     2168            delete_post_meta($translation_id, self::ELEMENTOR_ID_MAP_META);
     2169        }
     2170    }
     2171
    19712172    /**
    19722173 * Create a translation draft of $post_id in $target_lang.
    … …  
    20172218        $new_post_data['post_name'] = $original_post->post_name;
    20182219       
    2019         $new_post_id = wp_insert_post($new_post_data);
     2220        // wp_insert_post takes slashed data (it unslashes what it is given), and get_post hands the text out unslashed: a
     2221        // backslash of the source (a path, a pattern, LaTeX) is kept only if the data is slashed on the way in.
     2222        $new_post_id = wp_insert_post(wp_slash($new_post_data));
    20202223       
    20212224        if (is_wp_error($new_post_id)) {
    … …  
    20492252            '_langforge_language', '_langforge_source_snapshot', '_langforge_needs_update',
    20502253            '_pingme', '_encloseme',
    2051             '_elementor_data',
     2254            '_elementor_data', self::ELEMENTOR_ID_MAP_META,
    20522255        ],
    20532256        // What belongs to the source page alone (the canonical URL typed in for it, ...). The
    … …  
    20772280
    20782281            // Regenerate unique element IDs to prevent duplicates
    2079             $data = is_string($elementor_data) ? $elementor_data : wp_json_encode($elementor_data, JSON_UNESCAPED_UNICODE);
    2080             $data = preg_replace_callback('/"id"\s*:\s*"([a-f0-9]+)"/', function () {
    2081                 return '"id":"' . dechex(random_int(0x1000000, 0xFFFFFFF)) . '"';
    2082             }, $data);
     2282            $data = LANGFORGE_Elementor_Integration::with_fresh_element_ids($elementor_data);
    20832283            update_post_meta($new_post_id, '_elementor_data', wp_slash($data));
    20842284            delete_post_meta($new_post_id, '_elementor_element_cache');
     2285            // The visual editor finds the element of this copy by the original's element ID through this map.
     2286            self::record_elementor_id_map((int) $new_post_id, $elementor_data, $data);
    20852287
    20862288            // Copy Elementor CSS cache settings so styles render
    … …  
    21772379
    21782380    /**
     2381 * The glossary check of a translation as it is saved (BUG-LANG-GLOSS-02).
     2382 *
     2383 * The glossary promises that a term has one translation across every translated page, and the machine
     2384 * translation is told so (get_payload_for_translate). A translation typed by hand was never looked at. This
     2385 * reads the saved text of a translation (title, excerpt, content, without tags) against the text of its source
     2386 * and returns one warning for each glossary term the source has and the translation does not follow
     2387 * (LANGFORGE_Glossary::validate_translation). It only reports: the translation is saved whatever it says.
     2388 *
     2389 * Nothing is reported for a post that is not a translation, for a translation that has no text yet, and for one
     2390 * whose text is still the copy of its source (it is not translated yet: every term would be named).
     2391 *
     2392 * @param int $post_id The translation.
     2393 * @return string
     2394 */
     2395    public function glossary_warnings($post_id) {
     2396        $post_id = (int) $post_id;
     2397        if ($post_id <= 0 || !class_exists('LANGFORGE_Glossary')) {
     2398            return [];
     2399        }
     2400        $source_id = $this->get_source_post_id($post_id);
     2401        if ($source_id <= 0) {
     2402            return [];
     2403        }
     2404        $source_lang = LANGFORGE_Core::instance()->get_default_language();
     2405        $target_lang = (string) get_post_meta($post_id, '_langforge_language', true);
     2406        if ($target_lang === '' || $target_lang === $source_lang) {
     2407            return [];
     2408        }
     2409        $own    = $this->glossary_text($post_id);
     2410        $source = $this->glossary_text($source_id);
     2411        if ($own === '' || $own === $source) {
     2412            return [];
     2413        }
     2414        return LANGFORGE_Glossary::instance()->validate_translation($source, $own, $source_lang, $target_lang);
     2415    }
     2416
     2417    /**
     2418 * The words of a post for the glossary check: title, excerpt and content, tags stripped and entities read as the
     2419 * characters they stand for (the block editor stores "Fish & Chips" as "Fish &amp; Chips", the glossary term is
     2420 * typed with "&": without this a term with a sign of that kind is never found or is reported as missing).
     2421 */
     2422    private function glossary_text($post_id) {
     2423        $post = get_post((int) $post_id);
     2424        if (!is_object($post)) {
     2425            return '';
     2426        }
     2427        $parts = [];
     2428        foreach (['post_title', 'post_excerpt', 'post_content'] as $field) {
     2429            $text = isset($post->$field) ? trim(html_entity_decode(wp_strip_all_tags((string) $post->$field), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : '';
     2430            if ($text !== '') {
     2431                $parts[] = $text;
     2432            }
     2433        }
     2434        return implode("\n", $parts);
     2435    }
     2436
     2437    /** The heading and the list of the warnings (escaped), for the box of the Language metabox and for the notice. */
     2438    private function glossary_warnings_inner(array $warnings) {
     2439        $items = '';
     2440        foreach ($warnings as $warning) {
     2441            $items .= '<li>' . esc_html($warning) . '</li>';
     2442        }
     2443        return '<p style="margin:0;"><strong>' . esc_html__('Glossary: this translation does not follow the glossary.', 'langforge') . '</strong></p>'
     2444            . '<ul style="margin:6px 0 0 18px;list-style:disc;">' . $items . '</ul>';
     2445    }
     2446
     2447    /** The warning box inside the Language metabox. Empty when there is nothing to say. */
     2448    private function glossary_warnings_box(array $warnings) {
     2449        if (!$warnings) {
     2450            return '';
     2451        }
     2452        return '<div class="lf-glossary-warning" role="alert" style="margin:8px 0;padding:8px 10px;border-left:4px solid #dba617;background:#fff8e5;">'
     2453            . $this->glossary_warnings_inner($warnings) . '</div>';
     2454    }
     2455
     2456    /**
     2457 * admin_notices: in the classic editor, the glossary check of the translation just saved, at the top of the
     2458 * screen. The block editor does not reload and does not read the Language box again after a save: there, the
     2459 * script of the sidebar (assets/js/gutenberg-sidebar.js) reads the REST field `langforge_glossary_warnings` of the
     2460 * saved post and shows the same list as a notice of the editor.
     2461 */
     2462    public function maybe_show_glossary_notice() {
     2463        if (!function_exists('get_current_screen')) {
     2464            return;
     2465        }
     2466        $screen = get_current_screen();
     2467        if (!is_object($screen) || $screen->base !== 'post') {
     2468            return;
     2469        }
     2470        if (method_exists($screen, 'is_block_editor') && $screen->is_block_editor()) {
     2471            return;
     2472        }
     2473        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only: whether the screen was reached by a save.
     2474        if (!isset($_GET['message'], $_GET['post'])) {
     2475            return;
     2476        }
     2477        $post_id = absint(wp_unslash($_GET['post']));
     2478        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     2479        if ($post_id <= 0 || !current_user_can('edit_post', $post_id)) {
     2480            return;
     2481        }
     2482        $warnings = $this->glossary_warnings($post_id);
     2483        if (!$warnings) {
     2484            return;
     2485        }
     2486        echo '<div class="notice notice-warning">' . $this->glossary_warnings_inner($warnings) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in glossary_warnings_inner.
     2487    }
     2488
     2489    /**
    21792490 * PUBLIC API — listener for do_action('langforge/translate_post', int $post_id, array $context).
    21802491 *
    … …  
    22462557        }
    22472558
    2248         $queued = 0;
     2559        // One reading of the clock for the whole run: the step between two events is the stagger and nothing
     2560        // else, however long creating a draft takes (a clock read per event made it one second longer
     2561        // whenever the run crossed a second).
     2562        $run_started = time();
     2563        $queued      = 0;
    22492564        foreach (array_keys((array) $core->get_active_languages()) as $target_lang) {
    22502565            $target_lang = (string) $target_lang;
    … …  
    22632578            }
    22642579
    2265             if ($this->queue_machine_translation($post_id, (int) $target_id, $source_lang, $target_lang, $queued)) {
     2580            if ($this->queue_machine_translation($post_id, (int) $target_id, $source_lang, $target_lang, $queued, $run_started)) {
    22662581                // The job may throw this draft away if it cannot translate it, but only while the draft is still as it is now.
    22672582                LANGFORGE_Translation_Failures::stamp_stub((int) $target_id);
    … …  
    22882603 * @param string $target_lang
    22892604 * @param int $index Position in this run, used for the stagger.
     2605 * @param int $run_started Time the run began (Unix); the stagger counts from it. 0 = now.
    22902606 * @return bool Whether an event was scheduled.
    22912607 */
    2292     protected function queue_machine_translation($source_id, $target_id, $source_lang, $target_lang, $index = 0) {
     2608    protected function queue_machine_translation($source_id, $target_id, $source_lang, $target_lang, $index = 0, $run_started = 0) {
    22932609        $source_id   = (int) $source_id;
    22942610        $target_id   = (int) $target_id;
    … …  
    23092625        }
    23102626
    2311         $timestamp = time() + self::MT_QUEUE_DELAY + ($index * self::MT_QUEUE_STAGGER);
     2627        $timestamp = ((int) $run_started > 0 ? (int) $run_started : time()) + self::MT_QUEUE_DELAY + ($index * self::MT_QUEUE_STAGGER);
    23122628
    23132629        $scheduled = wp_schedule_single_event(
    … …  
    26542970    /** The answer of the handler when the credits ran out: the one the button turns into the checkout offer. */
    26552971    private static function refuse_exhausted_credits($result, $created_id = 0) {
     2972        $err_data = $result->get_error_data();
    26562973        if ($created_id) {
    2657             LANGFORGE_Translation_Failures::flag_created($created_id, LANGFORGE_Translation_Failures::CODE_CREDITS);
    2658         }
    2659         $err_data = $result->get_error_data();
     2974            LANGFORGE_Translation_Failures::flag_created($created_id, LANGFORGE_Translation_Failures::CODE_CREDITS, is_array($err_data) ? $err_data : null);
     2975        }
    26602976        wp_send_json_error([
    26612977            'message'    => $result->get_error_message(),
    26622978            'error'      => 'credits_exhausted',
     2979            // A free trial that cannot pay is told so (the screen does not say "your account has no credits" to it).
     2980            'trial'      => !empty($err_data['trial']),
    26632981            'remaining'  => $err_data['remaining'] ?? 0,
    26642982            'reset_date' => $err_data['reset_date'] ?? '',
    … …  
    26792997    }
    26802998
     2999    /**
     3000 * The parts a body is translated in: one for a short body (the request it always was), the pieces the cutter of the
     3001 * AI button makes of a long one (LANGFORGE_Forge_API::split_content_for_translation: at the end of a block, a line, a
     3002 * row or a sentence), and what stood between two pieces (the translated pieces are put together with it). A client of
     3003 * the service without the cutter (an older Pro add-on whose copy of it is loaded) gets the body whole, as before.
     3004 *
     3005 * @return array|WP_Error ['parts' => string, 'glue' => string], or the error of a body that has no place to cut.
     3006 */
     3007    private static function content_parts($content) {
     3008        if (class_exists('LANGFORGE_Forge_API') && is_callable(['LANGFORGE_Forge_API', 'split_content_for_translation'])) {
     3009            $glue  = [];
     3010            $parts = LANGFORGE_Forge_API::split_content_for_translation($content, $glue);
     3011            if (is_wp_error($parts)) {
     3012                return $parts;
     3013            }
     3014            if (is_array($parts) && $parts) {
     3015                return ['parts' => array_values($parts), 'glue' => is_array($glue) ? array_values($glue) : []];
     3016            }
     3017        }
     3018        return ['parts' => [(string) $content], 'glue' => []];
     3019    }
     3020
     3021    /** The translated parts of a body put together the way the body was written (one line break where the cutter knows no glue). */
     3022    private static function join_content_parts(array $pieces, array $glue) {
     3023        $out = '';
     3024        foreach (array_values($pieces) as $i => $piece) {
     3025            if ($i > 0) {
     3026                $out .= isset($glue[$i - 1]) ? $glue[$i - 1] : "\n";
     3027            }
     3028            $out .= trim((string) $piece);
     3029        }
     3030        return $out;
     3031    }
     3032
     3033    /**
     3034 * Whether the comments of the blocks of the editor are not the same in the text that came back as in the source: the
     3035 * number of <!-- wp:name --> and of <!-- /wp:name --> must be (a body that lost one would be a page the editor cannot open).
     3036 */
     3037    private static function block_comments_differ($source, $translated) {
     3038        foreach (['<!-- wp:', '<!-- /wp:'] as $comment) {
     3039            if (substr_count((string) $source, $comment) !== substr_count((string) $translated, $comment)) {
     3040                return true;
     3041            }
     3042        }
     3043        return false;
     3044    }
     3045
     3046    /** The seconds a request of the AI may take before PHP gives up on the script (the client waits 90 seconds for the service). */
     3047    const AI_REQUEST_TIME_LIMIT = 120;
     3048
     3049    /**
     3050 * Before every request of the AI: the time limit of the script starts again. A long post is a dozen requests one after
     3051 * the other in the same call, and the limit of a host (30 seconds is usual) would stop it in the middle of the job.
     3052 */
     3053    protected function extend_time_limit() {
     3054        if (!function_exists('set_time_limit')) {
     3055            return;
     3056        }
     3057        $disabled = array_map('trim', explode(',', (string) $this->php_disabled_functions()));
     3058        if (in_array('set_time_limit', $disabled, true)) {
     3059            return;
     3060        }
     3061        $this->set_php_time_limit(self::AI_REQUEST_TIME_LIMIT);
     3062    }
     3063
     3064    /** The functions the host switched off (a seam: the setting cannot be changed in a running script). */
     3065    protected function php_disabled_functions() {
     3066        return ini_get('disable_functions');
     3067    }
     3068
     3069    /** set_time_limit (a seam). */
     3070    protected function set_php_time_limit($seconds) {
     3071        // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- a long translation is many requests in one call; checked for availability above
     3072        @set_time_limit($seconds);
     3073    }
     3074
    26813075    /** What the person is told when no text came back: the reason of the service if there is one. */
    26823076    private static function ai_failure_message($ai_error) {
    … …  
    26923086    }
    26933087
     3088    /**
     3089 * A refusal of run_ai_translation: the WP_Error a caller reads, and the answer the button makes of it.
     3090 *
     3091 * @param array|null $ajax What the button answers (wp_send_json_error): ['message' => $message] when null.
     3092 * @param int|null $status The HTTP status the button answers with (null: the default one).
     3093 * @param array|null $credits What the service has charged for this run so far (see credits_since); null: none known.
     3094 */
     3095    private static function run_refusal($code, $message, $ajax = null, $status = null, $credits = null) {
     3096        $data = ['ajax' => $ajax !== null ? $ajax : ['message' => $message]];
     3097        if ($status !== null) {
     3098            $data['status'] = $status;
     3099        }
     3100        if ($credits !== null) {
     3101            $data['credits'] = $credits;
     3102        }
     3103        return new WP_Error($code, $message, $data);
     3104    }
     3105
     3106    /** An error that was made before (a refusal of a caller's check, of the service) with what the run has charged added to its data. */
     3107    private static function with_credits(WP_Error $error, $credits) {
     3108        if ($credits === null) {
     3109            return $error;
     3110        }
     3111        $data = $error->get_error_data();
     3112        $data = is_array($data) ? $data : [];
     3113        $data['credits'] = $credits;
     3114        return new WP_Error($error->get_error_code(), $error->get_error_message(), $data);
     3115    }
     3116
     3117    /** The AI client's running count of what the service charged (null when the client keeps none). */
     3118    private static function credits_snapshot($ai) {
     3119        if (!method_exists($ai, 'credits_report')) {
     3120            return null;
     3121        }
     3122        $report = $ai->credits_report();
     3123        return is_array($report) ? $report : null;
     3124    }
     3125
     3126    /**
     3127 * What the service has charged for the calls since $before: ['charged' => int|null, 'remaining' => int|null].
     3128 * 'charged' is null when a call of the run came back without the figure (it was made and its price is not known).
     3129 */
     3130    private static function credits_since($ai, $before) {
     3131        $after = $before === null ? null : self::credits_snapshot($ai);
     3132        if ($after === null) {
     3133            return null;
     3134        }
     3135        $uncounted = (int) ($after['uncounted'] ?? 0) - (int) ($before['uncounted'] ?? 0);
     3136        return [
     3137            'charged'   => $uncounted > 0 ? null : max(0, (int) ($after['charged'] ?? 0) - (int) ($before['charged'] ?? 0)),
     3138            'remaining' => isset($after['remaining']) && is_numeric($after['remaining']) ? (int) $after['remaining'] : null,
     3139        ];
     3140    }
     3141
     3142    /** The error of a text the service refused for lack of credits, or that came back without its shortcodes: null for any other answer. */
     3143    private static function part_error($result, $created_id, $credits) {
     3144        if (!is_wp_error($result)) {
     3145            return null;
     3146        }
     3147        $code = $result->get_error_code();
     3148        if ($code === 'credits_exhausted') {
     3149            $err_data = $result->get_error_data();
     3150            if ($created_id) {
     3151                LANGFORGE_Translation_Failures::flag_created($created_id, LANGFORGE_Translation_Failures::CODE_CREDITS, is_array($err_data) ? $err_data : null);
     3152            }
     3153            return self::with_credits($result, $credits);
     3154        }
     3155        // The code as a string: the add-on's copy of the client (which has the constant) may not be the one that is loaded.
     3156        if ($code === 'langforge_ai_shortcodes_lost') {
     3157            if ($created_id) {
     3158                LANGFORGE_Translation_Failures::flag_created($created_id, LANGFORGE_Translation_Failures::CODE_SHORTCODES);
     3159            }
     3160            return self::with_credits($result, $credits);
     3161        }
     3162        return null;
     3163    }
     3164
     3165    /** What the button sends when run_ai_translation refused: the answer it always sent for that refusal. */
     3166    private static function answer_run_refusal(WP_Error $error) {
     3167        if ($error->get_error_code() === 'credits_exhausted') {
     3168            // The page "Create + AI translate" made was flagged by the run; the button only sends the answer.
     3169            self::refuse_exhausted_credits($error, 0);
     3170        }
     3171        self::refuse_lost_shortcodes($error, 0);
     3172        $data   = $error->get_error_data();
     3173        $ajax   = is_array($data) && isset($data['ajax']) && is_array($data['ajax']) ? $data['ajax'] : ['message' => $error->get_error_message()];
     3174        $status = is_array($data) && isset($data['status']) ? (int) $data['status'] : null;
     3175        if ($status !== null) {
     3176            wp_send_json_error($ajax, $status);
     3177        }
     3178        wp_send_json_error($ajax);
     3179    }
     3180
     3181    /**
     3182 * The AI Translate button. The nonce, the rights and the reading of the request are its own; the translation is
     3183 * run_ai_translation, the one core every caller translates a post by.
     3184 */
    26943185    public function ajax_ai_translate_post() {
    26953186        check_ajax_referer('langforge_admin_nonce', 'nonce');
    … …  
    27033194        }
    27043195
    2705         // The second step of "Create + AI translate" says so (admin.js). The button then leaves for the edit screen of
     3196        $source_lang = sanitize_text_field(wp_unslash($_POST['source_lang'] ?? ''));
     3197        $target_lang = sanitize_text_field(wp_unslash($_POST['target_lang'] ?? ''));
     3198
     3199        // The second step of "Create + AI translate" says so (admin.js): the run flags the page it made, so that
     3200        // whatever failed is told on its edit screen (see run_ai_translation).
     3201        $result = $this->run_ai_translation($source_id, $target_lang, [
     3202            'target_id'    => $target_id,
     3203            'source_lang'  => $source_lang,
     3204            'after_create' => !empty($_POST['after_create']),
     3205        ]);
     3206        if (is_wp_error($result)) {
     3207            self::answer_run_refusal($result);
     3208            return;
     3209        }
     3210
     3211        wp_send_json_success([
     3212            /* translators: %d: number of fields that were translated. */
     3213            'message'    => sprintf(_n('Translated %d field successfully.', 'Translated %d fields successfully.', (int) $result['translated_count'], 'langforge'), (int) $result['translated_count']),
     3214            'target_id'  => $result['target_id'],
     3215            'edit_url'   => $result['edit_url'],
     3216        ]);
     3217    }
     3218
     3219    /**
     3220 * Translates a post with the AI of Lang Forge into a translation of it: the title, the content and the excerpt
     3221 * one request each, the meta of the post in one batch, the fields of Field Forge, the descriptions of the
     3222 * variations of a WooCommerce product, the SEO meta; with the glossary and the model of the site, the shortcodes
     3223 * kept out of the AI's hands, and what came back kept for an hour (LANGFORGE_Part_Cache) so that a second try pays
     3224 * only for what failed. This is what the AI button of the Translations box does (ajax_ai_translate_post), and
     3225 * nothing else translates a post differently.
     3226 *
     3227 * Rights are the caller's: the method checks none (like create_translation).
     3228 *
     3229 * @param int $source_id The post to translate from.
     3230 * @param string $target_lang The language to translate into.
     3231 * @param array $opts {
     3232 * @type int $target_id The translation to write into. Without it the translation the post has in that
     3233 * language is used.
     3234 * @type string $source_lang The language of the source (default: the language of the post).
     3235 * @type string $mode 'write' (default: as the button, whatever the status of the translation is) or
     3236 * 'draft_only': no translation makes a draft (after the texts came back, so that
     3237 * a refusal leaves nothing behind), a draft or a pending translation is
     3238 * updated, any other status is refused before the AI is asked for anything.
     3239 * @type bool $after_create The translation is the page "Create + AI translate" has just made: a failure
     3240 * is flagged on it, so that its edit screen says why it is not translated.
     3241 * @type callable $verify_before_write function (int $target_id): true|WP_Error, called after the texts came back and
     3242 * before anything is written (the caller's last look; 0 when nothing exists yet).
     3243 * @type int $max_credits The most the run may spend (default: no limit, as the button runs). Before every
     3244 * request (a text, a custom field, a field of Field Forge, a description of a variation,
     3245 * an SEO field) the run checks that what the service has charged so far plus the table
     3246 * price of that request stays within it; when it does not, nothing more is asked, what
     3247 * came back is written, and the answer says which parts were left (see below).
     3248 * A request that is dearer than the table says (a very long text) cannot be known
     3249 * beforehand: the one after it is not started.
     3250 * @type int $part_credits The table price of one request (default 1, the price of the action `translate`).
     3251 * }
     3252 * @return array|WP_Error ['target_id', 'translated_count', 'edit_url', 'created', 'credits' => ['charged', 'remaining']
     3253 * (only from a client that counts), and when max_credits stopped the run 'stopped_at_budget' => true and
     3254 * 'untranslated' => the parts that were left (title, content, excerpt, custom_fields, field_forge_fields,
     3255 * variation_descriptions, seo_title, seo_description)]; or the error (langforge_ai_over_budget: not even the
     3256 * first request fits, nothing was asked or written). The error's data may hold 'ajax' (what the
     3257 * button answers), 'status', and 'credits' (what the service has charged for the run, when the
     3258 * AI was already asked). credits_exhausted and langforge_ai_shortcodes_lost are the errors of the
     3259 * client, as they came.
     3260 */
     3261    public function run_ai_translation(int $source_id, string $target_lang, array $opts = []) {
     3262        $draft_only = isset($opts['mode']) && $opts['mode'] === 'draft_only';
     3263        $target_id  = isset($opts['target_id']) ? (int) $opts['target_id'] : 0;
     3264
     3265        // The second step of "Create + AI translate" says so. The button then leaves for the edit screen of
    27063266        // the page it made, and whatever failed must be told there, once, from here: the screen reads the flag, not the
    27073267        // address (LANGFORGE_Translation_Failures::render_created_notice). Until a good answer takes the flag back,
    … …  
    27093269        // with no answer at all (a fatal error, a timeout) too; a refusal that knows its reason replaces it. The AI
    27103270        // button of a page that already exists does not ask: its person stays on the screen and reads the toast.
    2711         $created_id = !empty($_POST['after_create']) ? $target_id : 0;
     3271        $created_id = !empty($opts['after_create']) ? $target_id : 0;
    27123272        if ($created_id) {
    27133273            LANGFORGE_Translation_Failures::flag_created($created_id, LANGFORGE_Translation_Failures::CREATED_OTHER);
    … …  
    27173277        // class is absent — return a clean error rather than fataling.
    27183278        if (!class_exists('LANGFORGE_AI_Client')) {
    2719             wp_send_json_error(['message' => __('AI translation requires the Lang Forge Pro add-on.', 'langforge')]);
     3279            return self::run_refusal('langforge_ai_unavailable', __('AI translation requires the Lang Forge Pro add-on.', 'langforge'));
    27203280        }
    27213281        $ai = LANGFORGE_AI_Client::instance();
    27223282        if (!$ai->is_configured()) {
    2723             wp_send_json_error(['message' => __('AI translation not configured. Go to Lang Forge → Settings.', 'langforge')]);
     3283            return self::run_refusal('langforge_ai_not_configured', __('AI translation not configured. Go to Lang Forge → Settings.', 'langforge'));
    27243284        }
    27253285        // Enforce it server-side too. is_configured only proves a key is
    … …  
    27283288        // AI credits. The add-on reports the feature off on Free / expired plans.
    27293289        if (!LANGFORGE_Pro_Bridge::instance()->is_enabled('ai_translation')) {
    2730             wp_send_json_error(['message' => __('AI translation is not enabled in Lang Forge Pro on this site.', 'langforge')], 403);
    2731         }
    2732 
    2733         $source_lang = sanitize_text_field(wp_unslash($_POST['source_lang'] ?? ''));
    2734         $target_lang = sanitize_text_field(wp_unslash($_POST['target_lang'] ?? ''));
     3290            return self::run_refusal('langforge_ai_disabled', __('AI translation is not enabled in Lang Forge Pro on this site.', 'langforge'), null, 403);
     3291        }
     3292
     3293        $source_lang = array_key_exists('source_lang', $opts) ? (string) $opts['source_lang'] : (string) $this->get_post_language($source_id);
    27353294
    27363295        if (!$source_lang || !$target_lang) {
    2737             wp_send_json_error(['message' => __('Missing required parameters.', 'langforge')]);
     3296            return self::run_refusal('langforge_ai_missing_params', __('Missing required parameters.', 'langforge'));
    27383297        }
    27393298
    27403299        $source = get_post($source_id);
    2741         $target = get_post($target_id);
    2742         if (!$source || !$target) {
    2743             wp_send_json_error(['message' => __('Post not found.', 'langforge')]);
    2744         }
     3300        if ($target_id <= 0 && $source) {
     3301            // A caller that names no translation means the one the post has (and the button always names one).
     3302            $target_id = $draft_only ? (int) $this->get_translation($source_id, $target_lang) : 0;
     3303            if ($target_id <= 0 && !$draft_only) {
     3304                return self::run_refusal('langforge_ai_no_target', 'The post has no translation to write into in this language: name it with the option target_id.');
     3305            }
     3306        }
     3307        $target = $target_id > 0 ? get_post($target_id) : null;
     3308        if (!$source || ($target_id > 0 && !$target)) {
     3309            if ($draft_only && $source) {
     3310                return self::run_refusal('langforge_ai_target_missing', sprintf('Lang Forge links post %d to a translation (post %d) that no longer exists.', $source_id, $target_id));
     3311            }
     3312            return self::run_refusal('langforge_ai_post_not_found', __('Post not found.', 'langforge'));
     3313        }
     3314
     3315        if ($draft_only) {
     3316            // A draft is all this mode writes into, and it is known before the AI is asked: a refusal here costs nothing.
     3317            $refusal = $this->draft_only_refusal($source_id, $source, $target, $target_id, $target_lang, false);
     3318            if ($refusal === null && trim((string) $source->post_title) === '' && trim((string) $source->post_content) === '' && trim((string) $source->post_excerpt) === '') {
     3319                $refusal = new WP_Error('langforge_ai_nothing_to_translate', sprintf('Post %d has no title, body or excerpt to translate.', $source_id));
     3320            }
     3321            if ($refusal !== null) {
     3322                return $refusal;
     3323            }
     3324        }
     3325
     3326        // The body is cut before the first request: a body with no place to cut cannot go out whole (the service would cut the
     3327        // answer at 4096 tokens and say nothing), and the refusal costs nothing, not even the title.
     3328        $body = !empty($source->post_content) ? self::content_parts($source->post_content) : ['parts' => [], 'glue' => []];
     3329        if (is_wp_error($body)) {
     3330            return self::run_refusal($body->get_error_code(), $body->get_error_message());
     3331        }
     3332
     3333        // What the service charges for this run is what the client counts from here (null: it keeps no count).
     3334        $credits_before = self::credits_snapshot($ai);
     3335        $credits_now    = function () use ($ai, $credits_before) {
     3336            return self::credits_since($ai, $credits_before);
     3337        };
     3338
     3339        // With a budget every request below goes through the gate (it stands in for the client from here on); without one
     3340        // the AI client is asked directly, as it always was. What the service charged is still read from the client itself
     3341        // ($credits_now above holds it).
     3342        $gate = null;
     3343        if (array_key_exists('max_credits', $opts) && $opts['max_credits'] !== null) {
     3344            $gate = new LANGFORGE_AI_Budget_Gate($ai, (int) $opts['max_credits'], isset($opts['part_credits']) ? (int) $opts['part_credits'] : 1, $credits_before);
     3345            $ai   = $gate;
     3346        }
     3347        $begin = function ($part) use ($gate) {
     3348            if ($gate !== null) {
     3349                $gate->begin($part);
     3350            }
     3351        };
     3352        // A request of the AI starts the time limit of the script again (a long post is a dozen of them in one call).
     3353        $extend = function () {
     3354            $this->extend_time_limit();
     3355        };
    27453356
    27463357        $translated_count = 0;
    … …  
    27503361        // The three texts are translated first and written afterwards. A text in which the AI lost or changed a
    27513362        // shortcode comes back as the error LANGFORGE_Forge_API::SHORTCODES_LOST, and that refuses the whole
    2752         // translation (self::refuse_lost_shortcodes): a title in the language of the translation over a content
     3363        // translation (self::part_error): a title in the language of the translation over a content
    27533364        // that is not, or a green "Translated 1 fields successfully" about a page whose form or gallery is broken,
    27543365        // would be worse than telling the person and leaving the translation as it was.
    … …  
    27593370        // request are dropped when the page is written: a person who translates it again wants a new one.
    27603371        $cache_keys = [];
    2761         $translate_part = function ($kind, $text, $context) use ($ai, $source_lang, $target_lang, &$cache_keys) {
     3372        $last_key = null;
     3373        $translate_part = function ($kind, $text, $context) use ($ai, $source_lang, $target_lang, &$cache_keys, &$last_key, $extend) {
     3374            $last_key = null;
    27623375            if (trim((string) $text) === '') {
    27633376                return $ai->translate($text, $source_lang, $target_lang, $context);
    … …  
    27733386            $key   = LANGFORGE_Part_Cache::key($kind, $source_lang, $target_lang, $text, [$model, $glossary]);
    27743387            $cache_keys[] = $key;
     3388            $last_key     = $key;
    27753389            $kept = LANGFORGE_Part_Cache::get($key);
    27763390            if (is_string($kept) && trim($kept) !== '') {
    27773391                return $kept;
    27783392            }
     3393            $extend();
    27793394            $translated = $ai->translate($text, $source_lang, $target_lang, $context);
    27803395            if (is_string($translated) && trim($translated) !== '') {
    … …  
    27843399        };
    27853400
     3401        // A request the budget refused is a stop, not a failure: the part stays as it is (null here), the parts after it
     3402        // are asked in their turn (the gate refuses them too, and a part kept from an earlier try is still free).
    27863403        // Translate title
     3404        $begin(LANGFORGE_AI_Budget_Gate::PART_TITLE);
    27873405        $title_result = $translate_part('title', $source->post_title, 'page/post title');
    2788         if (is_wp_error($title_result) && $title_result->get_error_code() === 'credits_exhausted') {
    2789             self::refuse_exhausted_credits($title_result, $created_id);
    2790         }
    2791         self::refuse_lost_shortcodes($title_result, $created_id);
     3406        $title_held   = LANGFORGE_AI_Budget_Gate::is_denial($title_result);
     3407        if ($title_held) {
     3408            $title_result = null;
     3409        }
     3410        if (($refused = self::part_error($title_result, $created_id, $credits_now())) !== null) {
     3411            return $refused;
     3412        }
    27923413
    27933414        // A part that went out and did not come back whole (an error, nothing, blanks) ends the request: the page
    27943415        // cannot be written half translated, and the texts after it are not paid for.
    2795         $broken = self::ai_part_failed($source->post_title, $title_result) ? $title_result : null;
    2796 
    2797         // Translate content
     3416        $broken = !$title_held && self::ai_part_failed($source->post_title, $title_result) ? $title_result : null;
     3417
     3418        // Translate content. A long body goes out in parts (the service answers with 4096 tokens at most and does not say
     3419        // when it stopped there, so a long text in one request would come back cut and look like a success), every part
     3420        // one request through the same gate; the body is one field of the translation: it is written when all its parts
     3421        // are here, and never half. The parts that did come back are in the cache of the parts for an hour.
    27983422        $content_result = null;
     3423        // How many parts of the body came back, and of how many (the body of one part is the request it always was).
     3424        $content_came  = 0;
     3425        $content_total = 0;
     3426        // The cache keys of the parts of the body, and of those whose own block comments came back changed.
     3427        $content_keys = [];
     3428        $lost_keys    = [];
    27993429        if ($broken === null && !empty($source->post_content)) {
    2800             $content_result = $translate_part('content', $source->post_content, 'blog post/page content with HTML');
    2801             if (is_wp_error($content_result) && $content_result->get_error_code() === 'credits_exhausted') {
    2802                 self::refuse_exhausted_credits($content_result, $created_id);
    2803             }
    2804             self::refuse_lost_shortcodes($content_result, $created_id);
    2805             $broken = self::ai_part_failed($source->post_content, $content_result) ? $content_result : null;
     3430            $begin(LANGFORGE_AI_Budget_Gate::PART_CONTENT);
     3431            $content_parts = $body['parts'];
     3432            $content_total = count($content_parts);
     3433            if ($content_total < 2) {
     3434                $content_result = $translate_part('content', $source->post_content, 'blog post/page content with HTML');
     3435                $content_keys[] = $last_key;
     3436                $content_held   = LANGFORGE_AI_Budget_Gate::is_denial($content_result);
     3437                if ($content_held) {
     3438                    $content_result = null;
     3439                }
     3440                if (($refused = self::part_error($content_result, $created_id, $credits_now())) !== null) {
     3441                    return $refused;
     3442                }
     3443                $broken = !$content_held && self::ai_part_failed($source->post_content, $content_result) ? $content_result : null;
     3444            } else {
     3445                $pieces = [];
     3446                foreach ($content_parts as $i => $content_part) {
     3447                    $piece = $translate_part('chunk', $content_part, sprintf('HTML content part %d of %d. Preserve all HTML tags.', $i + 1, $content_total));
     3448                    $content_keys[] = $last_key;
     3449                    if (LANGFORGE_AI_Budget_Gate::is_denial($piece)) {
     3450                        break;
     3451                    }
     3452                    if (($refused = self::part_error($piece, $created_id, $credits_now())) !== null) {
     3453                        return $refused;
     3454                    }
     3455                    if (self::ai_part_failed($content_part, $piece)) {
     3456                        // The parts after it are not paid for.
     3457                        $broken = $piece === null ? '' : $piece;
     3458                        break;
     3459                    }
     3460                    if (self::block_comments_differ($content_part, $piece)) {
     3461                        $lost_keys[] = $last_key;
     3462                    }
     3463                    $pieces[] = $piece;
     3464                }
     3465                $content_came = count($pieces);
     3466                if ($content_came === $content_total) {
     3467                    $content_result = self::join_content_parts($pieces, $body['glue']);
     3468                }
     3469            }
     3470            // A body that came back with another number of block comments than it went out with is not written: the editor
     3471            // cannot open such a page. What came back stays in the cache of the parts, so that a second try pays only for the
     3472            // texts that are missing, and the texts after the body are not asked.
     3473            if (is_string($content_result) && trim($content_result) !== '' && self::block_comments_differ($source->post_content, $content_result)) {
     3474                // The parts whose comments changed are asked again by a second try (an answer kept would fail the same way for an
     3475                // hour); the others are kept. When no single part changed (a comment moved to the next one), none is trusted.
     3476                LANGFORGE_Part_Cache::forget(array_filter($lost_keys ? $lost_keys : $content_keys));
     3477                $message = __('The translation of this post was not saved: the number of block comments of the editor in it does not match the original. What came back is kept for an hour, so a second try pays only for what still has to be translated. Try again, or translate this post by hand.', 'langforge');
     3478                return self::run_refusal('langforge_ai_blocks_lost', $message, ['message' => $message, 'error' => 'blocks_lost'], null, $credits_now());
     3479            }
    28063480        }
    28073481
    … …  
    28093483        $excerpt_result = null;
    28103484        if ($broken === null && !empty($source->post_excerpt)) {
     3485            $begin(LANGFORGE_AI_Budget_Gate::PART_EXCERPT);
    28113486            $excerpt_result = $translate_part('excerpt', $source->post_excerpt, 'post excerpt/summary');
    2812             if (is_wp_error($excerpt_result) && $excerpt_result->get_error_code() === 'credits_exhausted') {
    2813                 self::refuse_exhausted_credits($excerpt_result, $created_id);
    2814             }
    2815             self::refuse_lost_shortcodes($excerpt_result, $created_id);
    2816             $broken = self::ai_part_failed($source->post_excerpt, $excerpt_result) ? $excerpt_result : null;
     3487            $excerpt_held   = LANGFORGE_AI_Budget_Gate::is_denial($excerpt_result);
     3488            if ($excerpt_held) {
     3489                $excerpt_result = null;
     3490            }
     3491            if (($refused = self::part_error($excerpt_result, $created_id, $credits_now())) !== null) {
     3492                return $refused;
     3493            }
     3494            $broken = !$excerpt_held && self::ai_part_failed($source->post_excerpt, $excerpt_result) ? $excerpt_result : null;
    28173495        }
    28183496
    … …  
    28213499            // reason of the service, as before. Either way nothing is written - not the title that did come back,
    28223500            // not the snapshot of the source, and the failure record of the language stays (the Retry button).
    2823             $came_back = false;
     3501            $came_back = $content_came > 0;
    28243502            foreach ([$title_result, $content_result, $excerpt_result] as $result) {
    28253503                if (is_string($result) && trim($result) !== '') {
    … …  
    28283506            }
    28293507            if ($came_back) {
    2830                 wp_send_json_error([
    2831                     'message' => LANGFORGE_Translation_Failures::reason_text(LANGFORGE_Translation_Failures::CODE_PARTIAL),
     3508                $message = LANGFORGE_Translation_Failures::reason_text(LANGFORGE_Translation_Failures::CODE_PARTIAL);
     3509                return self::run_refusal(LANGFORGE_Translation_Failures::CODE_PARTIAL, $message, [
     3510                    'message' => $message,
    28323511                    'error'   => LANGFORGE_Translation_Failures::CODE_PARTIAL,
    2833                 ]);
    2834             }
    2835             wp_send_json_error(['message' => self::ai_failure_message($broken)]);
     3512                ], null, $credits_now());
     3513            }
     3514            return self::run_refusal('langforge_ai_failed', self::ai_failure_message($broken), null, null, $credits_now());
     3515        }
     3516
     3517        // The budget did not even cover the first request (no request went out, so nothing came back and nothing is
     3518        // owed): there is nothing to write, and no draft is made for it.
     3519        if ($gate !== null && $gate->stopped()) {
     3520            $any_text = false;
     3521            foreach ([$title_result, $content_result, $excerpt_result] as $result) {
     3522                if (is_string($result) && trim($result) !== '') {
     3523                    $any_text = true;
     3524                }
     3525            }
     3526            if (!$any_text) {
     3527                // A body that stopped between its parts (a post with nothing but a body) has been paid for in part: the parts
     3528                // that came back are kept, nothing is written, and the one who asks again pays for the rest only.
     3529                $message = $content_came > 0
     3530                    ? sprintf('The budget of this translation (max_credits %d) ended inside the body: %d of its %d parts came back and are kept for an hour, so that a second try pays only for the rest. Nothing was written.', (int) $opts['max_credits'], $content_came, $content_total)
     3531                    : sprintf('The first request of this translation does not fit in max_credits (%d): the AI was not asked and nothing was written.', (int) $opts['max_credits']);
     3532                return self::run_refusal(LANGFORGE_AI_Budget_Gate::DENIED, $message, null, null, $credits_now());
     3533            }
     3534        }
     3535
     3536        // A draft is all draft_only writes into, and the world may have changed while the service worked: the
     3537        // translation may have been published, and the caller's own rights may have gone. The texts are paid for; a
     3538        // refusal here writes nothing, and says what the run has charged.
     3539        if ($draft_only && $target_id > 0) {
     3540            clean_post_cache($target_id);
     3541            $now     = get_post($target_id);
     3542            $refusal = $now
     3543                ? $this->draft_only_refusal($source_id, $source, $now, $target_id, $target_lang, true)
     3544                : new WP_Error('langforge_ai_target_missing', sprintf('The %s translation (post %d) is gone. It disappeared while the AI was translating; nothing was written.', $target_lang, $target_id));
     3545            if ($refusal !== null) {
     3546                return self::with_credits($refusal, $credits_now());
     3547            }
     3548        }
     3549        if (isset($opts['verify_before_write']) && is_callable($opts['verify_before_write'])) {
     3550            $verdict = call_user_func($opts['verify_before_write'], $target_id);
     3551            if (is_wp_error($verdict)) {
     3552                return self::with_credits($verdict, $credits_now());
     3553            }
     3554        }
     3555
     3556        // The draft of a language that has none is made now, when what goes into it is in hand.
     3557        $created = false;
     3558        if ($draft_only && $target_id <= 0) {
     3559            $made = $this->create_translation($source_id, $target_lang);
     3560            if (is_wp_error($made)) {
     3561                return self::with_credits($made, $credits_now());
     3562            }
     3563            $target_id = (int) $made;
     3564            $created   = true;
    28363565        }
    28373566
    28383567        // Nothing was refused: write what came through.
    28393568        if (is_string($title_result) && trim($title_result) !== '') {
    2840             wp_update_post(['ID' => $target_id, 'post_title' => $title_result]);
     3569            // wp_update_post takes slashed data (it unslashes what it is given): a text is slashed first, or each backslash
     3570            // of the translation (a path, a pattern, LaTeX) is eaten.
     3571            wp_update_post(['ID' => $target_id, 'post_title' => wp_slash($title_result)]);
    28413572            $translated_count++;
    28423573        }
    28433574
    28443575        if (is_string($content_result) && trim($content_result) !== '') {
    2845             wp_update_post(['ID' => $target_id, 'post_content' => $content_result]);
     3576            wp_update_post(['ID' => $target_id, 'post_content' => wp_slash($content_result)]);
    28463577            $translated_count++;
    28473578        }
    28483579
    28493580        if (is_string($excerpt_result) && trim($excerpt_result) !== '') {
    2850             wp_update_post(['ID' => $target_id, 'post_excerpt' => $excerpt_result]);
     3581            wp_update_post(['ID' => $target_id, 'post_excerpt' => wp_slash($excerpt_result)]);
    28513582            $translated_count++;
    28523583        }
    28533584
    2854         // The three texts are in the page: what was kept for a second try is spent.
    2855         LANGFORGE_Part_Cache::forget($cache_keys);
     3585        // The three texts are in the page: what was kept for a second try is spent. (A run that stopped at its budget keeps
     3586        // them: the try with a higher budget pays only for what is left.)
     3587        if ($gate === null || !$gate->stopped()) {
     3588            LANGFORGE_Part_Cache::forget($cache_keys);
     3589        }
    28563590
    28573591        // Translate ALL text postmeta in ONE batch API call
    … …  
    28963630                $segments[] = ['key' => $key, 'text' => $val];
    28973631            }
     3632            $begin(LANGFORGE_AI_Budget_Gate::PART_FIELDS);
     3633            $extend();
    28983634            $bulk_result = $ai->translate_bulk($segments, $source_lang, $target_lang);
    28993635            if (!is_wp_error($bulk_result) && is_array($bulk_result)) {
    29003636                foreach ($bulk_result as $key => $translated_val) {
    29013637                    if (!empty($translated_val)) {
    2902                         update_post_meta($target_id, $key, $translated_val);
     3638                        // update_post_meta unslashes the value it is given: slashed here, the backslashes survive.
     3639                        update_post_meta($target_id, $key, wp_slash($translated_val));
    29033640                        $translated_count++;
    29043641                    }
    … …  
    29103647        // the translation says in its own words (the variations themselves are made when the translation is).
    29113648        if (class_exists('LANGFORGE_WooCommerce_Variations')) {
     3649            $begin(LANGFORGE_AI_Budget_Gate::PART_VARIATIONS);
     3650            $extend();
    29123651            $translated_count += LANGFORGE_WooCommerce_Variations::instance()->translate_descriptions($source_id, $target_id, $ai, $source_lang, $target_lang);
    29133652        }
    … …  
    29293668        $ff_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $ff_table)) === $ff_table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- related Forge table existence check
    29303669        if ($ff_exists) {
     3670            // The table of Field Forge is written with $wpdb, which takes the text as it is: no slashing here.
     3671            $begin(LANGFORGE_AI_Budget_Gate::PART_FF_FIELDS);
     3672            $extend();
    29313673            $translated_count += $this->translate_fieldforge_values(
    29323674                $ff_table, $source_id, $target_id, $ai, $source_lang, $target_lang
    … …  
    29373679        $seo_title = get_post_meta($source_id, '_rankforge_meta_title', true);
    29383680        if ($seo_title) {
     3681            $begin(LANGFORGE_AI_Budget_Gate::PART_SEO_TITLE);
     3682            $extend();
    29393683            $seo_title_t = $ai->translate($seo_title, $source_lang, $target_lang, 'SEO meta title');
    2940             if (!is_wp_error($seo_title_t)) update_post_meta($target_id, '_rankforge_meta_title', $seo_title_t);
     3684            if (!is_wp_error($seo_title_t)) update_post_meta($target_id, '_rankforge_meta_title', wp_slash($seo_title_t));
    29413685        }
    29423686        $seo_desc = get_post_meta($source_id, '_rankforge_meta_description', true);
    29433687        if ($seo_desc) {
     3688            $begin(LANGFORGE_AI_Budget_Gate::PART_SEO_DESC);
     3689            $extend();
    29443690            $seo_desc_t = $ai->translate($seo_desc, $source_lang, $target_lang, 'SEO meta description');
    2945             if (!is_wp_error($seo_desc_t)) update_post_meta($target_id, '_rankforge_meta_description', $seo_desc_t);
     3691            if (!is_wp_error($seo_desc_t)) update_post_meta($target_id, '_rankforge_meta_description', wp_slash($seo_desc_t));
    29463692        }
    29473693
    … …  
    29513697        // Translation Status "Out of Date" tile/list. Without this, the
    29523698        // user re-translates and the row still shows up flagged.
    2953         if ($translated_count > 0) {
     3699        // A run that stopped at its budget leaves parts untranslated: the translation is not in sync with the source,
     3700        // so the snapshot is not taken and the flag stays.
     3701        $stopped = $gate !== null && $gate->stopped();
     3702        if ($translated_count > 0 && !$stopped) {
    29543703            $fresh_source = get_post($source_id);
    29553704            if ($fresh_source) {
    2956                 update_post_meta($target_id, '_langforge_source_snapshot', [
     3705                // The array goes through update_post_meta too, which unslashes every string in it.
     3706                update_post_meta($target_id, '_langforge_source_snapshot', wp_slash([
    29573707                    'title'   => (string) $fresh_source->post_title,
    29583708                    'content' => (string) $fresh_source->post_content,
    2959                 ]);
     3709                ]));
    29603710            }
    29613711            delete_post_meta($target_id, '_langforge_needs_update');
    … …  
    29653715
    29663716        if ($translated_count === 0) {
    2967             wp_send_json_error(['message' => self::ai_failure_message(null)]);
    2968         }
    2969 
    2970         if ($created_id) {
     3717            return self::run_refusal('langforge_ai_failed', self::ai_failure_message(null), null, null, $credits_now());
     3718        }
     3719
     3720        if ($created_id && !$stopped) {
    29713721            LANGFORGE_Translation_Failures::unflag_created($created_id);
    29723722        }
    29733723
    2974         wp_send_json_success([
    2975             /* translators: %d: number of fields that were translated. */
    2976             'message'    => sprintf(__('Translated %d fields successfully.', 'langforge'), $translated_count),
    2977             'target_id'  => $target_id,
    2978             'edit_url'   => self::edit_url($target_id, 'raw'),
    2979         ]);
     3724        $done = [
     3725            'target_id'        => $target_id,
     3726            'translated_count' => $translated_count,
     3727            'edit_url'         => self::edit_url($target_id, 'raw'),
     3728            'created'          => $created,
     3729        ];
     3730        if ($stopped) {
     3731            $done['stopped_at_budget'] = true;
     3732            $done['untranslated']      = $gate->untranslated();
     3733        }
     3734        $credits = $credits_now();
     3735        if ($credits !== null) {
     3736            $done['credits'] = $credits;
     3737        }
     3738        return $done;
     3739    }
     3740
     3741    /**
     3742 * Why draft_only does not write into this translation (null: it may). The record must be a translation of this post
     3743 * (the same kind of post, kept in this language), and of any status but draft and pending it is live or on its way to
     3744 * be (published, private, scheduled), or gone (trash, auto-draft).
     3745 *
     3746 * @param object|null $target The translation as it is read now (null: the language has none).
     3747 */
     3748    private function draft_only_refusal($source_id, $source, $target, $target_id, $target_lang, $after_the_call) {
     3749        if ($target === null) {
     3750            return null;
     3751        }
     3752        $kept_in = (string) $this->get_post_language($target_id);
     3753        if ((string) $target->post_type !== (string) $source->post_type || $kept_in !== $target_lang) {
     3754            return new WP_Error('langforge_ai_target_mismatch', sprintf(
     3755                'Post %d is linked as the %s translation of post %d, but it is a %s kept in "%s". The link looks broken: check it in the Lang Forge box of the post. Nothing was written.',
     3756                $target_id,
     3757                $target_lang,
     3758                $source_id,
     3759                (string) $target->post_type,
     3760                $kept_in
     3761            ));
     3762        }
     3763        if (!in_array((string) $target->post_status, ['draft', 'pending'], true)) {
     3764            return new WP_Error('langforge_ai_target_live', sprintf(
     3765                'The %s translation (post %d) is %s, and this mode writes into a draft or a pending translation only.%s',
     3766                $target_lang,
     3767                $target_id,
     3768                (string) $target->post_status,
     3769                $after_the_call ? ' It changed while the AI was translating; nothing was written.' : ''
     3770            ));
     3771        }
     3772        return null;
    29803773    }
    29813774}
Note: See TracChangeset for help on using the changeset viewer.