PluginProbe
Translate and boost SEO – Automatic AI translation – wpLingua / 2.16.8
Translate and boost SEO – Automatic AI translation – wpLingua v2.16.8
2.16.8 2.16.7 2.16.6 2.16.5 2.16.4 2.16.3 2.16.2 2.16.1 2.16.0 2.15.2 2.15.1 2.15.0 2.14.3 2.14.2 2.14.1 2.14.0 2.13.1 2.13.0 2.12.3 2.12.2 2.12.1 trunk 1.0.3 1.0.4 1.0.5 All 113 releases
← All changes | inc/dictionary.php +968 -968 2.16.12.16.8 View file →
@@ -1,968 +1,968 @@
1 -<?php
2 -
3 -// If this file is called directly, abort.
4 -if ( ! defined( 'WPINC' ) ) {
5 - die;
6 -}
7 -
8 -
9 -/**
10 - * Get dictionary entries
11 - *
12 - * @return array
13 - */
14 -function wplng_dictionary_get_entries() {
15 -
16 - $entries_clear = array();
17 - $entries_json = get_option( 'wplng_dictionary_entries' );
18 - $entries = json_decode( $entries_json, true );
19 -
20 - if ( empty( $entries ) || ! is_array( $entries ) ) {
21 - return array();
22 - }
23 -
24 - foreach ( $entries as $entry ) {
25 -
26 - /**
27 - * Get and check the source
28 - */
29 -
30 - if ( ! isset( $entry['source'] )
31 - || ! is_string( $entry['source'] )
32 - || mb_strlen( $entry['source'] ) >= 256
33 - ) {
34 - continue;
35 - }
36 -
37 - $source_clear = wplng_text_esc( $entry['source'] );
38 - $source_clear = str_replace( '⊕', '', $source_clear );
39 - $source_clear = str_replace( '⊖', '', $source_clear );
40 - $source_clear = preg_replace( '#\[wplng_dictionary.*\]#', '', $source_clear );
41 - $source_clear = preg_replace( '#\[\/wplng_dictionary\]#', '', $source_clear );
42 -
43 - /**
44 - * Check if rule already exist
45 - */
46 -
47 - $already_in = false;
48 - foreach ( $entries_clear as $entry_clear ) {
49 - if ( $source_clear === $entry_clear['source'] ) {
50 - $already_in = true;
51 - break;
52 - }
53 - }
54 -
55 - if ( $already_in ) {
56 - continue;
57 - }
58 -
59 - /**
60 - * Get and check the rules
61 - */
62 -
63 - if ( ! isset( $entry['rules'] )
64 - || ! is_array( $entry['rules'] )
65 - ) {
66 - /**
67 - * Create the clear entries
68 - */
69 -
70 - $entries_clear[] = array(
71 - 'source' => $source_clear,
72 - );
73 - continue;
74 - }
75 -
76 - $rules_clear = array();
77 -
78 - foreach ( $entry['rules'] as $language_id => $rule ) {
79 - if ( ! wplng_is_valid_language_id( $language_id )
80 - || ! is_string( $rule )
81 - || '' === trim( $rule )
82 - || $rule === $source_clear
83 - || mb_strlen( $rule ) >= 256
84 - ) {
85 - continue;
86 - }
87 -
88 - $rules_clear[ $language_id ] = wplng_text_esc( $rule );
89 - }
90 -
91 - /**
92 - * Create the clear entries
93 - */
94 -
95 - if ( empty( $rules_clear ) ) {
96 - $entries_clear[] = array(
97 - 'source' => $source_clear,
98 - );
99 - } else {
100 - $entries_clear[] = array(
101 - 'source' => $source_clear,
102 - 'rules' => $rules_clear,
103 - );
104 - }
105 - }
106 -
107 - /**
108 - * Sort dictionary entries by sources length
109 - */
110 -
111 - usort(
112 - $entries_clear,
113 - function ( $a, $b ) {
114 - return mb_strlen( $b['source'] ) - mb_strlen( $a['source'] );
115 - }
116 - );
117 -
118 - /**
119 - * Apply wplng_dictionary_entries filter
120 - */
121 -
122 - $entries_clear = apply_filters(
123 - 'wplng_dictionary_entries',
124 - $entries_clear
125 - );
126 -
127 - return $entries_clear;
128 -}
129 -
130 -
131 -/**
132 - * Get dictionary entries in JSON format
133 - *
134 - * @return string JSON
135 - */
136 -function wplng_dictionary_get_entries_json() {
137 - return wp_json_encode(
138 - wplng_dictionary_get_entries(),
139 - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
140 - );
141 -}
142 -
143 -
144 -/**
145 - * Add dictionary tag on a texts list
146 - *
147 - * @param array $texts
148 - * @param array $dictionary_entries
149 - * @return array Texts tagged
150 - */
151 -function wplng_dictionary_add_tags( $texts, $language_target_id, $dictionary_entries = false ) {
152 -
153 - if ( false === $dictionary_entries ) {
154 - $dictionary_entries = wplng_dictionary_get_entries();
155 - }
156 -
157 - /**
158 - * Preg quote sources texts
159 - */
160 -
161 - foreach ( $dictionary_entries as $entry_key => $entry ) {
162 - $dictionary_entries[ $entry_key ]['source'] = preg_quote( $entry['source'], '#' );
163 - }
164 -
165 - foreach ( $texts as $text_key => $text ) {
166 -
167 - /**
168 - * Get used dictionary entry in current text
169 - */
170 -
171 - $entries_used = array();
172 -
173 - foreach ( $dictionary_entries as $entry_key => $entry ) {
174 -
175 - $has_rules = isset( $entry['rules'] )
176 - && is_array( $entry['rules'] )
177 - && ! empty( $entry['rules'] );
178 -
179 - if ( $has_rules && empty( $entry['rules'][ $language_target_id ] ) ) {
180 - continue;
181 - }
182 -
183 - $preg_match = array();
184 -
185 - preg_match_all(
186 - '#' . $entry['source'] . '#iu',
187 - $text,
188 - $preg_match
189 - );
190 -
191 - $preg_match = $preg_match[0];
192 -
193 - foreach ( $preg_match as $key => $match ) {
194 -
195 - $upper = 'none';
196 - // Caseless scripts (CJK, digits…) must not trigger uppercase detection:
197 - // mb_strtoupper('テスト') === 'テスト' is true, which would incorrectly
198 - // mark every CJK match as 'all' and uppercase the replacement.
199 - $is_caseless = mb_strtolower( $match ) === mb_strtoupper( $match );
200 - if ( ! $is_caseless ) {
201 - if ( $match === mb_strtoupper( $match ) ) {
202 - // Check uppercase
203 - $upper = 'all';
204 - } elseif ( $match === mb_strtoupper( mb_substr( $match, 0, 1 ) ) . mb_substr( $match, 1 ) ) {
205 - // Check capitalize
206 - $upper = 'first';
207 - }
208 - }
209 -
210 - $entry_current = $entry;
211 - $entry_current['source'] = $match;
212 - $entry_current['key'] = $entry_key;
213 - $entry_current['upper'] = $upper;
214 - $entries_used[] = $entry_current;
215 - }
216 - }
217 -
218 - /**
219 - * Remove dupicate in used entries
220 - */
221 -
222 - $entries_used = array_map( 'serialize', $entries_used );
223 - $entries_used = array_unique( $entries_used );
224 - $entries_used = array_map( 'unserialize', $entries_used );
225 -
226 - /**
227 - * Put tempory tag in current text
228 - */
229 -
230 - foreach ( $entries_used as $entry_key => $entry_used ) {
231 - // CJK scripts (Japanese, Chinese, etc.) have no word separators,
232 - // so word boundaries cannot be used. For all other scripts (including
233 - // Greek), (*UCP) makes \b Unicode-aware so letters are treated as \w.
234 - if ( preg_match( '#[\x{2E80}-\x{9FFF}\x{F900}-\x{FAFF}]#u', $entry_used['source'] ) ) {
235 - $boundary_pattern = '#' . $entry_used['source'] . '#u';
236 - } else {
237 - $boundary_pattern = '#(*UCP)\b' . $entry_used['source'] . '\b#u';
238 - }
239 - $text = preg_replace(
240 - $boundary_pattern,
241 - '⊕' . str_repeat( '⊖', $entry_key + 1 ) . '⊕',
242 - $text
243 - );
244 - }
245 -
246 - /**
247 - * Repace tempory tag by final tag
248 - */
249 -
250 - foreach ( $entries_used as $entry_key => $entry ) {
251 -
252 - $search = '⊕' . str_repeat( '⊖', $entry_key + 1 ) . '⊕';
253 -
254 - $replace = '[wplng_dictionary ';
255 - $replace .= 'key="' . $entry['key'] . '" ';
256 - $replace .= 'upper="' . $entry['upper'] . '"]';
257 - $replace .= $entry['source'];
258 - $replace .= '[/wplng_dictionary]';
259 -
260 - $text = str_replace(
261 - $search,
262 - $replace,
263 - $text
264 - );
265 -
266 - }
267 -
268 - /**
269 - * Set updated text in text array
270 - */
271 -
272 - $texts[ $text_key ] = $text;
273 - }
274 -
275 - return $texts;
276 -}
277 -
278 -
279 -/**
280 - * Transform dictionary tags to translated text
281 - *
282 - * @param array $texts
283 - * @param array $dictionary_entries
284 - * @return array Texts untagged
285 - */
286 -function wplng_dictionary_replace_tags( $texts, $language_target_id, $dictionary_entries = false ) {
287 -
288 - if ( false === $dictionary_entries ) {
289 - $dictionary_entries = wplng_dictionary_get_entries();
290 - }
291 -
292 - foreach ( $texts as $text_key => $text ) {
293 -
294 - // Ensure spaces around dictionary tags when adjacent to letters from scripts
295 - // that use word spaces (Latin, Greek, Cyrillic, Arabic, Hebrew, Devanagari…).
296 - // This fixes CJK-source → alphabetic-target replacements where the translation
297 - // API may not add spaces around the preserved tag (e.g. Japanese → French).
298 - // CJK scripts (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) are intentionally
299 - // excluded: they don't use word spaces, so no space should be inserted there.
300 - $text = preg_replace(
301 - '/(?<=[A-Za-z\x{00C0}-\x{04FF}\x{0590}-\x{097F}\d])\[wplng_dictionary /u',
302 - ' [wplng_dictionary ',
303 - $text
304 - );
305 - $text = preg_replace(
306 - '/\[\/wplng_dictionary\](?=[A-Za-z\x{00C0}-\x{04FF}\x{0590}-\x{097F}\d])/u',
307 - '[/wplng_dictionary] ',
308 - $text
309 - );
310 -
311 - foreach ( $dictionary_entries as $key => $entry ) {
312 -
313 - // For ruls "Do not translate
314 - $replacement = $entry['source'];
315 -
316 - // For specific rule by language
317 - if ( ! empty( $entry['rules'][ $language_target_id ] ) ) {
318 - $replacement = $entry['rules'][ $language_target_id ];
319 - }
320 -
321 - // Replacement for text in uppercase
322 - $text = preg_replace(
323 - '#\[wplng_dictionary key="' . $key . '" upper="all"\].+\[\/wplng_dictionary\]#Uu',
324 - mb_strtoupper( $replacement ),
325 - $text
326 - );
327 -
328 - // Replacement for text when only the first letter is uppercase
329 - $text = preg_replace(
330 - '#\[wplng_dictionary key="' . $key . '" upper="first"\].+\[\/wplng_dictionary\]#Uu',
331 - mb_strtoupper( mb_substr( $replacement, 0, 1 ) ) . mb_substr( $replacement, 1 ),
332 - $text
333 - );
334 -
335 - // Replacement for other case
336 - $text = preg_replace(
337 - '#\[wplng_dictionary key="' . $key . '" upper="none"\].+\[\/wplng_dictionary\]#Uu',
338 - $replacement,
339 - $text
340 - );
341 -
342 - }
343 -
344 - // Cleaning of any residual rules
345 - $text = preg_replace(
346 - '#\[wplng_dictionary key=".*" upper=".*"\](.+)\[\/wplng_dictionary\]#Uu',
347 - '${1}',
348 - $text
349 - );
350 -
351 - $texts[ $text_key ] = $text;
352 - }
353 -
354 - return $texts;
355 -}
356 -
357 -
358 -/**
359 - * Fired when the 'wplng_dictionary_entries' option is saved.
360 - *
361 - * Compares the old and new dictionary entries to determine which source texts
362 - * and target languages are affected by the change. Builds $update_todo, a list
363 - * of items whose cached translations need to be regenerated.
364 - *
365 - * Each item in $update_todo has the shape:
366 - * [ 'source' => string, 'impacted_languages' => 'all' | string[] ]
367 - *
368 - * 'impacted_languages' is the string 'all' when the entry has no per-language
369 - * rules (i.e., it is a "never translate" rule that applies to every language).
370 - *
371 - * @param string $old_value The previous JSON value of the option.
372 - * @param string $new_value The new JSON value of the option.
373 - */
374 -function wplng_on_dictionary_entries_updated( $old_value, $new_value ) {
375 -
376 - // Entries are stored as a JSON string in the WordPress options table.
377 - // Decode them; fall back to an empty array if the value is absent or invalid.
378 - $old_entries = json_decode( $old_value, true );
379 - $new_entries = json_decode( $new_value, true );
380 -
381 - if ( ! is_array( $old_entries ) ) {
382 - $old_entries = array();
383 - }
384 -
385 - if ( ! is_array( $new_entries ) ) {
386 - $new_entries = array();
387 - }
388 -
389 - // Re-index entries by their source text for O(1) lookup in the loops below.
390 - // The dictionary is stored as a plain list; keying by source avoids nested loops.
391 - $old_by_source = array();
392 - foreach ( $old_entries as $entry ) {
393 - if ( isset( $entry['source'] ) ) {
394 - $old_by_source[ $entry['source'] ] = $entry;
395 - }
396 - }
397 -
398 - $new_by_source = array();
399 - foreach ( $new_entries as $entry ) {
400 - if ( isset( $entry['source'] ) ) {
401 - $new_by_source[ $entry['source'] ] = $entry;
402 - }
403 - }
404 -
405 - // Will hold the list of source/language pairs whose translations need refreshing.
406 - $update_todo = array();
407 -
408 - /**
409 - * Detect removed entries.
410 - *
411 - * When a rule is deleted, cached translations built with that rule may be
412 - * outdated (e.g., a forced translation that should revert to the default
413 - * AI output). All languages covered by the old rule must be refreshed.
414 - */
415 -
416 - foreach ( $old_by_source as $source => $old_entry ) {
417 -
418 - // Skip entries that still exist in the new value.
419 - if ( isset( $new_by_source[ $source ] ) ) {
420 - continue;
421 - }
422 -
423 - $impacted_languages = array();
424 -
425 - if ( empty( $old_entry['rules'] ) ) {
426 - // No per-language rules → this was a "never translate" rule affecting all languages.
427 - $impacted_languages = 'all';
428 - } else {
429 - // Collect every language that had a rule for this source.
430 - foreach ( $old_entry['rules'] as $language_id => $rule ) {
431 - $impacted_languages[] = $language_id;
432 - }
433 - }
434 -
435 - $update_todo[] = array(
436 - 'source' => $source,
437 - 'impacted_languages' => $impacted_languages,
438 - );
439 - }
440 -
441 - /**
442 - * Detect added and modified entries
443 - */
444 -
445 - foreach ( $new_by_source as $source => $new_entry ) {
446 - if ( ! isset( $old_by_source[ $source ] ) ) {
447 -
448 - /**
449 - * Added.
450 - *
451 - * Existing translations were produced without this rule, so they may
452 - * not yet reflect the new forced translation or exclusion.
453 - */
454 -
455 - $impacted_languages = array();
456 -
457 - if ( empty( $new_entry['rules'] ) ) {
458 - // No per-language rules → "never translate" rule affecting all languages.
459 - $impacted_languages = 'all';
460 - } else {
461 - // Collect every language that has a rule for this new source.
462 - foreach ( $new_entry['rules'] as $language_id => $rule ) {
463 - $impacted_languages[] = $language_id;
464 - }
465 - }
466 -
467 - $update_todo[] = array(
468 - 'source' => $source,
469 - 'impacted_languages' => $impacted_languages,
470 - );
471 -
472 - } elseif ( $new_entry != $old_by_source[ $source ] ) {
473 -
474 - /**
475 - * Modified.
476 - *
477 - * Loose comparison (!=) is intentional: strict (!==) would treat arrays
478 - * with the same key/value pairs but different key order as unequal,
479 - * causing false positives when WordPress re-encodes the JSON.
480 - */
481 -
482 - $old_entry = $old_by_source[ $source ];
483 - $impacted_languages = array();
484 -
485 - if ( empty( $new_entry['rules'] ) || empty( $old_entry['rules'] )
486 - || ! is_array( $new_entry['rules'] ) || ! is_array( $old_entry['rules'] )
487 - ) {
488 - // One side has no per-language rules, meaning the entry was or became a
489 - // "never translate" rule — all languages are affected by the change.
490 - $impacted_languages = 'all';
491 - } else {
492 - // Find every language whose rule value changed, was added, or was removed.
493 - // array_diff_assoc() catches rules that changed value or were newly added.
494 - // array_diff_key() catches rules that existed in the old entry but were removed.
495 - // Merging both and taking the keys gives the full list of impacted languages.
496 - $impacted_languages = array_keys(
497 - array_merge(
498 - array_diff_assoc( $new_entry['rules'], $old_entry['rules'] ),
499 - array_diff_key( $old_entry['rules'], $new_entry['rules'] ),
500 - )
501 - );
502 - }
503 -
504 - if ( ! empty( $impacted_languages ) ) {
505 - $update_todo[] = array(
506 - 'source' => $source,
507 - 'impacted_languages' => $impacted_languages,
508 - );
509 - }
510 - }
511 - }
512 -
513 - if ( empty( $update_todo ) ) {
514 - return;
515 - }
516 -
517 - // Normalize: if impacted_languages lists every active target language,
518 - // collapse it to 'all' to avoid unnecessary per-language filtering later.
519 - $all_target_ids = wplng_get_languages_target_ids();
520 - foreach ( $update_todo as &$item ) {
521 - if ( is_array( $item['impacted_languages'] )
522 - && ! array_diff( $all_target_ids, $item['impacted_languages'] )
523 - ) {
524 - $item['impacted_languages'] = 'all';
525 - }
526 - }
527 - unset( $item );
528 -
529 - /**
530 - * Get all translations
531 - */
532 -
533 - $translations = wplng_get_translations();
534 - $translations_to_update = array();
535 -
536 - // Foreach translations
537 - foreach ( $translations as $index_strings => $translations_chunk ) {
538 - foreach ( $translations_chunk as $key => $translation ) {
539 -
540 - // Foreach $update_todo
541 - foreach ( $update_todo as $todo ) {
542 - $source_todo = mb_strtolower( $todo['source'] );
543 - $source_translation = mb_strtolower( $translation['source'] );
544 -
545 - // Use a simple substring match for CJK sources (no word boundaries in CJK text).
546 - // For all other scripts, require word boundaries to avoid false matches (e.g. "men" in "women").
547 - $is_cjk = (bool) preg_match( '#[\x{2E80}-\x{9FFF}\x{F900}-\x{FAFF}]#u', $source_todo );
548 - $pattern = $is_cjk
549 - ? '#' . preg_quote( $source_todo, '#' ) . '#iu'
550 - : '#(*UCP)\b' . preg_quote( $source_todo, '#' ) . '\b#iu';
551 -
552 - if ( ! wplng_str_contains( $source_translation, $source_todo )
553 - || ! preg_match( $pattern, $source_translation )
554 - ) {
555 - continue;
556 - }
557 -
558 - $impacted_languages = 'all';
559 -
560 - $translation_review = $translation['review'] ?? array();
561 -
562 - if ( $todo['impacted_languages'] !== 'all' ) {
563 -
564 - $impacted_languages = array();
565 -
566 - foreach ( $translation['translations'] as $language_id => $value ) {
567 - if ( in_array( $language_id, $todo['impacted_languages'], true )
568 - && ! in_array( $language_id, $translation_review, true )
569 - ) {
570 - $impacted_languages[] = $language_id;
571 - }
572 - }
573 - } else {
574 -
575 - // Even when the dictionary rule impacts every language, translations
576 - // that were manually reviewed must still be protected from being
577 - // flagged for automatic regeneration.
578 - $impacted_languages = array();
579 -
580 - foreach ( $translation['translations'] as $language_id => $value ) {
581 - if ( ! in_array( $language_id, $translation_review, true ) ) {
582 - $impacted_languages[] = $language_id;
583 - }
584 - }
585 - }
586 -
587 - // If impacted_languages covers every language that has been translated,
588 - // collapse it to 'all' to avoid unnecessary per-language filtering later.
589 - if ( is_array( $impacted_languages ) ) {
590 - $temp_translations = $translation['translations'];
591 - foreach ( $impacted_languages as $language_id ) {
592 - unset( $temp_translations[ $language_id ] );
593 - }
594 - if ( empty( $temp_translations ) ) {
595 - $impacted_languages = 'all';
596 - }
597 - }
598 -
599 - $translations_to_update[] = array(
600 - 'source' => $translation['source'],
601 - 'post_id' => $translation['post_id'],
602 - 'impacted_languages' => $impacted_languages,
603 - );
604 -
605 - }
606 - }
607 - }
608 -
609 - // Clear $translations_to_update when no loanguage impacted
610 - foreach ( $translations_to_update as $key => $value ) {
611 - if ( empty( $value['impacted_languages'] ) ) {
612 - unset( $translations_to_update[ $key ] );
613 - }
614 - }
615 -
616 - // Deduplicate entries by post_id: the same translation can match several
617 - // dictionary rules, producing multiple entries for the same post. Merge
618 - // their impacted_languages (union), collapsing to 'all' if any entry is 'all'.
619 - $translations_to_update_by_post = array();
620 -
621 - foreach ( $translations_to_update as $value ) {
622 -
623 - $post_id = $value['post_id'];
624 -
625 - if ( ! isset( $translations_to_update_by_post[ $post_id ] ) ) {
626 - $translations_to_update_by_post[ $post_id ] = $value;
627 - continue;
628 - }
629 -
630 - $existing = $translations_to_update_by_post[ $post_id ];
631 -
632 - if ( 'all' === $existing['impacted_languages']
633 - || 'all' === $value['impacted_languages']
634 - ) {
635 - $translations_to_update_by_post[ $post_id ]['impacted_languages'] = 'all';
636 - } else {
637 - $translations_to_update_by_post[ $post_id ]['impacted_languages'] = array_values(
638 - array_unique(
639 - array_merge( $existing['impacted_languages'], $value['impacted_languages'] )
640 - )
641 - );
642 - }
643 - }
644 -
645 - $translations_to_update = array_values( $translations_to_update_by_post );
646 -
647 - if ( ! empty( $translations_to_update ) ) {
648 - // Store the list in a short-lived transient so it survives the redirect
649 - // that options.php performs after saving. The page function reads and
650 - // immediately deletes it so it is only displayed once.
651 - set_transient( 'wplng_dictionary_updated_data', $translations_to_update, 30 );
652 - }
653 -}
654 -
655 -
656 -/**
657 - * AJAX handler: process one translation update triggered by a dictionary rule change.
658 - *
659 - * Receives post_id and impacted_languages from the JS queue.
660 - * For now, echoes the received data back so the JS loop can be validated end-to-end.
661 - *
662 - * @return void
663 - */
664 -function wplng_ajax_dictionary_update_translations() {
665 -
666 - // ------------------------------------------------------------------------
667 - // Get and check basic data
668 - // ------------------------------------------------------------------------
669 -
670 - /**
671 - * Check the nonce to prevent unauthorized requests
672 - */
673 -
674 - check_ajax_referer( 'wplng_dictionary_update_translations', 'nonce' );
675 -
676 - /**
677 - * Check the user's permissions
678 - */
679 -
680 - if ( ! current_user_can( 'manage_options' ) ) {
681 - wp_send_json_error(
682 - array(
683 - 'error' => true,
684 - 'message' => __( 'Error [1]: The current user is not authorized to edit translations', 'wplingua' ),
685 - )
686 - );
687 - return;
688 - }
689 -
690 - /**
691 - * Check the post ID parameter
692 - */
693 -
694 - if ( empty( $_POST['post_id'] ) ) {
695 - wp_send_json_error(
696 - array(
697 - 'error' => true,
698 - 'message' => __( 'Error [2]: Invalid post ID parameter', 'wplingua' ),
699 - )
700 - );
701 - return;
702 - }
703 -
704 - $post_id = intval( $_POST['post_id'] );
705 -
706 - /**
707 - * Check "impacted_languages" parameter
708 - */
709 -
710 - if ( empty( $_POST['impacted_languages'] )
711 - || ! is_string( $_POST['impacted_languages'] )
712 - ) {
713 - wp_send_json_error(
714 - array(
715 - 'error' => true,
716 - 'message' => __( 'Error [3]: Invalid impacted languages parameter', 'wplingua' ),
717 - )
718 - );
719 - return;
720 - }
721 -
722 - $impacted_languages = sanitize_text_field( wp_unslash( $_POST['impacted_languages'] ) );
723 -
724 - // Keep the raw (pre-decode) string: it is what was used to build the
725 - // "check" value server-side, and is needed below to verify it again.
726 - $impacted_languages_raw = $impacted_languages;
727 -
728 - if ( $impacted_languages !== 'all' ) {
729 -
730 - // We suppose it's a JSON with the list of impacted languages IDs
731 - $impacted_languages = json_decode( $impacted_languages, true );
732 -
733 - if ( ! is_array( $impacted_languages )
734 - || ! wplng_is_valid_language_ids( $impacted_languages )
735 - ) {
736 - wp_send_json_error(
737 - array(
738 - 'error' => true,
739 - 'message' => __( 'Error [4]: Invalid impacted languages data', 'wplingua' ),
740 - )
741 - );
742 - return;
743 - }
744 - }
745 -
746 - /**
747 - * Check the "check" parameter.
748 - *
749 - * It is an encrypted copy of "post_id-impacted_languages" generated when
750 - * the HTML was rendered (see wplng_option_page_dictionary_update_translations_html()).
751 - * Decrypting it and comparing it to the values actually received prevents
752 - * the browser (or an attacker via devtools) from tampering with post_id
753 - * or impacted_languages before the request is sent.
754 - */
755 -
756 - if ( empty( $_POST['check'] ) || ! is_string( $_POST['check'] ) ) {
757 - wp_send_json_error(
758 - array(
759 - 'error' => true,
760 - 'message' => __( 'Error [5]: Parameter "check" not found', 'wplingua' ),
761 - )
762 - );
763 - return;
764 - }
765 -
766 - $check_received = wplng_encryption_decrypt( sanitize_text_field( wp_unslash( $_POST['check'] ) ) );
767 - $check_expected = $post_id . '-' . $impacted_languages_raw;
768 -
769 - if ( '' === $check_received || ! hash_equals( $check_expected, $check_received ) ) {
770 - wp_send_json_error(
771 - array(
772 - 'error' => true,
773 - 'message' => __( 'Error [6]: Invalid "check" parameter', 'wplingua' ),
774 - )
775 - );
776 - return;
777 - }
778 -
779 - /**
780 - * Get and check the translation post
781 - */
782 -
783 - $post_translation = get_post( $post_id, ARRAY_A );
784 -
785 - if ( ! isset( $post_translation['post_type'] )
786 - || $post_translation['post_type'] !== 'wplng_translation'
787 - ) {
788 - wp_send_json_error(
789 - array(
790 - 'error' => true,
791 - 'message' => __( 'Error [7]: Invalid post ID or post type', 'wplingua' ),
792 - )
793 - );
794 - return;
795 - }
796 -
797 - // ------------------------------------------------------------------------
798 - // Apply the update to the relevant translation
799 - // ------------------------------------------------------------------------
800 -
801 - if ( $impacted_languages === 'all' ) {
802 -
803 - // ------------------------------------------------------------------------
804 - // Delete the post if all languages are impacted
805 - // ------------------------------------------------------------------------
806 -
807 - if ( empty( wp_delete_post( $post_id, true ) ) ) {
808 - wp_send_json_error(
809 - array(
810 - 'error' => true,
811 - 'message' => __( 'Error [8]: Failed to delete the translation', 'wplingua' ),
812 - )
813 - );
814 - return;
815 - }
816 -
817 - wp_send_json_success(
818 - array(
819 - 'post_id' => $post_id,
820 - 'impacted_languages' => $impacted_languages,
821 - )
822 - );
823 - return;
824 -
825 - } else {
826 -
827 - // ------------------------------------------------------------------------
828 - // Clear impacted translation in translation post
829 - // ------------------------------------------------------------------------
830 -
831 - /**
832 - * Check post meta : All meta
833 - */
834 -
835 - $meta = get_post_meta( $post_id );
836 -
837 - if ( ! isset( $meta['wplng_translation_translations'][0] )
838 - || ! is_string( $meta['wplng_translation_translations'][0] )
839 - ) {
840 - wp_send_json_error(
841 - array(
842 - 'error' => true,
843 - 'message' => __( 'Error [9]: Translation meta "translations" not found', 'wplingua' ),
844 - )
845 - );
846 - return;
847 - }
848 -
849 - /**
850 - * Check post meta : Translations meta
851 - */
852 -
853 - $translation_meta = json_decode( $meta['wplng_translation_translations'][0], true );
854 -
855 - if ( empty( $translation_meta ) || ! is_array( $translation_meta ) ) {
856 - wp_send_json_error(
857 - array(
858 - 'error' => true,
859 - 'message' => __( 'Error [10]: Invalid translation meta', 'wplingua' ),
860 - )
861 - );
862 - return;
863 - }
864 -
865 - /**
866 - * Check post meta : Original language
867 - */
868 -
869 - if ( empty( $meta['wplng_translation_original_language_id'][0] ) ) {
870 - wp_send_json_error(
871 - array(
872 - 'error' => true,
873 - 'message' => __( 'Error [11]: Translation meta "original language" not found', 'wplingua' ),
874 - )
875 - );
876 - return;
877 - }
878 -
879 - $original_language_id_meta = $meta['wplng_translation_original_language_id'][0];
880 -
881 - if ( $original_language_id_meta !== wplng_get_language_website_id() ) {
882 - wp_send_json_error(
883 - array(
884 - 'error' => true,
885 - 'message' => __( 'Error [12]: The language of the translation is not the same as the language of the site\'s settings', 'wplingua' ),
886 - )
887 - );
888 - return;
889 - }
890 -
891 - /**
892 - * Generate the new translation meta
893 - */
894 -
895 - $translation_meta_new = array();
896 -
897 - foreach ( $translation_meta as $key => $value ) {
898 - if ( ! isset( $value['language_id'] )
899 - || ! wplng_is_valid_language_id( $value['language_id'] )
900 - || ! isset( $value['translation'] )
901 - || ! is_string( $value['translation'] )
902 - ) {
903 - continue;
904 - }
905 -
906 - if ( ! in_array( $value['language_id'], $impacted_languages, true )
907 - || ! isset( $value['status'] )
908 - || $value['status'] !== 'generated'
909 - ) {
910 - $translation_meta_new[] = $value;
911 - }
912 - }
913 -
914 - /**
915 - * Update the translation meta
916 - */
917 -
918 - if ( empty( $translation_meta_new ) ) {
919 -
920 - /**
921 - * Delete the translation post if all translation is finaly impacted
922 - */
923 - if ( empty( wp_delete_post( $post_id, true ) ) ) {
924 - wp_send_json_error(
925 - array(
926 - 'error' => true,
927 - 'message' => __( 'Error [13]: Failed to delete the translation', 'wplingua' ),
928 - )
929 - );
930 - return;
931 - }
932 - } else {
933 -
934 - /**
935 - * Update the translations post meta
936 - */
937 -
938 - $meta_return = update_post_meta(
939 - $post_id,
940 - 'wplng_translation_translations',
941 - wp_json_encode(
942 - $translation_meta_new,
943 - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
944 - )
945 - );
946 -
947 - if ( false === $meta_return ) {
948 - wp_send_json_error(
949 - array(
950 - 'error' => true,
951 - 'message' => __( 'Error [14]: Failed to update the translation meta', 'wplingua' ),
952 - )
953 - );
954 - return;
955 - }
956 -
957 - // Clear cache explicitly.
958 - wplng_clear_translations_cache();
959 - }
960 - }
961 -
962 - wp_send_json_success(
963 - array(
964 - 'post_id' => $post_id,
965 - 'impacted_languages' => $impacted_languages,
966 - )
967 - );
968 -}
1 +<?php
2 +
3 +// If this file is called directly, abort.
4 +if ( ! defined( 'WPINC' ) ) {
5 + die;
6 +}
7 +
8 +
9 +/**
10 + * Get dictionary entries
11 + *
12 + * @return array
13 + */
14 +function wplng_dictionary_get_entries() {
15 +
16 + $entries_clear = array();
17 + $entries_json = get_option( 'wplng_dictionary_entries' );
18 + $entries = json_decode( $entries_json, true );
19 +
20 + if ( empty( $entries ) || ! is_array( $entries ) ) {
21 + return array();
22 + }
23 +
24 + foreach ( $entries as $entry ) {
25 +
26 + /**
27 + * Get and check the source
28 + */
29 +
30 + if ( ! isset( $entry['source'] )
31 + || ! is_string( $entry['source'] )
32 + || mb_strlen( $entry['source'] ) >= 256
33 + ) {
34 + continue;
35 + }
36 +
37 + $source_clear = wplng_text_esc( $entry['source'] );
38 + $source_clear = str_replace( '⊕', '', $source_clear );
39 + $source_clear = str_replace( '⊖', '', $source_clear );
40 + $source_clear = preg_replace( '#\[wplng_dictionary.*\]#', '', $source_clear );
41 + $source_clear = preg_replace( '#\[\/wplng_dictionary\]#', '', $source_clear );
42 +
43 + /**
44 + * Check if rule already exist
45 + */
46 +
47 + $already_in = false;
48 + foreach ( $entries_clear as $entry_clear ) {
49 + if ( $source_clear === $entry_clear['source'] ) {
50 + $already_in = true;
51 + break;
52 + }
53 + }
54 +
55 + if ( $already_in ) {
56 + continue;
57 + }
58 +
59 + /**
60 + * Get and check the rules
61 + */
62 +
63 + if ( ! isset( $entry['rules'] )
64 + || ! is_array( $entry['rules'] )
65 + ) {
66 + /**
67 + * Create the clear entries
68 + */
69 +
70 + $entries_clear[] = array(
71 + 'source' => $source_clear,
72 + );
73 + continue;
74 + }
75 +
76 + $rules_clear = array();
77 +
78 + foreach ( $entry['rules'] as $language_id => $rule ) {
79 + if ( ! wplng_is_valid_language_id( $language_id )
80 + || ! is_string( $rule )
81 + || '' === trim( $rule )
82 + || $rule === $source_clear
83 + || mb_strlen( $rule ) >= 256
84 + ) {
85 + continue;
86 + }
87 +
88 + $rules_clear[ $language_id ] = wplng_text_esc( $rule );
89 + }
90 +
91 + /**
92 + * Create the clear entries
93 + */
94 +
95 + if ( empty( $rules_clear ) ) {
96 + $entries_clear[] = array(
97 + 'source' => $source_clear,
98 + );
99 + } else {
100 + $entries_clear[] = array(
101 + 'source' => $source_clear,
102 + 'rules' => $rules_clear,
103 + );
104 + }
105 + }
106 +
107 + /**
108 + * Sort dictionary entries by sources length
109 + */
110 +
111 + usort(
112 + $entries_clear,
113 + function ( $a, $b ) {
114 + return mb_strlen( $b['source'] ) - mb_strlen( $a['source'] );
115 + }
116 + );
117 +
118 + /**
119 + * Apply wplng_dictionary_entries filter
120 + */
121 +
122 + $entries_clear = apply_filters(
123 + 'wplng_dictionary_entries',
124 + $entries_clear
125 + );
126 +
127 + return $entries_clear;
128 +}
129 +
130 +
131 +/**
132 + * Get dictionary entries in JSON format
133 + *
134 + * @return string JSON
135 + */
136 +function wplng_dictionary_get_entries_json() {
137 + return wp_json_encode(
138 + wplng_dictionary_get_entries(),
139 + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
140 + );
141 +}
142 +
143 +
144 +/**
145 + * Add dictionary tag on a texts list
146 + *
147 + * @param array $texts
148 + * @param array $dictionary_entries
149 + * @return array Texts tagged
150 + */
151 +function wplng_dictionary_add_tags( $texts, $language_target_id, $dictionary_entries = false ) {
152 +
153 + if ( false === $dictionary_entries ) {
154 + $dictionary_entries = wplng_dictionary_get_entries();
155 + }
156 +
157 + /**
158 + * Preg quote sources texts
159 + */
160 +
161 + foreach ( $dictionary_entries as $entry_key => $entry ) {
162 + $dictionary_entries[ $entry_key ]['source'] = preg_quote( $entry['source'], '#' );
163 + }
164 +
165 + foreach ( $texts as $text_key => $text ) {
166 +
167 + /**
168 + * Get used dictionary entry in current text
169 + */
170 +
171 + $entries_used = array();
172 +
173 + foreach ( $dictionary_entries as $entry_key => $entry ) {
174 +
175 + $has_rules = isset( $entry['rules'] )
176 + && is_array( $entry['rules'] )
177 + && ! empty( $entry['rules'] );
178 +
179 + if ( $has_rules && empty( $entry['rules'][ $language_target_id ] ) ) {
180 + continue;
181 + }
182 +
183 + $preg_match = array();
184 +
185 + preg_match_all(
186 + '#' . $entry['source'] . '#iu',
187 + $text,
188 + $preg_match
189 + );
190 +
191 + $preg_match = $preg_match[0];
192 +
193 + foreach ( $preg_match as $key => $match ) {
194 +
195 + $upper = 'none';
196 + // Caseless scripts (CJK, digits…) must not trigger uppercase detection:
197 + // mb_strtoupper('テスト') === 'テスト' is true, which would incorrectly
198 + // mark every CJK match as 'all' and uppercase the replacement.
199 + $is_caseless = mb_strtolower( $match ) === mb_strtoupper( $match );
200 + if ( ! $is_caseless ) {
201 + if ( $match === mb_strtoupper( $match ) ) {
202 + // Check uppercase
203 + $upper = 'all';
204 + } elseif ( $match === mb_strtoupper( mb_substr( $match, 0, 1 ) ) . mb_substr( $match, 1 ) ) {
205 + // Check capitalize
206 + $upper = 'first';
207 + }
208 + }
209 +
210 + $entry_current = $entry;
211 + $entry_current['source'] = $match;
212 + $entry_current['key'] = $entry_key;
213 + $entry_current['upper'] = $upper;
214 + $entries_used[] = $entry_current;
215 + }
216 + }
217 +
218 + /**
219 + * Remove dupicate in used entries
220 + */
221 +
222 + $entries_used = array_map( 'serialize', $entries_used );
223 + $entries_used = array_unique( $entries_used );
224 + $entries_used = array_map( 'unserialize', $entries_used );
225 +
226 + /**
227 + * Put tempory tag in current text
228 + */
229 +
230 + foreach ( $entries_used as $entry_key => $entry_used ) {
231 + // CJK scripts (Japanese, Chinese, etc.) have no word separators,
232 + // so word boundaries cannot be used. For all other scripts (including
233 + // Greek), (*UCP) makes \b Unicode-aware so letters are treated as \w.
234 + if ( preg_match( '#[\x{2E80}-\x{9FFF}\x{F900}-\x{FAFF}]#u', $entry_used['source'] ) ) {
235 + $boundary_pattern = '#' . $entry_used['source'] . '#u';
236 + } else {
237 + $boundary_pattern = '#(*UCP)\b' . $entry_used['source'] . '\b#u';
238 + }
239 + $text = preg_replace(
240 + $boundary_pattern,
241 + '⊕' . str_repeat( '⊖', $entry_key + 1 ) . '⊕',
242 + $text
243 + );
244 + }
245 +
246 + /**
247 + * Repace tempory tag by final tag
248 + */
249 +
250 + foreach ( $entries_used as $entry_key => $entry ) {
251 +
252 + $search = '⊕' . str_repeat( '⊖', $entry_key + 1 ) . '⊕';
253 +
254 + $replace = '[wplng_dictionary ';
255 + $replace .= 'key="' . $entry['key'] . '" ';
256 + $replace .= 'upper="' . $entry['upper'] . '"]';
257 + $replace .= $entry['source'];
258 + $replace .= '[/wplng_dictionary]';
259 +
260 + $text = str_replace(
261 + $search,
262 + $replace,
263 + $text
264 + );
265 +
266 + }
267 +
268 + /**
269 + * Set updated text in text array
270 + */
271 +
272 + $texts[ $text_key ] = $text;
273 + }
274 +
275 + return $texts;
276 +}
277 +
278 +
279 +/**
280 + * Transform dictionary tags to translated text
281 + *
282 + * @param array $texts
283 + * @param array $dictionary_entries
284 + * @return array Texts untagged
285 + */
286 +function wplng_dictionary_replace_tags( $texts, $language_target_id, $dictionary_entries = false ) {
287 +
288 + if ( false === $dictionary_entries ) {
289 + $dictionary_entries = wplng_dictionary_get_entries();
290 + }
291 +
292 + foreach ( $texts as $text_key => $text ) {
293 +
294 + // Ensure spaces around dictionary tags when adjacent to letters from scripts
295 + // that use word spaces (Latin, Greek, Cyrillic, Arabic, Hebrew, Devanagari…).
296 + // This fixes CJK-source → alphabetic-target replacements where the translation
297 + // API may not add spaces around the preserved tag (e.g. Japanese → French).
298 + // CJK scripts (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) are intentionally
299 + // excluded: they don't use word spaces, so no space should be inserted there.
300 + $text = preg_replace(
301 + '/(?<=[A-Za-z\x{00C0}-\x{04FF}\x{0590}-\x{097F}\d])\[wplng_dictionary /u',
302 + ' [wplng_dictionary ',
303 + $text
304 + );
305 + $text = preg_replace(
306 + '/\[\/wplng_dictionary\](?=[A-Za-z\x{00C0}-\x{04FF}\x{0590}-\x{097F}\d])/u',
307 + '[/wplng_dictionary] ',
308 + $text
309 + );
310 +
311 + foreach ( $dictionary_entries as $key => $entry ) {
312 +
313 + // For ruls "Do not translate
314 + $replacement = $entry['source'];
315 +
316 + // For specific rule by language
317 + if ( ! empty( $entry['rules'][ $language_target_id ] ) ) {
318 + $replacement = $entry['rules'][ $language_target_id ];
319 + }
320 +
321 + // Replacement for text in uppercase
322 + $text = preg_replace(
323 + '#\[wplng_dictionary key="' . $key . '" upper="all"\].+\[\/wplng_dictionary\]#Uu',
324 + mb_strtoupper( $replacement ),
325 + $text
326 + );
327 +
328 + // Replacement for text when only the first letter is uppercase
329 + $text = preg_replace(
330 + '#\[wplng_dictionary key="' . $key . '" upper="first"\].+\[\/wplng_dictionary\]#Uu',
331 + mb_strtoupper( mb_substr( $replacement, 0, 1 ) ) . mb_substr( $replacement, 1 ),
332 + $text
333 + );
334 +
335 + // Replacement for other case
336 + $text = preg_replace(
337 + '#\[wplng_dictionary key="' . $key . '" upper="none"\].+\[\/wplng_dictionary\]#Uu',
338 + $replacement,
339 + $text
340 + );
341 +
342 + }
343 +
344 + // Cleaning of any residual rules
345 + $text = preg_replace(
346 + '#\[wplng_dictionary key=".*" upper=".*"\](.+)\[\/wplng_dictionary\]#Uu',
347 + '${1}',
348 + $text
349 + );
350 +
351 + $texts[ $text_key ] = $text;
352 + }
353 +
354 + return $texts;
355 +}
356 +
357 +
358 +/**
359 + * Fired when the 'wplng_dictionary_entries' option is saved.
360 + *
361 + * Compares the old and new dictionary entries to determine which source texts
362 + * and target languages are affected by the change. Builds $update_todo, a list
363 + * of items whose cached translations need to be regenerated.
364 + *
365 + * Each item in $update_todo has the shape:
366 + * [ 'source' => string, 'impacted_languages' => 'all' | string[] ]
367 + *
368 + * 'impacted_languages' is the string 'all' when the entry has no per-language
369 + * rules (i.e., it is a "never translate" rule that applies to every language).
370 + *
371 + * @param string $old_value The previous JSON value of the option.
372 + * @param string $new_value The new JSON value of the option.
373 + */
374 +function wplng_on_dictionary_entries_updated( $old_value, $new_value ) {
375 +
376 + // Entries are stored as a JSON string in the WordPress options table.
377 + // Decode them; fall back to an empty array if the value is absent or invalid.
378 + $old_entries = json_decode( $old_value, true );
379 + $new_entries = json_decode( $new_value, true );
380 +
381 + if ( ! is_array( $old_entries ) ) {
382 + $old_entries = array();
383 + }
384 +
385 + if ( ! is_array( $new_entries ) ) {
386 + $new_entries = array();
387 + }
388 +
389 + // Re-index entries by their source text for O(1) lookup in the loops below.
390 + // The dictionary is stored as a plain list; keying by source avoids nested loops.
391 + $old_by_source = array();
392 + foreach ( $old_entries as $entry ) {
393 + if ( isset( $entry['source'] ) ) {
394 + $old_by_source[ $entry['source'] ] = $entry;
395 + }
396 + }
397 +
398 + $new_by_source = array();
399 + foreach ( $new_entries as $entry ) {
400 + if ( isset( $entry['source'] ) ) {
401 + $new_by_source[ $entry['source'] ] = $entry;
402 + }
403 + }
404 +
405 + // Will hold the list of source/language pairs whose translations need refreshing.
406 + $update_todo = array();
407 +
408 + /**
409 + * Detect removed entries.
410 + *
411 + * When a rule is deleted, cached translations built with that rule may be
412 + * outdated (e.g., a forced translation that should revert to the default
413 + * AI output). All languages covered by the old rule must be refreshed.
414 + */
415 +
416 + foreach ( $old_by_source as $source => $old_entry ) {
417 +
418 + // Skip entries that still exist in the new value.
419 + if ( isset( $new_by_source[ $source ] ) ) {
420 + continue;
421 + }
422 +
423 + $impacted_languages = array();
424 +
425 + if ( empty( $old_entry['rules'] ) ) {
426 + // No per-language rules → this was a "never translate" rule affecting all languages.
427 + $impacted_languages = 'all';
428 + } else {
429 + // Collect every language that had a rule for this source.
430 + foreach ( $old_entry['rules'] as $language_id => $rule ) {
431 + $impacted_languages[] = $language_id;
432 + }
433 + }
434 +
435 + $update_todo[] = array(
436 + 'source' => $source,
437 + 'impacted_languages' => $impacted_languages,
438 + );
439 + }
440 +
441 + /**
442 + * Detect added and modified entries
443 + */
444 +
445 + foreach ( $new_by_source as $source => $new_entry ) {
446 + if ( ! isset( $old_by_source[ $source ] ) ) {
447 +
448 + /**
449 + * Added.
450 + *
451 + * Existing translations were produced without this rule, so they may
452 + * not yet reflect the new forced translation or exclusion.
453 + */
454 +
455 + $impacted_languages = array();
456 +
457 + if ( empty( $new_entry['rules'] ) ) {
458 + // No per-language rules → "never translate" rule affecting all languages.
459 + $impacted_languages = 'all';
460 + } else {
461 + // Collect every language that has a rule for this new source.
462 + foreach ( $new_entry['rules'] as $language_id => $rule ) {
463 + $impacted_languages[] = $language_id;
464 + }
465 + }
466 +
467 + $update_todo[] = array(
468 + 'source' => $source,
469 + 'impacted_languages' => $impacted_languages,
470 + );
471 +
472 + } elseif ( $new_entry != $old_by_source[ $source ] ) {
473 +
474 + /**
475 + * Modified.
476 + *
477 + * Loose comparison (!=) is intentional: strict (!==) would treat arrays
478 + * with the same key/value pairs but different key order as unequal,
479 + * causing false positives when WordPress re-encodes the JSON.
480 + */
481 +
482 + $old_entry = $old_by_source[ $source ];
483 + $impacted_languages = array();
484 +
485 + if ( empty( $new_entry['rules'] ) || empty( $old_entry['rules'] )
486 + || ! is_array( $new_entry['rules'] ) || ! is_array( $old_entry['rules'] )
487 + ) {
488 + // One side has no per-language rules, meaning the entry was or became a
489 + // "never translate" rule — all languages are affected by the change.
490 + $impacted_languages = 'all';
491 + } else {
492 + // Find every language whose rule value changed, was added, or was removed.
493 + // array_diff_assoc() catches rules that changed value or were newly added.
494 + // array_diff_key() catches rules that existed in the old entry but were removed.
495 + // Merging both and taking the keys gives the full list of impacted languages.
496 + $impacted_languages = array_keys(
497 + array_merge(
498 + array_diff_assoc( $new_entry['rules'], $old_entry['rules'] ),
499 + array_diff_key( $old_entry['rules'], $new_entry['rules'] ),
500 + )
501 + );
502 + }
503 +
504 + if ( ! empty( $impacted_languages ) ) {
505 + $update_todo[] = array(
506 + 'source' => $source,
507 + 'impacted_languages' => $impacted_languages,
508 + );
509 + }
510 + }
511 + }
512 +
513 + if ( empty( $update_todo ) ) {
514 + return;
515 + }
516 +
517 + // Normalize: if impacted_languages lists every active target language,
518 + // collapse it to 'all' to avoid unnecessary per-language filtering later.
519 + $all_target_ids = wplng_get_languages_target_ids();
520 + foreach ( $update_todo as &$item ) {
521 + if ( is_array( $item['impacted_languages'] )
522 + && ! array_diff( $all_target_ids, $item['impacted_languages'] )
523 + ) {
524 + $item['impacted_languages'] = 'all';
525 + }
526 + }
527 + unset( $item );
528 +
529 + /**
530 + * Get all translations
531 + */
532 +
533 + $translations = wplng_get_translations();
534 + $translations_to_update = array();
535 +
536 + // Foreach translations
537 + foreach ( $translations as $index_strings => $translations_chunk ) {
538 + foreach ( $translations_chunk as $key => $translation ) {
539 +
540 + // Foreach $update_todo
541 + foreach ( $update_todo as $todo ) {
542 + $source_todo = mb_strtolower( $todo['source'] );
543 + $source_translation = mb_strtolower( $translation['source'] );
544 +
545 + // Use a simple substring match for CJK sources (no word boundaries in CJK text).
546 + // For all other scripts, require word boundaries to avoid false matches (e.g. "men" in "women").
547 + $is_cjk = (bool) preg_match( '#[\x{2E80}-\x{9FFF}\x{F900}-\x{FAFF}]#u', $source_todo );
548 + $pattern = $is_cjk
549 + ? '#' . preg_quote( $source_todo, '#' ) . '#iu'
550 + : '#(*UCP)\b' . preg_quote( $source_todo, '#' ) . '\b#iu';
551 +
552 + if ( ! wplng_str_contains( $source_translation, $source_todo )
553 + || ! preg_match( $pattern, $source_translation )
554 + ) {
555 + continue;
556 + }
557 +
558 + $impacted_languages = 'all';
559 +
560 + $translation_review = $translation['review'] ?? array();
561 +
562 + if ( $todo['impacted_languages'] !== 'all' ) {
563 +
564 + $impacted_languages = array();
565 +
566 + foreach ( $translation['translations'] as $language_id => $value ) {
567 + if ( in_array( $language_id, $todo['impacted_languages'], true )
568 + && ! in_array( $language_id, $translation_review, true )
569 + ) {
570 + $impacted_languages[] = $language_id;
571 + }
572 + }
573 + } else {
574 +
575 + // Even when the dictionary rule impacts every language, translations
576 + // that were manually reviewed must still be protected from being
577 + // flagged for automatic regeneration.
578 + $impacted_languages = array();
579 +
580 + foreach ( $translation['translations'] as $language_id => $value ) {
581 + if ( ! in_array( $language_id, $translation_review, true ) ) {
582 + $impacted_languages[] = $language_id;
583 + }
584 + }
585 + }
586 +
587 + // If impacted_languages covers every language that has been translated,
588 + // collapse it to 'all' to avoid unnecessary per-language filtering later.
589 + if ( is_array( $impacted_languages ) ) {
590 + $temp_translations = $translation['translations'];
591 + foreach ( $impacted_languages as $language_id ) {
592 + unset( $temp_translations[ $language_id ] );
593 + }
594 + if ( empty( $temp_translations ) ) {
595 + $impacted_languages = 'all';
596 + }
597 + }
598 +
599 + $translations_to_update[] = array(
600 + 'source' => $translation['source'],
601 + 'post_id' => $translation['post_id'],
602 + 'impacted_languages' => $impacted_languages,
603 + );
604 +
605 + }
606 + }
607 + }
608 +
609 + // Clear $translations_to_update when no loanguage impacted
610 + foreach ( $translations_to_update as $key => $value ) {
611 + if ( empty( $value['impacted_languages'] ) ) {
612 + unset( $translations_to_update[ $key ] );
613 + }
614 + }
615 +
616 + // Deduplicate entries by post_id: the same translation can match several
617 + // dictionary rules, producing multiple entries for the same post. Merge
618 + // their impacted_languages (union), collapsing to 'all' if any entry is 'all'.
619 + $translations_to_update_by_post = array();
620 +
621 + foreach ( $translations_to_update as $value ) {
622 +
623 + $post_id = $value['post_id'];
624 +
625 + if ( ! isset( $translations_to_update_by_post[ $post_id ] ) ) {
626 + $translations_to_update_by_post[ $post_id ] = $value;
627 + continue;
628 + }
629 +
630 + $existing = $translations_to_update_by_post[ $post_id ];
631 +
632 + if ( 'all' === $existing['impacted_languages']
633 + || 'all' === $value['impacted_languages']
634 + ) {
635 + $translations_to_update_by_post[ $post_id ]['impacted_languages'] = 'all';
636 + } else {
637 + $translations_to_update_by_post[ $post_id ]['impacted_languages'] = array_values(
638 + array_unique(
639 + array_merge( $existing['impacted_languages'], $value['impacted_languages'] )
640 + )
641 + );
642 + }
643 + }
644 +
645 + $translations_to_update = array_values( $translations_to_update_by_post );
646 +
647 + if ( ! empty( $translations_to_update ) ) {
648 + // Store the list in a short-lived transient so it survives the redirect
649 + // that options.php performs after saving. The page function reads and
650 + // immediately deletes it so it is only displayed once.
651 + set_transient( 'wplng_dictionary_updated_data', $translations_to_update, 30 );
652 + }
653 +}
654 +
655 +
656 +/**
657 + * AJAX handler: process one translation update triggered by a dictionary rule change.
658 + *
659 + * Receives post_id and impacted_languages from the JS queue.
660 + * For now, echoes the received data back so the JS loop can be validated end-to-end.
661 + *
662 + * @return void
663 + */
664 +function wplng_ajax_dictionary_update_translations() {
665 +
666 + // ------------------------------------------------------------------------
667 + // Get and check basic data
668 + // ------------------------------------------------------------------------
669 +
670 + /**
671 + * Check the nonce to prevent unauthorized requests
672 + */
673 +
674 + check_ajax_referer( 'wplng_dictionary_update_translations', 'nonce' );
675 +
676 + /**
677 + * Check the user's permissions
678 + */
679 +
680 + if ( ! current_user_can( 'manage_options' ) ) {
681 + wp_send_json_error(
682 + array(
683 + 'error' => true,
684 + 'message' => __( 'Error [1]: The current user is not authorized to edit translations', 'wplingua' ),
685 + )
686 + );
687 + return;
688 + }
689 +
690 + /**
691 + * Check the post ID parameter
692 + */
693 +
694 + if ( empty( $_POST['post_id'] ) ) {
695 + wp_send_json_error(
696 + array(
697 + 'error' => true,
698 + 'message' => __( 'Error [2]: Invalid post ID parameter', 'wplingua' ),
699 + )
700 + );
701 + return;
702 + }
703 +
704 + $post_id = intval( $_POST['post_id'] );
705 +
706 + /**
707 + * Check "impacted_languages" parameter
708 + */
709 +
710 + if ( empty( $_POST['impacted_languages'] )
711 + || ! is_string( $_POST['impacted_languages'] )
712 + ) {
713 + wp_send_json_error(
714 + array(
715 + 'error' => true,
716 + 'message' => __( 'Error [3]: Invalid impacted languages parameter', 'wplingua' ),
717 + )
718 + );
719 + return;
720 + }
721 +
722 + $impacted_languages = sanitize_text_field( wp_unslash( $_POST['impacted_languages'] ) );
723 +
724 + // Keep the raw (pre-decode) string: it is what was used to build the
725 + // "check" value server-side, and is needed below to verify it again.
726 + $impacted_languages_raw = $impacted_languages;
727 +
728 + if ( $impacted_languages !== 'all' ) {
729 +
730 + // We suppose it's a JSON with the list of impacted languages IDs
731 + $impacted_languages = json_decode( $impacted_languages, true );
732 +
733 + if ( ! is_array( $impacted_languages )
734 + || ! wplng_is_valid_language_ids( $impacted_languages )
735 + ) {
736 + wp_send_json_error(
737 + array(
738 + 'error' => true,
739 + 'message' => __( 'Error [4]: Invalid impacted languages data', 'wplingua' ),
740 + )
741 + );
742 + return;
743 + }
744 + }
745 +
746 + /**
747 + * Check the "check" parameter.
748 + *
749 + * It is an encrypted copy of "post_id-impacted_languages" generated when
750 + * the HTML was rendered (see wplng_option_page_dictionary_update_translations_html()).
751 + * Decrypting it and comparing it to the values actually received prevents
752 + * the browser (or an attacker via devtools) from tampering with post_id
753 + * or impacted_languages before the request is sent.
754 + */
755 +
756 + if ( empty( $_POST['check'] ) || ! is_string( $_POST['check'] ) ) {
757 + wp_send_json_error(
758 + array(
759 + 'error' => true,
760 + 'message' => __( 'Error [5]: Parameter "check" not found', 'wplingua' ),
761 + )
762 + );
763 + return;
764 + }
765 +
766 + $check_received = wplng_encryption_decrypt( sanitize_text_field( wp_unslash( $_POST['check'] ) ) );
767 + $check_expected = $post_id . '-' . $impacted_languages_raw;
768 +
769 + if ( '' === $check_received || ! hash_equals( $check_expected, $check_received ) ) {
770 + wp_send_json_error(
771 + array(
772 + 'error' => true,
773 + 'message' => __( 'Error [6]: Invalid "check" parameter', 'wplingua' ),
774 + )
775 + );
776 + return;
777 + }
778 +
779 + /**
780 + * Get and check the translation post
781 + */
782 +
783 + $post_translation = get_post( $post_id, ARRAY_A );
784 +
785 + if ( ! isset( $post_translation['post_type'] )
786 + || $post_translation['post_type'] !== 'wplng_translation'
787 + ) {
788 + wp_send_json_error(
789 + array(
790 + 'error' => true,
791 + 'message' => __( 'Error [7]: Invalid post ID or post type', 'wplingua' ),
792 + )
793 + );
794 + return;
795 + }
796 +
797 + // ------------------------------------------------------------------------
798 + // Apply the update to the relevant translation
799 + // ------------------------------------------------------------------------
800 +
801 + if ( $impacted_languages === 'all' ) {
802 +
803 + // ------------------------------------------------------------------------
804 + // Delete the post if all languages are impacted
805 + // ------------------------------------------------------------------------
806 +
807 + if ( empty( wp_delete_post( $post_id, true ) ) ) {
808 + wp_send_json_error(
809 + array(
810 + 'error' => true,
811 + 'message' => __( 'Error [8]: Failed to delete the translation', 'wplingua' ),
812 + )
813 + );
814 + return;
815 + }
816 +
817 + wp_send_json_success(
818 + array(
819 + 'post_id' => $post_id,
820 + 'impacted_languages' => $impacted_languages,
821 + )
822 + );
823 + return;
824 +
825 + } else {
826 +
827 + // ------------------------------------------------------------------------
828 + // Clear impacted translation in translation post
829 + // ------------------------------------------------------------------------
830 +
831 + /**
832 + * Check post meta : All meta
833 + */
834 +
835 + $meta = get_post_meta( $post_id );
836 +
837 + if ( ! isset( $meta['wplng_translation_translations'][0] )
838 + || ! is_string( $meta['wplng_translation_translations'][0] )
839 + ) {
840 + wp_send_json_error(
841 + array(
842 + 'error' => true,
843 + 'message' => __( 'Error [9]: Translation meta "translations" not found', 'wplingua' ),
844 + )
845 + );
846 + return;
847 + }
848 +
849 + /**
850 + * Check post meta : Translations meta
851 + */
852 +
853 + $translation_meta = json_decode( $meta['wplng_translation_translations'][0], true );
854 +
855 + if ( empty( $translation_meta ) || ! is_array( $translation_meta ) ) {
856 + wp_send_json_error(
857 + array(
858 + 'error' => true,
859 + 'message' => __( 'Error [10]: Invalid translation meta', 'wplingua' ),
860 + )
861 + );
862 + return;
863 + }
864 +
865 + /**
866 + * Check post meta : Original language
867 + */
868 +
869 + if ( empty( $meta['wplng_translation_original_language_id'][0] ) ) {
870 + wp_send_json_error(
871 + array(
872 + 'error' => true,
873 + 'message' => __( 'Error [11]: Translation meta "original language" not found', 'wplingua' ),
874 + )
875 + );
876 + return;
877 + }
878 +
879 + $original_language_id_meta = $meta['wplng_translation_original_language_id'][0];
880 +
881 + if ( $original_language_id_meta !== wplng_get_language_website_id() ) {
882 + wp_send_json_error(
883 + array(
884 + 'error' => true,
885 + 'message' => __( 'Error [12]: The language of the translation is not the same as the language of the site\'s settings', 'wplingua' ),
886 + )
887 + );
888 + return;
889 + }
890 +
891 + /**
892 + * Generate the new translation meta
893 + */
894 +
895 + $translation_meta_new = array();
896 +
897 + foreach ( $translation_meta as $key => $value ) {
898 + if ( ! isset( $value['language_id'] )
899 + || ! wplng_is_valid_language_id( $value['language_id'] )
900 + || ! isset( $value['translation'] )
901 + || ! is_string( $value['translation'] )
902 + ) {
903 + continue;
904 + }
905 +
906 + if ( ! in_array( $value['language_id'], $impacted_languages, true )
907 + || ! isset( $value['status'] )
908 + || $value['status'] !== 'generated'
909 + ) {
910 + $translation_meta_new[] = $value;
911 + }
912 + }
913 +
914 + /**
915 + * Update the translation meta
916 + */
917 +
918 + if ( empty( $translation_meta_new ) ) {
919 +
920 + /**
921 + * Delete the translation post if all translation is finaly impacted
922 + */
923 + if ( empty( wp_delete_post( $post_id, true ) ) ) {
924 + wp_send_json_error(
925 + array(
926 + 'error' => true,
927 + 'message' => __( 'Error [13]: Failed to delete the translation', 'wplingua' ),
928 + )
929 + );
930 + return;
931 + }
932 + } else {
933 +
934 + /**
935 + * Update the translations post meta
936 + */
937 +
938 + $meta_return = update_post_meta(
939 + $post_id,
940 + 'wplng_translation_translations',
941 + wp_json_encode(
942 + $translation_meta_new,
943 + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
944 + )
945 + );
946 +
947 + if ( false === $meta_return ) {
948 + wp_send_json_error(
949 + array(
950 + 'error' => true,
951 + 'message' => __( 'Error [14]: Failed to update the translation meta', 'wplingua' ),
952 + )
953 + );
954 + return;
955 + }
956 +
957 + // Clear cache explicitly.
958 + wplng_clear_translations_cache();
959 + }
960 + }
961 +
962 + wp_send_json_success(
963 + array(
964 + 'post_id' => $post_id,
965 + 'impacted_languages' => $impacted_languages,
966 + )
967 + );
968 +}