Changeset 3764005 for langforge/trunk/admin/class-admin.php
- Timestamp:
- 10/25/2026 06:29:16 AM (35 hours ago)
- File:
-
- 1 edited
-
langforge/trunk/admin/class-admin.php (modified) (27 diffs)
Legend:
- Unmodified
- Added
- Removed
-
langforge/trunk/admin/class-admin.php
r3752345 r3764005 79 79 // Lang Forge → Migration → WPML. 80 80 add_action('admin_notices', [$this, 'render_wpml_coexistence_notice']); 81 // BUG-LANG-INTEG-02: the same warning for Polylang and TranslatePress. 82 add_action('admin_notices', [$this, 'render_rival_coexistence_notice']); 81 83 } 82 84 … … 207 209 'skipped' => $total_skipped, 208 210 'errors' => $errors, 211 /* translators: %d: number of strings translated; the sentence takes the form of that number. */ 212 'message' => sprintf(_n('Translated %d string. Reloading…', 'Translated %d strings. Reloading…', $total_done, 'langforge'), $total_done), 209 213 ]); 210 214 } … … 405 409 if ($result['success']) { 406 410 /* translators: %d: number of translation groups imported from WPML. */ 407 echo '<div class="notice notice-success"><p>' . sprintf(esc_html __('Migration complete! Imported %d translation groups.', 'langforge'), esc_html($result['count'])) . '</p></div>';411 echo '<div class="notice notice-success"><p>' . sprintf(esc_html(_n('Migration complete! Imported %d translation group.', 'Migration complete! Imported %d translation groups.', (int) $result['count'], 'langforge')), (int) $result['count']) . '</p></div>'; 408 412 } else { 409 413 echo '<div class="notice notice-error"><p>' . esc_html__("Migration failed: ", "langforge") . esc_html($result['error']) . '</p></div>'; … … 768 772 $is_lf_page = strpos($page_param, 'langforge-') === 0; 769 773 $is_post_list = in_array($screen_base, ['edit', 'post', 'page'], true); 770 $is_taxonomy = $screen_base === 'edit-tags'; 774 // The list of terms (edit-tags.php) and the edit screen of one term (term.php) both print the Language and 775 // Translations fields, with the cross that unlinks a translation: its click handler lives in admin.js. 776 // (BUG-LANG-TERM-01: term.php used to be left out, so the cross there did nothing.) 777 $is_taxonomy = in_array($screen_base, ['edit-tags', 'term'], true); 771 778 772 779 $is_post_edit = $screen_base === 'post'; // single post/page edit screen … … 834 841 'resetsOn' => __('Resets on', 'langforge'), 835 842 'buyCredits' => __('Buy credits', 'langforge'), 843 'choosePlan' => __('Choose a plan', 'langforge'), 844 'trialCreditsExhausted' => __('Your free trial has too few credits left for this action. Connect a licence or choose a plan to keep using AI.', 'langforge'), 836 845 'fixApplied' => __('Fix applied.', 'langforge'), 837 846 'dismissTitle' => __('Dismiss', 'langforge'), … … 1494 1503 if ($result['success']) { 1495 1504 /* translators: %d: number of translation groups imported from WPML. */ 1496 echo '<div class="notice notice-success"><p>' . sprintf(esc_html __('Migration complete! Imported %d translation groups.', 'langforge'), esc_html($result['count'])) . '</p></div>';1505 echo '<div class="notice notice-success"><p>' . sprintf(esc_html(_n('Migration complete! Imported %d translation group.', 'Migration complete! Imported %d translation groups.', (int) $result['count'], 'langforge')), (int) $result['count']) . '</p></div>'; 1497 1506 } else { 1498 1507 echo '<div class="notice notice-error"><p>' . esc_html__("Migration failed: ", "langforge") . esc_html($result['error']) . '</p></div>'; … … 1773 1782 // Handle save 1774 1783 if (isset($_POST['langforge_save_strings']) && wp_verify_nonce(isset($_POST['lf_strings_nonce']) ? sanitize_text_field(wp_unslash($_POST['lf_strings_nonce'])) : '', 'langforge_save_strings')) { 1775 $ this->save_string_translations();1784 $glossary_report = $this->save_string_translations(); 1776 1785 echo '<div class="notice notice-success"><p>' . esc_html__('Translations saved.', 'langforge') . '</p></div>'; 1786 // BUG-LANG-GLOSS-02: saved all the same; the terms of the glossary a translation does not follow are named. 1787 LANGFORGE_Strings_Screen::render_glossary_report($glossary_report); 1777 1788 } 1778 1789 … … 1921 1932 printf( 1922 1933 /* translators: 1: first row number shown, 2: last row number shown, 3: total number of strings. */ 1923 esc_html __('Showing %1$d – %2$d of %3$d strings', 'langforge'),1934 esc_html(_n('Showing %1$d – %2$d of %3$d string', 'Showing %1$d – %2$d of %3$d strings', (int) $total_strings, 'langforge')), 1924 1935 esc_html($from_idx), esc_html($to_idx), esc_html($total_strings) 1925 1936 ); … … 2172 2183 data-source="<?php echo esc_attr($default_lang); ?>" 2173 2184 data-missing-cost="<?php echo (int) $ai_missing_cost; ?>" 2185 data-missing-cost-word="<?php /* translators: the unit after a number of AI credits, in the form that number takes ("1 credit", "5 credits"); the AI Translate dialog puts it after the cost. */ echo esc_attr(_n('credit', 'credits', $ai_missing_cost, 'langforge')); ?>" 2174 2186 data-missing-slots="<?php echo (int) $ai_pending; ?>" 2175 2187 data-missing-langs="<?php echo (int) $ai_missing_lang_count; ?>" 2188 data-missing-text="<?php echo esc_attr(self::ai_missing_text($ai_pending, $ai_missing_lang_count, $ai_missing_cost)); ?>" 2176 2189 data-all-cost="<?php echo (int) $ai_all_cost; ?>" 2190 data-all-cost-word="<?php /* translators: the unit after a number of AI credits, in the form that number takes ("1 credit", "5 credits"); the AI Translate dialog puts it after the cost. */ echo esc_attr(_n('credit', 'credits', $ai_all_cost, 'langforge')); ?>" 2177 2191 data-all-slots="<?php echo (int) $ai_all_slots; ?>" 2178 2192 data-all-langs="<?php echo (int) $ai_all_lang_count; ?>" … … 2245 2259 var $modal = jQuery('#lf-ai-modal'); 2246 2260 function closeModal(){ $modal.css('display','none').attr('aria-hidden','true'); } 2247 function crWord(n){ return n === 1 ? '<?php echo esc_js(__('credit', 'langforge')); ?>' : '<?php echo esc_js(__('credits', 'langforge')); ?>'; }2248 2261 2249 2262 function runBulk($btn, mode){ … … 2264 2277 var n = (r.data && r.data.translated) || 0; 2265 2278 var errs = (r.data && r.data.errors) || []; 2266 var msg2 = '<?php /* translators: %d: number of strings translated (replaced client-side). */ echo esc_js(__('Translated %d strings. Reloading…', 'langforge')); ?>'.replace('%d', n); 2279 // The server words the count, in the plural form of its number (ajax_ai_translate_strings_bulk()). 2280 var msg2 = (r.data && typeof r.data.message === 'string' && r.data.message !== '') ? r.data.message : String(n); 2267 2281 if (errs.length) msg2 += '\n\n' + errs.join('\n'); 2268 2282 $btn.html('<span class="dashicons dashicons-yes"></span> ' + msg2); … … 2291 2305 var missing = parseInt($btn.data('missing-slots'),10)||0; 2292 2306 var missingCost = parseInt($btn.data('missing-cost'),10)||0; 2293 var missingLangs = parseInt($btn.data('missing-langs'),10)||0;2294 2307 var allSlots = parseInt($btn.data('all-slots'),10)||0; 2295 2308 var allCost = parseInt($btn.data('all-cost'),10)||0; 2309 // The word after each cost, in the form that cost takes: chosen with _n() when the page was 2310 // drawn (one form in English, three in Russian, six in Arabic), not by n === 1 here. 2311 var missingWord = String($btn.data('missing-cost-word') || ''); 2312 var allWord = String($btn.data('all-cost-word') || ''); 2296 2313 2297 2314 var title, body, actions = []; 2298 2315 if (missing > 0) { 2299 2316 title = '<?php echo esc_js(__('Translate missing strings?', 'langforge')); ?>'; 2300 body = '<?php /* translators: 1: number of untranslated strings, 2: number of languages, 3: credit cost, 4: the word "credit"/"credits". */ echo esc_js(__('%1$s string(s) across %2$s language(s) have no translation yet. Translating them costs %3$s %4$s, and the result is saved automatically.', 'langforge')); ?>'2301 .replace('%1$s', missing).replace('%2$s', missingLangs).replace('%3$s', missingCost).replace('%4$s', crWord(missingCost));2302 actions.push({ label: '<?php echo esc_js(__('Translate Missing', 'langforge')); ?> · ' + missingCost + ' ' + crWord(missingCost), mode:'missing', color:'green' });2317 // Put together when the page was drawn (ai_missing_text()): each number in its own plural form. 2318 body = String($btn.data('missing-text') || ''); 2319 actions.push({ label: '<?php echo esc_js(__('Translate Missing', 'langforge')); ?> · ' + missingCost + ' ' + missingWord, mode:'missing', color:'green' }); 2303 2320 if (allSlots > missing) { 2304 actions.push({ label: '<?php echo esc_js(__('Re-Translate All', 'langforge')); ?> · ' + allCost + ' ' + crWord(allCost), mode:'all', color:'amber' });2321 actions.push({ label: '<?php echo esc_js(__('Re-Translate All', 'langforge')); ?> · ' + allCost + ' ' + allWord, mode:'all', color:'amber' }); 2305 2322 } 2306 2323 } else if (allSlots > 0) { 2307 2324 title = '<?php echo esc_js(__('Everything is already translated', 'langforge')); ?>'; 2308 2325 body = '<?php /* translators: 1: credit cost to re-translate all, 2: the word "credit"/"credits". */ echo esc_js(__('All strings in this domain already have a translation, so there is nothing new to translate — that is why the cost shows 0. You can re-generate every translation from scratch with the latest glossary: this overwrites the existing values, ignores Translation Memory, and costs %1$s %2$s.', 'langforge')); ?>' 2309 .replace('%1$s', allCost).replace('%2$s', crWord(allCost));2310 actions.push({ label: '<?php echo esc_js(__('Re-Translate All', 'langforge')); ?> · ' + allCost + ' ' + crWord(allCost), mode:'all', color:'amber' });2326 .replace('%1$s', allCost).replace('%2$s', allWord); 2327 actions.push({ label: '<?php echo esc_js(__('Re-Translate All', 'langforge')); ?> · ' + allCost + ' ' + allWord, mode:'all', color:'amber' }); 2311 2328 } else { 2312 2329 title = '<?php echo esc_js(__('Nothing to translate', 'langforge')); ?>'; … … 2495 2512 /** 2496 2513 * Save string translations 2514 * 2515 * Everything posted is saved. Afterwards the saved texts are checked against the glossary (BUG-LANG-GLOSS-02) and 2516 * what does not follow it is returned for the screen to name; the check never keeps a text from being saved. 2517 * 2518 * @return array<int,array{name:string,domain:string,lang:string,warnings:string}> See LANGFORGE_Strings_Screen::glossary_report. 2497 2519 */ 2498 2520 private function save_string_translations() { 2499 2521 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in render_string_translation_page (lf_strings_nonce / langforge_save_strings) before this method runs. 2500 2522 if (empty($_POST['strings']) || !is_array($_POST['strings'])) { 2501 return ;2523 return []; 2502 2524 } 2503 2525 … … 2508 2530 : []; 2509 2531 2532 $source_lang = LANGFORGE_Core::instance()->get_default_language(); 2533 $saved = []; 2534 2510 2535 foreach ($posted_strings as $raw_name => $translations) { 2511 2536 if (!is_array($translations)) { … … 2520 2545 : ($global_domain ?: 'default'); 2521 2546 2547 $values = []; 2522 2548 foreach ($translations as $lang => $value) { 2523 2549 $lang = sanitize_text_field($lang); 2524 2550 $value = LANGFORGE_Strings_Screen::sanitize_translation($domain, $value); 2551 $values[$lang] = $value; 2525 2552 2526 2553 if (!empty($value)) { … … 2528 2555 } 2529 2556 } 2557 2558 foreach ($values as $lang => $value) { 2559 if (!empty($value) && $lang !== $source_lang) { 2560 $saved[] = ['name' => $name, 'domain' => $domain, 'lang' => $lang, 'value' => $value, 'source' => $values[$source_lang] ?? null]; 2561 } 2562 } 2530 2563 } 2531 2564 // phpcs:enable WordPress.Security.NonceVerification.Missing 2565 2566 return LANGFORGE_Strings_Screen::glossary_report($saved, $source_lang); 2532 2567 } 2533 2568 … … 2811 2846 2812 2847 /** 2848 * Translation Status, one language: of the published source posts of $types, how many have a translation 2849 * in $code (any status but trash), and how many of those translations are published . 2850 * 2851 * Every join of the translations table on a post ID carries post_row_sql: the table also holds the rows 2852 * of terms, and a term can have the same number as a post (BUG-LANG-SEO-03). 2853 * 2854 * @param string $code Target language. 2855 * @param string $types_to_check Post types counted. 2856 * @param string $default_lang Source language. 2857 * @return int [translated, published]. 2858 */ 2859 private function count_translated_sources($code, array $types_to_check, $default_lang) { 2860 global $wpdb; 2861 $lf_table = $wpdb->prefix . 'langforge_translations'; 2862 $type_placeholders = implode(',', array_fill(0, count($types_to_check), '%s')); 2863 2864 // Count source posts that have a translation linked in 2865 // lf_translations for this language. Again, no post_status 2866 // filter on source or target — only existence of the pair. 2867 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Core + $wpdb->prefix tables; IN built from %s placeholders, values are %-prepared; post_row_sql is a fixed fragment with no input. 2868 $translated = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $lf_table from $wpdb->prefix; values %-prepared. 2869 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p 2870 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 2871 INNER JOIN {$lf_table} t1 ON p.ID = t1.element_id AND " . LANGFORGE_Post_Translation::post_row_sql('t1') . " 2872 INNER JOIN {$lf_table} t2 ON t1.translation_group = t2.translation_group AND t2.language_code = %s AND " . LANGFORGE_Post_Translation::post_row_sql('t2') . " 2873 INNER JOIN {$wpdb->posts} tp ON t2.element_id = tp.ID AND tp.post_status NOT IN ('trash','auto-draft','inherit') 2874 WHERE p.post_status = 'publish' 2875 AND p.post_type IN ($type_placeholders) 2876 AND pm.meta_key = '_langforge_language' AND pm.meta_value = %s", 2877 $code, ...array_merge($types_to_check, [$default_lang]) 2878 )); 2879 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared 2880 2881 // break translated count down by the target post's 2882 // publish status so the card can show published / draft / 2883 // missing instead of a single opaque percentage. 2884 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Core + $wpdb->prefix tables; IN built from %s placeholders, values are %-prepared; post_row_sql is a fixed fragment with no input. 2885 $published = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $lf_table from $wpdb->prefix; values %-prepared. 2886 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p 2887 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 2888 INNER JOIN {$lf_table} t1 ON p.ID = t1.element_id AND " . LANGFORGE_Post_Translation::post_row_sql('t1') . " 2889 INNER JOIN {$lf_table} t2 ON t1.translation_group = t2.translation_group AND t2.language_code = %s AND " . LANGFORGE_Post_Translation::post_row_sql('t2') . " 2890 INNER JOIN {$wpdb->posts} tp ON t2.element_id = tp.ID AND tp.post_status = 'publish' 2891 WHERE p.post_status = 'publish' 2892 AND p.post_type IN ($type_placeholders) 2893 AND pm.meta_key = '_langforge_language' AND pm.meta_value = %s", 2894 $code, ...array_merge($types_to_check, [$default_lang]) 2895 )); 2896 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared 2897 return [$translated, $published]; 2898 } 2899 2900 /** 2901 * Translation Status, "Out of date" list: the translation posts flagged _langforge_needs_update, each with its 2902 * source post. The joins on the translations table are on posts' rows only (post_row_sql, BUG-LANG-SEO-03). 2903 * 2904 * @param string $default_lang Source language. 2905 * @param string $filter_type One post type, or '' for all translatable ones. 2906 * @param string $filter_lang One language, or '' for every language but the default one. 2907 * @param string $translatable Translatable post types. 2908 * @return object 2909 */ 2910 private function get_outdated_posts($default_lang, $filter_type, $filter_lang, array $translatable) { 2911 global $wpdb; 2912 2913 // Build query for out-of-date posts. 2914 $outdated_args = []; 2915 $type_clause = ''; 2916 if ($filter_type) { 2917 $type_clause = ' AND p.post_type = %s'; 2918 $outdated_args[] = $filter_type; 2919 } else { 2920 $placeholders = implode(',', array_fill(0, count($translatable), '%s')); 2921 $type_clause = " AND p.post_type IN ($placeholders)"; 2922 $outdated_args = array_merge($outdated_args, $translatable); 2923 } 2924 $lang_clause = ''; 2925 if ($filter_lang && $filter_lang !== $default_lang) { 2926 $lang_clause = ' AND pm_lang.meta_value = %s'; 2927 $outdated_args[] = $filter_lang; 2928 } else { 2929 $lang_clause = ' AND pm_lang.meta_value <> %s'; 2930 $outdated_args[] = $default_lang; 2931 } 2932 // surface the modified timestamp of both the 2933 // translation post itself and its source. JOIN to 2934 // wp_lf_translations twice — once to find the 2935 // translation_group of the outdated post, once to find 2936 // the default-language sibling in that group — and pull 2937 // its post_modified through a third join on wp_posts. 2938 // Outer LEFT JOINs because half-migrated rows 2939 // (translation in lf_translations but source row dropped) 2940 // shouldn't drop the outdated post from the list — we'd 2941 // rather show "—" than hide the row. 2942 $lf_table = $wpdb->prefix . 'langforge_translations'; 2943 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core + $wpdb->prefix tables; $type_clause/$lang_clause are literal %s placeholder fragments, values %-prepared below. 2944 $outdated_sql = " 2945 SELECT p.ID, p.post_title, p.post_type, p.post_status, 2946 p.post_date, p.post_modified, 2947 pm_lang.meta_value AS lang_code, 2948 pm_flag.meta_value AS flagged_at, 2949 src.ID AS source_id, 2950 src.post_modified AS source_modified 2951 FROM {$wpdb->posts} p 2952 INNER JOIN {$wpdb->postmeta} pm_flag 2953 ON pm_flag.post_id = p.ID AND pm_flag.meta_key = '_langforge_needs_update' 2954 INNER JOIN {$wpdb->postmeta} pm_lang 2955 ON pm_lang.post_id = p.ID AND pm_lang.meta_key = '_langforge_language' 2956 LEFT JOIN {$lf_table} t1 ON t1.element_id = p.ID AND " . LANGFORGE_Post_Translation::post_row_sql('t1') . " 2957 LEFT JOIN {$lf_table} t2 ON t2.translation_group = t1.translation_group 2958 AND t2.language_code = %s AND " . LANGFORGE_Post_Translation::post_row_sql('t2') . " 2959 LEFT JOIN {$wpdb->posts} src ON src.ID = t2.element_id 2960 AND src.post_status NOT IN ('trash','auto-draft','inherit') 2961 WHERE p.post_status NOT IN ('trash','auto-draft','inherit') 2962 {$type_clause}{$lang_clause} 2963 ORDER BY pm_flag.meta_value DESC 2964 LIMIT 200 2965 "; 2966 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 2967 // Inject the default-language code as the first %s for the 2968 // t2.language_code = %s placeholder above. Other args 2969 // (type clause, lang clause) follow in their declared order. 2970 $outdated_args = array_merge([$default_lang], $outdated_args); 2971 $outdated_posts = $wpdb->get_results($wpdb->prepare($outdated_sql, ...$outdated_args)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $outdated_sql is prepared here with %s placeholders; core/own tables. 2972 return $outdated_posts; 2973 } 2974 2975 /** 2813 2976 * Render translation status page 2814 2977 */ … … 2960 3123 $total = $total_count; 2961 3124 2962 // Count source posts that have a translation linked in 2963 // lf_translations for this language. Again, no post_status 2964 // filter on source or target — only existence of the pair. 2965 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core + $wpdb->prefix tables; IN built from %s placeholders, values are %-prepared. 2966 $translated = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $lf_table from $wpdb->prefix; values %-prepared. 2967 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p 2968 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 2969 INNER JOIN {$lf_table} t1 ON p.ID = t1.element_id 2970 INNER JOIN {$lf_table} t2 ON t1.translation_group = t2.translation_group AND t2.language_code = %s 2971 INNER JOIN {$wpdb->posts} tp ON t2.element_id = tp.ID AND tp.post_status NOT IN ('trash','auto-draft','inherit') 2972 WHERE p.post_status = 'publish' 2973 AND p.post_type IN ($type_placeholders) 2974 AND pm.meta_key = '_langforge_language' AND pm.meta_value = %s", 2975 $code, ...array_merge($types_to_check, [$default_lang]) 2976 )); 2977 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 2978 2979 // break translated count down by the target post's 2980 // publish status so the card can show published / draft / 2981 // missing instead of a single opaque percentage. 2982 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core + $wpdb->prefix tables; IN built from %s placeholders, values are %-prepared. 2983 $published = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $lf_table from $wpdb->prefix; values %-prepared. 2984 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p 2985 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 2986 INNER JOIN {$lf_table} t1 ON p.ID = t1.element_id 2987 INNER JOIN {$lf_table} t2 ON t1.translation_group = t2.translation_group AND t2.language_code = %s 2988 INNER JOIN {$wpdb->posts} tp ON t2.element_id = tp.ID AND tp.post_status = 'publish' 2989 WHERE p.post_status = 'publish' 2990 AND p.post_type IN ($type_placeholders) 2991 AND pm.meta_key = '_langforge_language' AND pm.meta_value = %s", 2992 $code, ...array_merge($types_to_check, [$default_lang]) 2993 )); 2994 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 3125 // The counts are in count_translated_sources: posts only, never a term's row of the translations table. 3126 [$translated, $published] = $this->count_translated_sources($code, $types_to_check, $default_lang); 2995 3127 $drafts = max(0, $translated - $published); 2996 3128 $missing = max(0, $total - $translated); … … 3116 3248 </p> 3117 3249 <?php 3118 // Build query for out-of-date posts. 3119 $outdated_args = []; 3120 $type_clause = ''; 3121 if ($filter_type) { 3122 $type_clause = ' AND p.post_type = %s'; 3123 $outdated_args[] = $filter_type; 3124 } else { 3125 $placeholders = implode(',', array_fill(0, count($translatable), '%s')); 3126 $type_clause = " AND p.post_type IN ($placeholders)"; 3127 $outdated_args = array_merge($outdated_args, $translatable); 3128 } 3129 $lang_clause = ''; 3130 if ($filter_lang && $filter_lang !== $default_lang) { 3131 $lang_clause = ' AND pm_lang.meta_value = %s'; 3132 $outdated_args[] = $filter_lang; 3133 } else { 3134 $lang_clause = ' AND pm_lang.meta_value <> %s'; 3135 $outdated_args[] = $default_lang; 3136 } 3137 // surface the modified timestamp of both the 3138 // translation post itself and its source. JOIN to 3139 // wp_lf_translations twice — once to find the 3140 // translation_group of the outdated post, once to find 3141 // the default-language sibling in that group — and pull 3142 // its post_modified through a third join on wp_posts. 3143 // Outer LEFT JOINs because half-migrated rows 3144 // (translation in lf_translations but source row dropped) 3145 // shouldn't drop the outdated post from the list — we'd 3146 // rather show "—" than hide the row. 3147 $lf_table = $wpdb->prefix . 'langforge_translations'; 3148 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core + $wpdb->prefix tables; $type_clause/$lang_clause are literal %s placeholder fragments, values %-prepared below. 3149 $outdated_sql = " 3150 SELECT p.ID, p.post_title, p.post_type, p.post_status, 3151 p.post_date, p.post_modified, 3152 pm_lang.meta_value AS lang_code, 3153 pm_flag.meta_value AS flagged_at, 3154 src.ID AS source_id, 3155 src.post_modified AS source_modified 3156 FROM {$wpdb->posts} p 3157 INNER JOIN {$wpdb->postmeta} pm_flag 3158 ON pm_flag.post_id = p.ID AND pm_flag.meta_key = '_langforge_needs_update' 3159 INNER JOIN {$wpdb->postmeta} pm_lang 3160 ON pm_lang.post_id = p.ID AND pm_lang.meta_key = '_langforge_language' 3161 LEFT JOIN {$lf_table} t1 ON t1.element_id = p.ID 3162 LEFT JOIN {$lf_table} t2 ON t2.translation_group = t1.translation_group 3163 AND t2.language_code = %s 3164 LEFT JOIN {$wpdb->posts} src ON src.ID = t2.element_id 3165 AND src.post_status NOT IN ('trash','auto-draft','inherit') 3166 WHERE p.post_status NOT IN ('trash','auto-draft','inherit') 3167 {$type_clause}{$lang_clause} 3168 ORDER BY pm_flag.meta_value DESC 3169 LIMIT 200 3170 "; 3171 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 3172 // Inject the default-language code as the first %s for the 3173 // t2.language_code = %s placeholder above. Other args 3174 // (type clause, lang clause) follow in their declared order. 3175 $outdated_args = array_merge([$default_lang], $outdated_args); 3176 $outdated_posts = $wpdb->get_results($wpdb->prepare($outdated_sql, ...$outdated_args)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $outdated_sql is prepared here with %s placeholders; core/own tables. 3250 $outdated_posts = $this->get_outdated_posts($default_lang, $filter_type, $filter_lang, $translatable); 3177 3251 if (empty($outdated_posts)): 3178 3252 echo '<p style="color:#059669;font-weight:600;">' . esc_html__('Nothing flagged. Translations are current with their source.', 'langforge') . '</p>'; … … 3259 3333 <?php $rt_cost = class_exists('Forge_Wporg_Disabled_Credits') ? (int) Forge_Wporg_Disabled_Credits::get_action_cost('translate_post') : 3; if ($rt_cost < 1) { $rt_cost = 1; } ?> 3260 3334 <button type="button" class="button button-small button-primary lf-outdated-retranslate" 3261 title="<?php /* translators: %d: approximate number of AI credits the re-translation will use. */ echo esc_attr(sprintf(_ _('Re-run AI translation against the latest source content — uses about %d AI credits', 'langforge'), $rt_cost)); ?>"3335 title="<?php /* translators: %d: approximate number of AI credits the re-translation will use. */ echo esc_attr(sprintf(_n('Re-run AI translation against the latest source content — uses about %d AI credit', 'Re-run AI translation against the latest source content — uses about %d AI credits', $rt_cost, 'langforge'), $rt_cost)); ?>" 3262 3336 style="display:inline-flex;align-items:center;gap:5px;"> 3263 3337 <?php esc_html_e('Re-translate', 'langforge'); ?> … … 3286 3360 var failMsg = <?php echo wp_json_encode(__('Re-translate failed.', 'langforge')); ?>; 3287 3361 var netMsg = <?php echo wp_json_encode(__('Network error during re-translate.', 'langforge')); ?>; 3288 var confirmMsg = <?php /* translators: %d: approximate number of AI credits the re-translation will use. */ echo wp_json_encode(sprintf(__('Re-run AI translation for this post? This uses about %d AI credits and overwrites the current translation.', 'langforge'), (int) (class_exists('Forge_Wporg_Disabled_Credits') ? Forge_Wporg_Disabled_Credits::get_action_cost('translate_post') : 3))); ?>;3362 var confirmMsg = <?php $rt_confirm_cost = (int) (class_exists('Forge_Wporg_Disabled_Credits') ? Forge_Wporg_Disabled_Credits::get_action_cost('translate_post') : 3); /* translators: %d: approximate number of AI credits the re-translation will use. */ echo wp_json_encode(sprintf(_n('Re-run AI translation for this post? This uses about %d AI credit and overwrites the current translation.', 'Re-run AI translation for this post? This uses about %d AI credits and overwrites the current translation.', $rt_confirm_cost, 'langforge'), $rt_confirm_cost)); ?>; 3289 3363 document.querySelectorAll('.lf-outdated-retranslate').forEach(function(btn) { 3290 3364 btn.addEventListener('click', function() { … … 3469 3543 $errors[] = sprintf( 3470 3544 /* translators: %d = max characters */ 3471 _ _('Source term is too long (max %d characters).', 'langforge'),3545 _n('Source term is too long (max %d character).', 'Source term is too long (max %d characters).', 255, 'langforge'), 3472 3546 255 3473 3547 ); … … 3476 3550 $errors[] = sprintf( 3477 3551 /* translators: %d = max characters */ 3478 _ _('Translation is too long (max %d characters).', 'langforge'),3552 _n('Translation is too long (max %d character).', 'Translation is too long (max %d characters).', 255, 'langforge'), 3479 3553 255 3480 3554 ); … … 3506 3580 $csv = file_get_contents($_FILES['glossary_csv']['tmp_name']); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- $_FILES path validated by is_uploaded_file above. 3507 3581 $count = LANGFORGE_Glossary::instance()->import_csv($csv, $from, $to); 3508 echo '<div class="notice notice-success"><p>' . sprintf(3509 /* translators: %d = number of glossary terms imported*/3510 esc_html__('Imported %d term(s).', 'langforge'), (int) $count3511 ) . '</p></div>';3582 echo '<div class="notice notice-success"><p>' . esc_html(sprintf( 3583 /* translators: %d: number of glossary terms imported. */ 3584 _n('Imported %d term.', 'Imported %d terms.', (int) $count, 'langforge'), (int) $count 3585 )) . '</p></div>'; 3512 3586 } else { 3513 3587 echo '<div class="notice notice-error"><p>' . esc_html__('No CSV file uploaded.', 'langforge') . '</p></div>'; … … 4074 4148 4075 4149 /** 4150 * The other multilingual plugins Lang can take the content of, that are running now (BUG-LANG-INTEG-02). 4151 * WPML has its own notice above. 4152 * 4153 * @return array<string,string> Migration source key => plugin name. 4154 */ 4155 public static function active_rival_plugins() { 4156 $rivals = []; 4157 if (defined('POLYLANG_VERSION')) { 4158 $rivals['polylang'] = 'Polylang'; 4159 } 4160 if (defined('TRP_PLUGIN_VERSION') || class_exists('TRP_Translate_Press')) { 4161 $rivals['translatepress'] = 'TranslatePress'; 4162 } 4163 return $rivals; 4164 } 4165 4166 /** 4167 * BUG-LANG-INTEG-02: Polylang or TranslatePress × LangForge coexistence warning. 4168 * 4169 * Next to Lang both print their own hreflang set and language switcher, and TranslatePress also owns 4170 * `<html lang>`: every hreflang twice, two switchers, a Spanish page that says it is English. Same 4171 * fault state as WPML, same way out: move the content with Lang Forge → Migration, then deactivate the 4172 * other plugin. Shown where it is actionable, as the WPML notice. 4173 */ 4174 public function render_rival_coexistence_notice() { 4175 if (!current_user_can('manage_options')) return; 4176 $rivals = self::active_rival_plugins(); 4177 if (!$rivals) return; 4178 4179 $screen = function_exists('get_current_screen') ? get_current_screen() : null; 4180 $sid = $screen ? (string) $screen->id : ''; 4181 if ($sid !== 'plugins' 4182 && strpos($sid, 'langforge') === false 4183 && strpos($sid, 'lang-forge') === false 4184 && strpos($sid, 'lf-') === false) { 4185 return; 4186 } 4187 4188 $migration_url = admin_url('admin.php?page=langforge-migration'); 4189 foreach ($rivals as $name) { 4190 ?> 4191 <div class="notice notice-warning is-dismissible lf-rival-coexistence-notice"> 4192 <p style="font-size:14px;line-height:1.55;margin:6px 0;"> 4193 <strong style="color:#92400e;">⚠ <?php 4194 /* translators: %s: name of another multilingual plugin (Polylang, TranslatePress) */ 4195 echo esc_html(sprintf(__('%s and Lang Forge are both active', 'langforge'), $name)); 4196 ?></strong><br> 4197 <?php esc_html_e('Two multilingual plugins running at the same time conflict on language routing and HTML meta — frontend output may be unpredictable.', 'langforge'); ?> 4198 <br> 4199 <?php 4200 printf( 4201 /* translators: 1: name of another multilingual plugin (Polylang, TranslatePress), 2: link to Lang Forge → Migration */ 4202 esc_html__('To use Lang Forge, deactivate %1$s on the Plugins page. To migrate %1$s content into Lang Forge, run %2$s.', 'langforge'), 4203 esc_html($name), 4204 '<a href="' . esc_url($migration_url) . '"><strong>' . esc_html__('Lang Forge → Migration', 'langforge') . '</strong></a>' 4205 ); 4206 ?> 4207 </p> 4208 </div> 4209 <?php 4210 } 4211 } 4212 4213 /** 4076 4214 * LF audit 2026-04-28 (R2-CP1): inline, non-dismissible callout shown 4077 4215 * at the top of Lang Forge → Languages when WPML is active alongside … … 4165 4303 'leave' => __('Left published. They stay in DB but not linked from the switcher.', 'langforge'), 4166 4304 /* translators: %d: number of posts moved to Draft. */ 4167 'draft' => sprintf(_ _('Moved %d post(s) to Draft.', 'langforge'), $applied),4305 'draft' => sprintf(_n('Moved %d post to Draft.', 'Moved %d posts to Draft.', $applied, 'langforge'), $applied), 4168 4306 /* translators: %d: number of posts deleted. */ 4169 'delete' => sprintf(_ _('Deleted %d post(s).', 'langforge'), $applied),4307 'delete' => sprintf(_n('Deleted %d post.', 'Deleted %d posts.', $applied, 'langforge'), $applied), 4170 4308 ]; 4171 4309 wp_send_json_success([ … … 4174 4312 ]); 4175 4313 } 4314 4315 /** 4316 * The sentence of the AI Translate dialog of String Translation for the strings that have no translation yet: how 4317 * many, in how many languages, and what translating them costs. All three numbers are known when the page is drawn, 4318 * so each takes its own plural form here (_n: one in English, three in Russian, six in Arabic): the sentence the 4319 * form of the strings, the languages a phrase of their own in the form of their number, the cost the word of its 4320 * number. The script of the dialog puts it in as it is (data-missing-text of the button). 4321 * 4322 * @param int $strings The empty slots: a string in a language it has no translation in. 4323 * @param int $languages The languages they are in. 4324 * @param int $cost The credits translating them costs. 4325 * @return string Plain text. 4326 */ 4327 public static function ai_missing_text($strings, $languages, $cost) { 4328 $strings = max(0, (int) $strings); 4329 $languages = max(0, (int) $languages); 4330 $cost = max(0, (int) $cost); 4331 return sprintf( 4332 /* translators: 1: number of untranslated strings (the sentence takes the form of this number), 2: the languages they are in ("across 2 languages", its own plural phrase), 3: credit cost, 4: the word "credit" in the form the cost takes. */ 4333 _n('%1$s string %2$s has no translation yet. Translating it costs %3$s %4$s, and the result is saved automatically.', '%1$s strings %2$s have no translation yet. Translating them costs %3$s %4$s, and the result is saved automatically.', $strings, 'langforge'), 4334 $strings, 4335 sprintf( 4336 /* translators: %s: number of languages. The phrase is the %2$s of "N strings across N languages have no translation yet": translate it with its preposition, in the form and case the number takes there. */ 4337 _n('across %s language', 'across %s languages', $languages, 'langforge'), 4338 $languages 4339 ), 4340 $cost, 4341 /* translators: the unit after a number of AI credits, in the form that number takes ("1 credit", "5 credits"); the AI Translate dialog puts it after the cost. */ 4342 _n('credit', 'credits', $cost, 'langforge') 4343 ); 4344 } 4176 4345 }
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)