Changeset 3764005 for langforge/trunk/includes/class-post-translation.php
- Timestamp:
- 10/25/2026 06:29:16 AM (3 days ago)
- File:
-
- 1 edited
Legend:
- Unmodified
- Added
- Removed
-
langforge/trunk/includes/class-post-translation.php
r3752345 r3764005 13 13 require_once __DIR__ . '/class-translation-failures.php'; 14 14 require_once __DIR__ . '/class-part-cache.php'; 15 require_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). 17 require_once __DIR__ . '/class-elementor-integration.php'; 15 18 16 19 // IDE hints for autoloaded classes … … 88 91 add_action('add_meta_boxes', [$this, 'add_meta_boxes']); 89 92 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']); 90 95 91 96 // Auto-assign default language to posts missing _lf_language … … 141 146 $table = $wpdb->prefix . 'langforge_translations'; 142 147 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 )); 144 153 145 154 // sibling posts' cached translation arrays listed this … … 158 167 'avl-gutenberg-sidebar', 159 168 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'], 161 170 LANGFORGE_VERSION, 162 171 true … … 168 177 'ajaxUrl' => admin_url('admin-ajax.php'), 169 178 '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'), 170 183 ]); 171 184 } … … 218 231 return null; // untracked, or already the language being viewed 219 232 } 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 } 220 236 221 237 $translation = $this->get_translation($post_id, $current); … … 232 248 $this->localizing_permalink = false; 233 249 } 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(); 234 283 } 235 284 … … 280 329 $hide_default = get_option('langforge_hide_default_language', false); 281 330 282 // Don't modify URL for default language if hide option is enabled283 if ($hide_default && $post_lang === $default_lang) {284 return $permalink;285 }286 287 331 // Check if this post is a translation of the Front Page (fix) 288 332 $front_page_id = (int) get_option('page_on_front'); … … 295 339 } 296 340 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 297 351 // Always the *unlocalized* site root: on an /es/ page home_url('/') 298 352 // returns `.../es/`, which no default-language permalink starts with — … … 300 354 // never be applied to any link rendered on a non-default page. 301 355 $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 } 302 364 303 365 if ($url_format === 'parameter') { … … 639 701 </div> 640 702 <?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 ?> 641 710 642 711 <?php … … 749 818 </ul> 750 819 </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 ?> 751 831 </div> 752 832 … … 994 1074 } 995 1075 $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'])); 997 1077 $title = $compact ? $label . ' — ' . $why : $why; 998 1078 … … 1039 1119 if ($failure) { 1040 1120 // 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'])); 1042 1122 } 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'])) : ''), [ 1044 1124 'class' => 'lf-col-trans lf-col-trans--exists' . ($failure ? ' lf-col-trans--failed' : ''), 1045 1125 'title' => $title, … … 1052 1132 // Not translated, and it is known why: a mark with the reason, not a link. There is nothing to open; 1053 1133 // 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>'; 1056 1136 } else { 1057 1137 $nonce = wp_create_nonce('langforge_admin_nonce'); … … 1199 1279 // WHERE translation_group IN) with one round-trip, halving the preload 1200 1280 // 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. 1202 1285 $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. 1203 1286 "SELECT t2.element_id, t2.language_code, t2.translation_group 1204 1287 FROM $table t2 1205 1288 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') 1207 1292 ); 1208 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1293 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared 1209 1294 1210 1295 if (empty($all_translations)) { … … 1301 1386 // Get all translations in this group regardless of element_type 1302 1387 $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. 1304 1389 $group 1305 1390 ), ARRAY_A); … … 1632 1717 1633 1718 /** 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 /** 1634 1742 * Is this WPML element type a taxonomy term (category, post_tag, a custom 1635 1743 * taxonomy, "tax_*", a nav menu) rather than a post? … … 1646 1754 } 1647 1755 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 1648 1783 public function get_translation_id($element_id, $element_type = 'post', $language = null, $return_original = false) { 1649 1784 if (!$language) { 1650 1785 $language = LANGFORGE_Core::instance()->get_current_language(); 1651 1786 } 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 1653 1798 $translations = $this->get_translations($element_id); 1654 1799 … … 1732 1877 $group2 = $this->get_translation_group($post_id_2); 1733 1878 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) { 1735 1884 // Merge groups - update all items in group2 to group1 1736 1885 $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. … … 1969 2118 } 1970 2119 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 1971 2172 /** 1972 2173 * Create a translation draft of $post_id in $target_lang. … … 2017 2218 $new_post_data['post_name'] = $original_post->post_name; 2018 2219 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)); 2020 2223 2021 2224 if (is_wp_error($new_post_id)) { … … 2049 2252 '_langforge_language', '_langforge_source_snapshot', '_langforge_needs_update', 2050 2253 '_pingme', '_encloseme', 2051 '_elementor_data', 2254 '_elementor_data', self::ELEMENTOR_ID_MAP_META, 2052 2255 ], 2053 2256 // What belongs to the source page alone (the canonical URL typed in for it, ...). The … … 2077 2280 2078 2281 // 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); 2083 2283 update_post_meta($new_post_id, '_elementor_data', wp_slash($data)); 2084 2284 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); 2085 2287 2086 2288 // Copy Elementor CSS cache settings so styles render … … 2177 2379 2178 2380 /** 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 & 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 /** 2179 2490 * PUBLIC API — listener for do_action('langforge/translate_post', int $post_id, array $context). 2180 2491 * … … 2246 2557 } 2247 2558 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; 2249 2564 foreach (array_keys((array) $core->get_active_languages()) as $target_lang) { 2250 2565 $target_lang = (string) $target_lang; … … 2263 2578 } 2264 2579 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)) { 2266 2581 // The job may throw this draft away if it cannot translate it, but only while the draft is still as it is now. 2267 2582 LANGFORGE_Translation_Failures::stamp_stub((int) $target_id); … … 2288 2603 * @param string $target_lang 2289 2604 * @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. 2290 2606 * @return bool Whether an event was scheduled. 2291 2607 */ 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) { 2293 2609 $source_id = (int) $source_id; 2294 2610 $target_id = (int) $target_id; … … 2309 2625 } 2310 2626 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); 2312 2628 2313 2629 $scheduled = wp_schedule_single_event( … … 2654 2970 /** The answer of the handler when the credits ran out: the one the button turns into the checkout offer. */ 2655 2971 private static function refuse_exhausted_credits($result, $created_id = 0) { 2972 $err_data = $result->get_error_data(); 2656 2973 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 } 2660 2976 wp_send_json_error([ 2661 2977 'message' => $result->get_error_message(), 2662 2978 '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']), 2663 2981 'remaining' => $err_data['remaining'] ?? 0, 2664 2982 'reset_date' => $err_data['reset_date'] ?? '', … … 2679 2997 } 2680 2998 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 2681 3075 /** What the person is told when no text came back: the reason of the service if there is one. */ 2682 3076 private static function ai_failure_message($ai_error) { … … 2692 3086 } 2693 3087 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 */ 2694 3185 public function ajax_ai_translate_post() { 2695 3186 check_ajax_referer('langforge_admin_nonce', 'nonce'); … … 2703 3194 } 2704 3195 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 2706 3266 // the page it made, and whatever failed must be told there, once, from here: the screen reads the flag, not the 2707 3267 // address (LANGFORGE_Translation_Failures::render_created_notice). Until a good answer takes the flag back, … … 2709 3269 // with no answer at all (a fatal error, a timeout) too; a refusal that knows its reason replaces it. The AI 2710 3270 // 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; 2712 3272 if ($created_id) { 2713 3273 LANGFORGE_Translation_Failures::flag_created($created_id, LANGFORGE_Translation_Failures::CREATED_OTHER); … … 2717 3277 // class is absent — return a clean error rather than fataling. 2718 3278 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')); 2720 3280 } 2721 3281 $ai = LANGFORGE_AI_Client::instance(); 2722 3282 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')); 2724 3284 } 2725 3285 // Enforce it server-side too. is_configured only proves a key is … … 2728 3288 // AI credits. The add-on reports the feature off on Free / expired plans. 2729 3289 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); 2735 3294 2736 3295 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')); 2738 3297 } 2739 3298 2740 3299 $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 }; 2745 3356 2746 3357 $translated_count = 0; … … 2750 3361 // The three texts are translated first and written afterwards. A text in which the AI lost or changed a 2751 3362 // 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 content3363 // translation (self::part_error): a title in the language of the translation over a content 2753 3364 // that is not, or a green "Translated 1 fields successfully" about a page whose form or gallery is broken, 2754 3365 // would be worse than telling the person and leaving the translation as it was. … … 2759 3370 // request are dropped when the page is written: a person who translates it again wants a new one. 2760 3371 $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; 2762 3375 if (trim((string) $text) === '') { 2763 3376 return $ai->translate($text, $source_lang, $target_lang, $context); … … 2773 3386 $key = LANGFORGE_Part_Cache::key($kind, $source_lang, $target_lang, $text, [$model, $glossary]); 2774 3387 $cache_keys[] = $key; 3388 $last_key = $key; 2775 3389 $kept = LANGFORGE_Part_Cache::get($key); 2776 3390 if (is_string($kept) && trim($kept) !== '') { 2777 3391 return $kept; 2778 3392 } 3393 $extend(); 2779 3394 $translated = $ai->translate($text, $source_lang, $target_lang, $context); 2780 3395 if (is_string($translated) && trim($translated) !== '') { … … 2784 3399 }; 2785 3400 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). 2786 3403 // Translate title 3404 $begin(LANGFORGE_AI_Budget_Gate::PART_TITLE); 2787 3405 $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 } 2792 3413 2793 3414 // A part that went out and did not come back whole (an error, nothing, blanks) ends the request: the page 2794 3415 // 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. 2798 3422 $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 = []; 2799 3429 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 } 2806 3480 } 2807 3481 … … 2809 3483 $excerpt_result = null; 2810 3484 if ($broken === null && !empty($source->post_excerpt)) { 3485 $begin(LANGFORGE_AI_Budget_Gate::PART_EXCERPT); 2811 3486 $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; 2817 3495 } 2818 3496 … … 2821 3499 // reason of the service, as before. Either way nothing is written - not the title that did come back, 2822 3500 // 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; 2824 3502 foreach ([$title_result, $content_result, $excerpt_result] as $result) { 2825 3503 if (is_string($result) && trim($result) !== '') { … … 2828 3506 } 2829 3507 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, 2832 3511 '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; 2836 3565 } 2837 3566 2838 3567 // Nothing was refused: write what came through. 2839 3568 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)]); 2841 3572 $translated_count++; 2842 3573 } 2843 3574 2844 3575 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)]); 2846 3577 $translated_count++; 2847 3578 } 2848 3579 2849 3580 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)]); 2851 3582 $translated_count++; 2852 3583 } 2853 3584 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 } 2856 3590 2857 3591 // Translate ALL text postmeta in ONE batch API call … … 2896 3630 $segments[] = ['key' => $key, 'text' => $val]; 2897 3631 } 3632 $begin(LANGFORGE_AI_Budget_Gate::PART_FIELDS); 3633 $extend(); 2898 3634 $bulk_result = $ai->translate_bulk($segments, $source_lang, $target_lang); 2899 3635 if (!is_wp_error($bulk_result) && is_array($bulk_result)) { 2900 3636 foreach ($bulk_result as $key => $translated_val) { 2901 3637 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)); 2903 3640 $translated_count++; 2904 3641 } … … 2910 3647 // the translation says in its own words (the variations themselves are made when the translation is). 2911 3648 if (class_exists('LANGFORGE_WooCommerce_Variations')) { 3649 $begin(LANGFORGE_AI_Budget_Gate::PART_VARIATIONS); 3650 $extend(); 2912 3651 $translated_count += LANGFORGE_WooCommerce_Variations::instance()->translate_descriptions($source_id, $target_id, $ai, $source_lang, $target_lang); 2913 3652 } … … 2929 3668 $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 2930 3669 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(); 2931 3673 $translated_count += $this->translate_fieldforge_values( 2932 3674 $ff_table, $source_id, $target_id, $ai, $source_lang, $target_lang … … 2937 3679 $seo_title = get_post_meta($source_id, '_rankforge_meta_title', true); 2938 3680 if ($seo_title) { 3681 $begin(LANGFORGE_AI_Budget_Gate::PART_SEO_TITLE); 3682 $extend(); 2939 3683 $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)); 2941 3685 } 2942 3686 $seo_desc = get_post_meta($source_id, '_rankforge_meta_description', true); 2943 3687 if ($seo_desc) { 3688 $begin(LANGFORGE_AI_Budget_Gate::PART_SEO_DESC); 3689 $extend(); 2944 3690 $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)); 2946 3692 } 2947 3693 … … 2951 3697 // Translation Status "Out of Date" tile/list. Without this, the 2952 3698 // 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) { 2954 3703 $fresh_source = get_post($source_id); 2955 3704 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([ 2957 3707 'title' => (string) $fresh_source->post_title, 2958 3708 'content' => (string) $fresh_source->post_content, 2959 ]) ;3709 ])); 2960 3710 } 2961 3711 delete_post_meta($target_id, '_langforge_needs_update'); … … 2965 3715 2966 3716 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) { 2971 3721 LANGFORGE_Translation_Failures::unflag_created($created_id); 2972 3722 } 2973 3723 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; 2980 3773 } 2981 3774 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)