Plugin Directory


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

Release 1.3.0 (trunk + tag; see readme changelog)

File:
1 edited

Legend:

Unmodified
Added
Removed
  • langforge/trunk/admin/class-admin.php

    r3752345 r3764005  
    7979        // Lang Forge → Migration → WPML.
    8080        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']);
    8183    }
    8284
    … …  
    207209            'skipped'    => $total_skipped,
    208210            '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),
    209213        ]);
    210214    }
    … …  
    405409            if ($result['success']) {
    406410                /* 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>';
    408412            } else {
    409413                echo '<div class="notice notice-error"><p>' . esc_html__("Migration failed: ", "langforge") . esc_html($result['error']) . '</p></div>';
    … …  
    768772        $is_lf_page    = strpos($page_param, 'langforge-') === 0;
    769773        $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);
    771778
    772779        $is_post_edit  = $screen_base === 'post'; // single post/page edit screen
    … …  
    834841                'resetsOn'               => __('Resets on', 'langforge'),
    835842                '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'),
    836845                'fixApplied'             => __('Fix applied.', 'langforge'),
    837846                'dismissTitle'           => __('Dismiss', 'langforge'),
    … …  
    14941503            if ($result['success']) {
    14951504                /* 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>';
    14971506            } else {
    14981507                echo '<div class="notice notice-error"><p>' . esc_html__("Migration failed: ", "langforge") . esc_html($result['error']) . '</p></div>';
    … …  
    17731782        // Handle save
    17741783        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();
    17761785            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);
    17771788        }
    17781789
    … …  
    19211932                        printf(
    19221933                            /* 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')),
    19241935                            esc_html($from_idx), esc_html($to_idx), esc_html($total_strings)
    19251936                        );
    … …  
    21722183                                data-source="<?php echo esc_attr($default_lang); ?>"
    21732184                                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')); ?>"
    21742186                                data-missing-slots="<?php echo (int) $ai_pending; ?>"
    21752187                                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)); ?>"
    21762189                                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')); ?>"
    21772191                                data-all-slots="<?php echo (int) $ai_all_slots; ?>"
    21782192                                data-all-langs="<?php echo (int) $ai_all_lang_count; ?>"
    … …  
    22452259                        var $modal = jQuery('#lf-ai-modal');
    22462260                        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')); ?>'; }
    22482261
    22492262                        function runBulk($btn, mode){
    … …  
    22642277                                    var n = (r.data && r.data.translated) || 0;
    22652278                                    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);
    22672281                                    if (errs.length) msg2 += '\n\n' + errs.join('\n');
    22682282                                    $btn.html('<span class="dashicons dashicons-yes"></span> ' + msg2);
    … …  
    22912305                            var missing = parseInt($btn.data('missing-slots'),10)||0;
    22922306                            var missingCost = parseInt($btn.data('missing-cost'),10)||0;
    2293                             var missingLangs = parseInt($btn.data('missing-langs'),10)||0;
    22942307                            var allSlots = parseInt($btn.data('all-slots'),10)||0;
    22952308                            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') || '');
    22962313
    22972314                            var title, body, actions = [];
    22982315                            if (missing > 0) {
    22992316                                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' });
    23032320                                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' });
    23052322                                }
    23062323                            } else if (allSlots > 0) {
    23072324                                title = '<?php echo esc_js(__('Everything is already translated', 'langforge')); ?>';
    23082325                                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' });
    23112328                            } else {
    23122329                                title = '<?php echo esc_js(__('Nothing to translate', 'langforge')); ?>';
    … …  
    24952512    /**
    24962513 * 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.
    24972519 */
    24982520    private function save_string_translations() {
    24992521        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in render_string_translation_page (lf_strings_nonce / langforge_save_strings) before this method runs.
    25002522        if (empty($_POST['strings']) || !is_array($_POST['strings'])) {
    2501             return;
     2523            return [];
    25022524        }
    25032525
    … …  
    25082530            : [];
    25092531
     2532        $source_lang = LANGFORGE_Core::instance()->get_default_language();
     2533        $saved = [];
     2534
    25102535        foreach ($posted_strings as $raw_name => $translations) {
    25112536            if (!is_array($translations)) {
    … …  
    25202545                : ($global_domain ?: 'default');
    25212546
     2547            $values = [];
    25222548            foreach ($translations as $lang => $value) {
    25232549                $lang = sanitize_text_field($lang);
    25242550                $value = LANGFORGE_Strings_Screen::sanitize_translation($domain, $value);
     2551                $values[$lang] = $value;
    25252552
    25262553                if (!empty($value)) {
    … …  
    25282555                }
    25292556            }
     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            }
    25302563        }
    25312564        // phpcs:enable WordPress.Security.NonceVerification.Missing
     2565
     2566        return LANGFORGE_Strings_Screen::glossary_report($saved, $source_lang);
    25322567    }
    25332568
    … …  
    28112846
    28122847    /**
     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    /**
    28132976 * Render translation status page
    28142977 */
    … …  
    29603123                $total = $total_count;
    29613124
    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);
    29953127                $drafts  = max(0, $translated - $published);
    29963128                $missing = max(0, $total - $translated);
    … …  
    31163248                </p>
    31173249                <?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);
    31773251                if (empty($outdated_posts)):
    31783252                    echo '<p style="color:#059669;font-weight:600;">' . esc_html__('Nothing flagged. Translations are current with their source.', 'langforge') . '</p>';
    … …  
    32593333                                        <?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; } ?>
    32603334                                        <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)); ?>"
    32623336                                                style="display:inline-flex;align-items:center;gap:5px;">
    32633337                                            <?php esc_html_e('Re-translate', 'langforge'); ?>
    … …  
    32863360                        var failMsg    = <?php echo wp_json_encode(__('Re-translate failed.', 'langforge')); ?>;
    32873361                        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)); ?>;
    32893363                        document.querySelectorAll('.lf-outdated-retranslate').forEach(function(btn) {
    32903364                            btn.addEventListener('click', function() {
    … …  
    34693543                $errors[] = sprintf(
    34703544                    /* 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'),
    34723546                    255
    34733547                );
    … …  
    34763550                $errors[] = sprintf(
    34773551                    /* 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'),
    34793553                    255
    34803554                );
    … …  
    35063580                $csv = file_get_contents($_FILES['glossary_csv']['tmp_name']); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- $_FILES path validated by is_uploaded_file above.
    35073581                $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) $count
    3511                 ) . '</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>';
    35123586            } else {
    35133587                echo '<div class="notice notice-error"><p>' . esc_html__('No CSV file uploaded.', 'langforge') . '</p></div>';
    … …  
    40744148
    40754149    /**
     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    /**
    40764214 * LF audit 2026-04-28 (R2-CP1): inline, non-dismissible callout shown
    40774215 * at the top of Lang Forge → Languages when WPML is active alongside
    … …  
    41654303            'leave'  => __('Left published. They stay in DB but not linked from the switcher.', 'langforge'),
    41664304            /* 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),
    41684306            /* 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),
    41704308        ];
    41714309        wp_send_json_success([
    … …  
    41744312        ]);
    41754313    }
     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    }
    41764345}
Note: See TracChangeset for help on using the changeset viewer.