| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\REST; |
| 4 |
|
| 5 |
use WP_REST_Request; |
| 6 |
use WPDeveloper\BetterDocs\Core\BaseAPI; |
| 7 |
use WPDeveloper\BetterDocs\Utils\Helper; |
| 8 |
|
| 9 |
/** |
| 10 |
* Term translation linking for the React admin (WPML / Polylang). |
| 11 |
* |
| 12 |
* Powers the slide-over "Language / This is a translation of / Translate" panel |
| 13 |
* for the doc_category, doc_tag and knowledge_base taxonomies. |
| 14 |
*/ |
| 15 |
class Translations extends BaseAPI { |
| 16 |
|
| 17 |
/** Taxonomies the React admins manage. */ |
| 18 |
private function allowed_taxonomies() { |
| 19 |
return array_filter( |
| 20 |
[ 'doc_category', 'doc_tag', 'knowledge_base' ], |
| 21 |
'taxonomy_exists' |
| 22 |
); |
| 23 |
} |
| 24 |
|
| 25 |
public function permission_check(): bool { |
| 26 |
// QA-013: these routes read/write term language meta, so require the |
| 27 |
// doc-term management cap (Editor+) — `edit_docs` is Author-level and |
| 28 |
// let any author rewrite another category's language assignment. Mirrors |
| 29 |
// the `manage_doc_terms` guard used by PostType term operations. |
| 30 |
return current_user_can( 'manage_doc_terms' ); |
| 31 |
} |
| 32 |
|
| 33 |
public function register() { |
| 34 |
$this->get( 'term-translations', [ $this, 'get_term_translations' ] ); |
| 35 |
$this->post( 'term-language', [ $this, 'set_term_language' ] ); |
| 36 |
|
| 37 |
add_filter( 'rest_request_before_callbacks', [ $this, 'switch_language_for_term_update' ], 10, 3 ); |
| 38 |
} |
| 39 |
|
| 40 |
/** |
| 41 |
* WPML lets translated terms share one slug across languages, but it |
| 42 |
* deliberately skips its language filtering whenever get_term_by() is in the |
| 43 |
* call stack (WPML_Term_Clauses::filter). Core wp_update_term() uses exactly |
| 44 |
* that lookup for its duplicate-slug check, so updating a term whose slug has |
| 45 |
* an older other-language twin via the core REST controllers always fails |
| 46 |
* with duplicate_term_slug — even when the payload never touches the slug |
| 47 |
* (e.g. changing only the KB order from the React admin). While such a |
| 48 |
* request is being dispatched, restrict slug lookups on the edited taxonomy |
| 49 |
* to the edited term's own language: the term matches itself, and genuine |
| 50 |
* same-language collisions are still rejected. |
| 51 |
* |
| 52 |
* The term's language is read from its term_taxonomy_id directly because |
| 53 |
* get_term() under WPML adjusts the ID to the current display language's |
| 54 |
* translation, which would report the wrong language here. |
| 55 |
*/ |
| 56 |
public function switch_language_for_term_update( $response, $handler, $request ) { |
| 57 |
global $wpdb; |
| 58 |
|
| 59 |
if ( ! $request instanceof WP_REST_Request || ! Helper::is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) { |
| 60 |
return $response; |
| 61 |
} |
| 62 |
|
| 63 |
if ( ! in_array( $request->get_method(), [ 'POST', 'PUT', 'PATCH' ], true ) ) { |
| 64 |
return $response; |
| 65 |
} |
| 66 |
|
| 67 |
if ( ! preg_match( '#^/wp/v2/(doc_category|doc_tag|knowledge_base)/(\d+)$#', $request->get_route(), $matches ) ) { |
| 68 |
return $response; |
| 69 |
} |
| 70 |
|
| 71 |
$taxonomy = $matches[1]; |
| 72 |
$ttid = $wpdb->get_var( |
| 73 |
$wpdb->prepare( |
| 74 |
"SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE term_id = %d AND taxonomy = %s", |
| 75 |
absint( $matches[2] ), |
| 76 |
$taxonomy |
| 77 |
) |
| 78 |
); |
| 79 |
if ( ! $ttid ) { |
| 80 |
return $response; |
| 81 |
} |
| 82 |
|
| 83 |
$term_lang = apply_filters( 'wpml_element_language_code', null, [ |
| 84 |
'element_id' => (int) $ttid, |
| 85 |
'element_type' => $taxonomy, |
| 86 |
] ); |
| 87 |
if ( ! $term_lang ) { |
| 88 |
return $response; |
| 89 |
} |
| 90 |
|
| 91 |
$clauses_filter = function ( $clauses, $taxonomies, $args ) use ( $taxonomy, $term_lang ) { |
| 92 |
global $wpdb; |
| 93 |
|
| 94 |
// Only slug lookups on the edited taxonomy (the duplicate-slug check). |
| 95 |
if ( empty( $args['slug'] ) || [ $taxonomy ] !== (array) $taxonomies ) { |
| 96 |
return $clauses; |
| 97 |
} |
| 98 |
|
| 99 |
$clauses['join'] .= " LEFT JOIN {$wpdb->prefix}icl_translations bd_icl_t ON bd_icl_t.element_id = tt.term_taxonomy_id AND bd_icl_t.element_type = CONCAT( 'tax_', tt.taxonomy )"; |
| 100 |
$clauses['where'] .= $wpdb->prepare( ' AND ( bd_icl_t.language_code = %s OR bd_icl_t.language_code IS NULL )', $term_lang ); |
| 101 |
|
| 102 |
return $clauses; |
| 103 |
}; |
| 104 |
add_filter( 'terms_clauses', $clauses_filter, 10, 3 ); |
| 105 |
|
| 106 |
// WPML's save hooks stamp edited terms with the *current* language, so a |
| 107 |
// request arriving in another language context would silently reassign |
| 108 |
// the term's language. Align the context with the edited term. |
| 109 |
$prev_lang = apply_filters( 'wpml_current_language', null ); |
| 110 |
if ( $term_lang !== $prev_lang ) { |
| 111 |
do_action( 'wpml_switch_language', $term_lang ); |
| 112 |
} |
| 113 |
|
| 114 |
// Scope the workaround to this callback only: undo both the clause |
| 115 |
// filter and the language switch once the targeted update has run, so |
| 116 |
// nothing leaks into batch subrequests or response-side hooks. |
| 117 |
$cleanup = function ( $after_response ) use ( &$cleanup, $clauses_filter, $term_lang, $prev_lang ) { |
| 118 |
remove_filter( 'terms_clauses', $clauses_filter, 10 ); |
| 119 |
if ( $term_lang !== $prev_lang ) { |
| 120 |
do_action( 'wpml_switch_language', $prev_lang ); |
| 121 |
} |
| 122 |
remove_filter( 'rest_request_after_callbacks', $cleanup, 10 ); |
| 123 |
|
| 124 |
return $after_response; |
| 125 |
}; |
| 126 |
add_filter( 'rest_request_after_callbacks', $cleanup, 10 ); |
| 127 |
|
| 128 |
return $response; |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Translation group + "this is a translation of" candidates for a term (or for |
| 133 |
* a target language when creating a new translation). |
| 134 |
*/ |
| 135 |
public function get_term_translations( WP_REST_Request $request ) { |
| 136 |
$taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) ); |
| 137 |
if ( ! in_array( $taxonomy, $this->allowed_taxonomies(), true ) || ! Helper::is_multilingual_active() ) { |
| 138 |
return rest_ensure_response( [ 'enabled' => false ] ); |
| 139 |
} |
| 140 |
|
| 141 |
$default_lang = Helper::get_default_language(); |
| 142 |
$target_lang = sanitize_text_field( (string) $request->get_param( 'lang' ) ); |
| 143 |
$source_lang = sanitize_text_field( (string) $request->get_param( 'source_lang' ) ) ?: $default_lang; |
| 144 |
$term_id = absint( $request->get_param( 'term_id' ) ); |
| 145 |
|
| 146 |
$current_lang = ''; |
| 147 |
$translation_of = 0; |
| 148 |
$translations = []; |
| 149 |
|
| 150 |
if ( $term_id ) { |
| 151 |
$term = get_term( $term_id, $taxonomy ); |
| 152 |
if ( $term && ! is_wp_error( $term ) ) { |
| 153 |
$current_lang = Helper::get_term_language( $term ); |
| 154 |
$translations = Helper::get_term_translations( $term ); |
| 155 |
if ( ! $target_lang ) { |
| 156 |
$target_lang = $current_lang; |
| 157 |
} |
| 158 |
// The source-language sibling (if this term is already a translation). |
| 159 |
if ( isset( $translations[ $source_lang ]['term_id'] ) ) { |
| 160 |
$translation_of = (int) $translations[ $source_lang ]['term_id']; |
| 161 |
} |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
// Candidates only matter when assigning a non-default language. |
| 166 |
$candidates = ( $target_lang && $target_lang !== $default_lang ) |
| 167 |
? Helper::get_translation_candidates( $taxonomy, $target_lang, $source_lang ) |
| 168 |
: []; |
| 169 |
|
| 170 |
return rest_ensure_response( [ |
| 171 |
'enabled' => true, |
| 172 |
'default_lang' => $default_lang, |
| 173 |
'current_lang' => $current_lang, |
| 174 |
'translation_of' => $translation_of, |
| 175 |
'translations' => $translations, |
| 176 |
'candidates' => $candidates, |
| 177 |
] ); |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Set a term's language and link it into the chosen translation group. |
| 182 |
*/ |
| 183 |
public function set_term_language( WP_REST_Request $request ) { |
| 184 |
$taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) ); |
| 185 |
$term_id = absint( $request->get_param( 'term_id' ) ); |
| 186 |
$lang = sanitize_text_field( (string) $request->get_param( 'lang' ) ); |
| 187 |
$source = absint( $request->get_param( 'translation_of' ) ); |
| 188 |
|
| 189 |
if ( ! in_array( $taxonomy, $this->allowed_taxonomies(), true ) || ! $term_id || ! Helper::is_multilingual_active() ) { |
| 190 |
return rest_ensure_response( [ 'success' => false ] ); |
| 191 |
} |
| 192 |
|
| 193 |
$term = get_term( $term_id, $taxonomy ); |
| 194 |
if ( ! $term || is_wp_error( $term ) ) { |
| 195 |
return rest_ensure_response( [ 'success' => false ] ); |
| 196 |
} |
| 197 |
|
| 198 |
Helper::link_term_translation( $term, $lang, $source ); |
| 199 |
|
| 200 |
return rest_ensure_response( [ 'success' => true, 'lang' => $lang ] ); |
| 201 |
} |
| 202 |
} |
| 203 |
|