PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 All 201 releases
← All changes | includes/Utils/Helper.php +1691 -110 3.4.2 → 4.9.3 View file →
@@ -1,168 +1,1749 @@
1 1 <?php
2 2
3 3 namespace WPDeveloper\BetterDocs\Utils;
4 4
5 +// Helper utilities mix per-language URL detection (read-only $_GET reads),
6 +// dynamic alphabet-letter / glossary queries composed via $wpdb->prepare,
7 +// and meta-key term lookups that are core to BetterDocs functionality.
8 +// phpcs:disable WordPress.Security.NonceVerification.Recommended
9 +// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
10 +// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
11 +// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
12 +// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
13 +// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
14 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
15 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
16 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
17 +
18 +use function BetterLinksPro\Dependencies\GuzzleHttp\json_decode;
19 +use function WPML\PHP\Logger\error;
20 +
5 21 class Helper extends Base {
6 22
7 - public static function get_plugins( $plugin_basename = null ) {
8 - if ( ! function_exists( 'get_plugins' ) ) {
9 - include_once ABSPATH . 'wp-admin/includes/plugin.php';
23 + /**
24 + * Mask an API key for safe display.
25 + *
26 + * Prefix-aware: when the key carries a recognizable provider prefix
27 + * (OpenAI sk-/sk-proj-, Anthropic sk-ant-/sk-ant-api03-, Gemini AIza) that
28 + * prefix is kept visible so an admin can tell which provider/key is set,
29 + * then a fixed 8-asterisk block, then the last 4 chars. Keys without a known
30 + * prefix fall back to first 3 + 8 asterisks + last 4. The asterisk count is
31 + * always fixed so the real key length is never leaked.
32 + */
33 + public static function mask_api_key( $key ) {
34 + if ( ! is_string( $key ) || $key === '' ) {
35 + return '';
36 + }
37 + $key = trim( $key );
38 + if ( $key === '' ) {
39 + return '';
40 + }
41 +
42 + // Longest prefixes first so sk-proj-/sk-ant- win over the bare sk-.
43 + $prefixes = array( 'sk-ant-api03-', 'sk-ant-', 'sk-proj-', 'sk-', 'AIza' );
44 + foreach ( $prefixes as $prefix ) {
45 + if ( strncmp( $key, $prefix, strlen( $prefix ) ) === 0
46 + && strlen( $key ) >= strlen( $prefix ) + 4 ) {
47 + return $prefix . str_repeat( '*', 8 ) . substr( $key, -4 );
48 + }
49 + }
50 +
51 + if ( strlen( $key ) < 8 ) {
52 + return str_repeat( '*', strlen( $key ) );
53 + }
54 + return substr( $key, 0, 3 ) . str_repeat( '*', 8 ) . substr( $key, -4 );
55 + }
56 +
57 + /**
58 + * Drop the scheme + host from an absolute URL so it stays same-origin.
59 + *
60 + * Returns the ROOT-RELATIVE form of $url ("/wp-admin/admin-ajax.php",
61 + * "/wp-json/wp/v2/docs?search=foo", "/subdir/..." for a WP install in a
62 + * sub-directory) so the browser resolves it against the CURRENT page's origin
63 + * instead of the host baked into the URL. Path, query and fragment are kept
64 + * verbatim — only scheme+host are dropped — so sub-directory, multisite and
65 + * plain-permalink (?rest_route=) URLs all survive intact. A URL with no usable
66 + * path is returned unchanged.
67 + *
68 + * This is the origin-agnostic primitive behind frontend_ajax_url(); callers
69 + * whose output is embedded on a THIRD-PARTY site (e.g. the Instant Answer
70 + * cross-domain snippet) must keep the absolute URL and simply not call this.
71 + *
72 + * @param string $url Absolute URL.
73 + * @return string Root-relative URL, or $url unchanged when it has no path.
74 + */
75 + public static function relative_url( $url ) {
76 + if ( ! is_string( $url ) || $url === '' ) {
77 + return $url;
78 + }
79 +
80 + $parts = wp_parse_url( $url );
81 +
82 + if ( ! is_array( $parts ) || empty( $parts['path'] ) ) {
83 + return $url;
84 + }
85 +
86 + $relative = $parts['path'];
87 +
88 + if ( isset( $parts['query'] ) && $parts['query'] !== '' ) {
89 + $relative .= '?' . $parts['query'];
90 + }
91 +
92 + if ( isset( $parts['fragment'] ) && $parts['fragment'] !== '' ) {
93 + $relative .= '#' . $parts['fragment'];
94 + }
95 +
96 + return $relative;
97 + }
98 +
99 + /**
100 + * Same-origin admin-ajax URL for front-end requests (live search, etc.).
101 + *
102 + * admin_url() always resolves to the configured Site Address host, so when a
103 + * knowledge base is served on a HOST different from WP's Site Address — a
104 + * subdomain (e.g. faq.example.com), a domain alias, or a reverse proxy — the
105 + * AJAX request becomes cross-origin and is silently blocked or redirected by
106 + * the browser (the live search then returns no results).
107 + *
108 + * Emitting a ROOT-RELATIVE path ("/wp-admin/admin-ajax.php", or
109 + * "/subdir/wp-admin/admin-ajax.php" for a WP install in a sub-directory) lets
110 + * the browser resolve it against the CURRENT page's origin, so the request
111 + * always stays same-origin regardless of the Site Address. The path component
112 + * is taken verbatim from admin_url(), so sub-directory and multisite install
113 + * paths are preserved; only the scheme+host is dropped. When the parsed path
114 + * is empty, or in the admin area, the absolute URL is returned unchanged so
115 + * nothing else is affected.
116 + *
117 + * Override with the `betterdocs_frontend_ajax_url` filter if a site genuinely
118 + * needs an absolute or a different endpoint.
119 + *
120 + * @return string Root-relative admin-ajax path on the front end, else the absolute URL.
121 + */
122 + public static function frontend_ajax_url() {
123 + $ajax_url = admin_url( 'admin-ajax.php' );
124 +
125 + if ( ! is_admin() ) {
126 + $ajax_url = self::relative_url( $ajax_url );
127 + }
128 +
129 + /**
130 + * Filter the front-end admin-ajax URL used by BetterDocs live search.
131 + *
132 + * @param string $ajax_url Root-relative path (front end) or absolute URL.
133 + */
134 + return apply_filters( 'betterdocs_frontend_ajax_url', $ajax_url );
135 + }
136 +
137 + /**
138 + * Resolve the WPML-translated base slug of a taxonomy for the CURRENT language.
139 + *
140 + * WPML registers each translatable taxonomy's rewrite slug as a string named
141 + * "URL <taxonomy> tax slug" in the "WordPress" domain (e.g. "URL doc_tag tax slug").
142 + * BetterDocs stores only the default-language slug in its settings, so routing and
143 + * term links must read the translated value back here. Returns the trimmed default
144 + * slug unchanged when WPML is inactive or the string has no translation.
145 + *
146 + * @param string $taxonomy Taxonomy key, e.g. 'doc_tag'.
147 + * @param string $default_slug Default-language base slug from settings.
148 + * @return string Translated base slug for the active language (falls back to default).
149 + */
150 + public static function wpml_translated_tax_slug( $taxonomy, $default_slug ) {
151 + $default_slug = trim( (string) $default_slug, '/' );
152 +
153 + if ( $default_slug === '' || ! has_filter( 'wpml_translate_single_string' ) ) {
154 + return $default_slug;
155 + }
156 +
157 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML-owned filter; name must be used verbatim.
158 + $translated = apply_filters( 'wpml_translate_single_string', $default_slug, 'WordPress', 'URL ' . $taxonomy . ' tax slug' );
159 + $translated = trim( (string) $translated, '/' );
160 +
161 + return $translated !== '' ? $translated : $default_slug;
162 + }
163 +
164 + public static function get_plugins( $plugin_basename = null ) {
165 + if ( ! function_exists( 'get_plugins' ) ) {
166 + include_once ABSPATH . 'wp-admin/includes/plugin.php';
167 + }
168 +
169 + $plugins = get_plugins();
170 + return $plugin_basename == null ? $plugins : isset( $plugins[ $plugin_basename ] );
171 + }
172 +
173 + public static function is_plugin_active( $plugin_basename ) {
174 + if ( ! function_exists( 'is_plugin_active' ) ) {
175 + include_once ABSPATH . 'wp-admin/includes/plugin.php';
176 + }
177 +
178 + return is_plugin_active( $plugin_basename );
179 + }
180 +
181 + /**
182 + * Whether an SEO plugin already emits FAQPage schema on the current page.
183 + *
184 + * True only when Yoast or Rank Math is active AND its FAQ block is present
185 + * in the post's content, so BetterDocs can skip its own FAQPage JSON-LD and
186 + * avoid duplicate structured data. Defaults to the queried object when no
187 + * post is given.
188 + *
189 + * @param int|\WP_Post|null $post
190 + * @return bool
191 + */
192 + public static function seo_plugin_outputs_faq_schema( $post = null ) {
193 + if ( null === $post ) {
194 + $post = get_queried_object();
195 + }
196 +
197 + $post = get_post( $post );
198 + if ( ! $post instanceof \WP_Post ) {
199 + return false;
200 + }
201 +
202 + if ( self::is_plugin_active( 'wordpress-seo/wp-seo.php' ) && has_block( 'yoast/faq-block', $post ) ) {
203 + return true;
204 + }
205 +
206 + if ( self::is_plugin_active( 'seo-by-rank-math/rank-math.php' ) && has_block( 'rank-math/faq-block', $post ) ) {
207 + return true;
208 + }
209 +
210 + // Extension seam for Pro / other SEO integrations.
211 + return (bool) apply_filters( 'betterdocs_seo_plugin_outputs_faq_schema', false, $post );
212 + }
213 +
214 + public static function get_tax( $tax = '' ) {
215 + global $wp_query;
216 +
217 + if ( is_tax( 'knowledge_base' ) ) {
218 + $_taxes = $wp_query->tax_query->queried_terms;
219 + if ( array_key_exists( 'doc_category', $_taxes ) ) {
220 + $tax = 'doc_category';
221 + } else {
222 + $tax = 'knowledge_base';
223 + }
224 + } elseif ( is_tax( 'doc_category' ) ) {
225 + $tax = 'doc_category';
226 + } elseif ( is_tax( 'doc_tag' ) ) {
227 + $tax = 'doc_tag';
228 + }
229 +
230 + return $tax;
231 + }
232 +
233 + public function is_templates() {
234 + global $wp_query;
235 + $slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopedia' );
236 +
237 + $tax = $this->get_tax();
238 + if ( is_post_type_archive( 'docs' ) || $tax === 'knowledge_base' || $tax === 'doc_category' || $tax === 'doc_tag' || is_singular( 'docs' ) || is_tax( 'glossaries' ) ) {
239 + return true;
240 + }
241 +
242 + if ( isset( $wp_query->query['pagename'] ) && $wp_query->query['pagename'] === $slug ) {
243 + return true;
244 + }
245 +
246 + return false;
247 + }
248 +
249 + public function is_el_templates() {
250 + $_return_val = betterdocs()->editor->get( 'elementor' )->is_templates();
251 +
252 + if ( $_return_val !== null ) {
253 + return $_return_val;
254 + }
255 +
256 + $this->is_templates();
257 + }
258 +
259 + /**
260 + * Which tab to show.
261 + *
262 + * 1. Drag and Drop UI
263 + * 2. Post List UI
264 + *
265 + * * 1. dnd
266 + * * 2. classic
267 + *
268 + * look into views/admin/docs-ui directory to know more.
269 + *
270 + * @return string
271 + */
272 + public static function admin_tab() {
273 + $admin_ui = 'grid';
274 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin UI selection, no state change.
275 + $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
276 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin UI selection, no state change.
277 + $mode = isset( $_GET['mode'] ) ? sanitize_text_field( wp_unslash( $_GET['mode'] ) ) : '';
278 + if ( $page === 'betterdocs-admin' && ! empty( $mode ) ) {
279 + $admin_ui = $mode === 'grid' ? 'grid' : 'list';
280 + }
281 +
282 + return $admin_ui;
283 + }
284 +
285 + public static function is_active( $prev, $current, $class = 'active' ) {
286 + if ( $current == $prev ) {
287 + return $class;
288 + }
289 +
290 + return '';
291 + }
292 +
293 + public function get_users( $args ) {
294 + $cache_key = 'betterdocs_cache_admin_user_roles';
295 + $users = betterdocs()->database->get_cache( $cache_key );
296 +
297 + if ( false === $users ) {
298 + $users = get_users( $args );
299 + betterdocs()->database->set_cache( $cache_key, $users );
300 + }
301 +
302 + return $users;
303 + }
304 +
305 + /**
306 + * Normalize Menu Array
307 + * Menu creator helper
308 + *
309 + * @since 2.5.0
310 + *
311 + * @param string $title
312 + * @param string $slug
313 + * @param string $cap
314 + * @param array $callback
315 + *
316 + * @return array
317 + */
318 + public static function normalize_menu( $title, $slug, $cap = 'edit_docs', $callback = null, $optional = [] ) {
319 + $args = [
320 + 'page_title' => $title,
321 + 'menu_title' => $title,
322 + 'capability' => $cap,
323 + 'menu_slug' => $slug
324 + ];
325 +
326 + if ( $callback != null ) {
327 + $args['callback'] = $callback;
328 + }
329 +
330 + return wp_parse_args( $optional, $args );
331 + }
332 +
333 + /**
334 + * Check if the current theme is a block theme.
335 + *
336 + * @since x.x.x
337 + * @return bool
338 + */
339 + public function current_theme_is_fse_theme() {
340 + if ( function_exists( 'wp_is_block_theme' ) ) {
341 + return (bool) wp_is_block_theme();
342 + }
343 + if ( function_exists( 'gutenberg_is_fse_theme' ) ) {
344 + return (bool) gutenberg_is_fse_theme();
345 + }
346 +
347 + return false;
348 + }
349 +
350 + protected static function is_assoc_array( $array ) {
351 + return array_keys( $array ) !== range( 0, count( $array ) - 1 );
352 + }
353 +
354 + public static function merge( &$array1, &$array2 ) {
355 + $merged = $array1;
356 +
357 + foreach ( $array2 as $key => &$value ) {
358 + if ( is_array( $value ) && self::is_assoc_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
359 + $merged[ $key ] = self::merge( $merged[ $key ], $value );
360 + } elseif ( is_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
361 + $merged[ $key ] = array_merge( $merged[ $key ], $value );
362 + } else {
363 + $merged[ $key ] = $value;
364 + }
365 + }
366 +
367 + return $merged;
368 + }
369 +
370 + public static function get_custom_excerpt( $content, $numOfWords ) {
371 + $content = strip_shortcodes( $content );
372 + $content = wp_strip_all_tags( $content );
373 + $words = explode( ' ', $content );
374 + $excerptWords = array_slice( $words, 0, $numOfWords );
375 + $excerpt = implode( ' ', $excerptWords );
376 + if ( count( $words ) > $numOfWords ) {
377 + $excerpt .= '...';
378 + }
379 + return $excerpt;
380 + }
381 +
382 + /**
383 + * Get current language from various multilingual plugins
384 + *
385 + * @return string|null Current language code
386 + */
387 + public static function get_current_language() {
388 + $current_language = null;
389 +
390 + // WPML Support
391 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
392 + global $sitepress;
393 + if ( $sitepress && $sitepress->is_setup_complete() ) {
394 + $current_language = defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : $sitepress->get_current_language();
395 + }
396 + }
397 + // Polylang Support
398 + elseif ( function_exists( 'pll_current_language' ) ) {
399 + $current_language = pll_current_language();
400 + }
401 + // qTranslate-X Support
402 + elseif ( function_exists( 'qtranxf_getLanguage' ) ) {
403 + $current_language = qtranxf_getLanguage();
404 + }
405 + // Weglot Support
406 + elseif ( function_exists( 'weglot_get_current_language' ) ) {
407 + $current_language = weglot_get_current_language();
408 + }
409 + // TranslatePress Support
410 + elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
411 + $current_language = trp_get_current_language();
412 + }
413 +
414 + return $current_language;
415 + }
416 +
417 + /**
418 + * Check if any multilingual plugin is active
419 + *
420 + * @return bool
421 + */
422 + public static function is_multilingual_active() {
423 + return is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ||
424 + function_exists( 'pll_current_language' ) ||
425 + function_exists( 'qtranxf_getLanguage' ) ||
426 + function_exists( 'weglot_get_current_language' ) ||
427 + ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) );
428 + }
429 +
430 + /**
431 + * Configured/active languages from whichever multilingual plugin is present.
432 + *
433 + * Returns a list of { value, label } pairs (language code + display name).
434 + * Used to populate the optional language selector in the Write-with-AI modal;
435 + * returns an empty array when no multilingual plugin is active so the
436 + * selector stays hidden. Mirrors the Pro cross-domain language options.
437 + *
438 + * @return array<int,array{value:string,label:string}>
439 + */
440 + public static function get_active_languages() {
441 + $options = array();
442 +
443 + // WPML
444 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
445 + global $sitepress;
446 + if ( $sitepress && method_exists( $sitepress, 'get_active_languages' ) ) {
447 + $active_languages = $sitepress->get_active_languages();
448 + if ( is_array( $active_languages ) ) {
449 + foreach ( $active_languages as $code => $lang ) {
450 + $options[] = array(
451 + 'value' => (string) $code,
452 + 'label' => isset( $lang['native_name'] ) ? $lang['native_name'] : (string) $code,
453 + );
454 + }
455 + }
456 + }
457 + } elseif ( function_exists( 'pll_languages_list' ) ) {
458 + // Polylang
459 + $languages = pll_languages_list( array( 'fields' => array() ) );
460 + if ( is_array( $languages ) ) {
461 + foreach ( $languages as $lang ) {
462 + if ( is_object( $lang ) && isset( $lang->slug ) ) {
463 + $options[] = array(
464 + 'value' => (string) $lang->slug,
465 + 'label' => isset( $lang->name ) ? $lang->name : (string) $lang->slug,
466 + );
467 + }
468 + }
469 + }
470 + }
471 +
472 + /**
473 + * Filter the language options exposed to the Write-with-AI modal.
474 + *
475 + * @param array $options List of { value, label } language pairs.
476 + */
477 + return apply_filters( 'betterdocs_active_languages', $options );
478 + }
479 +
480 + /**
481 + * Check if we should apply language filtering
482 + * Only apply on frontend or when specifically requested
483 + *
484 + * @return bool
485 + */
486 + public static function should_apply_language_filtering() {
487 + // Don't apply language filtering in admin context unless it's a frontend request
488 + if ( is_admin() ) {
489 + // Allow language filtering for REST API requests that are frontend-facing
490 + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
491 + // Check if this is a frontend REST request (not admin)
492 + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
493 + // Don't filter admin REST requests for glossaries management
494 + if ( strpos( $request_uri, '/wp/v2/glossaries' ) !== false ) {
495 + return false; // Don't filter admin glossaries management
496 + }
497 + }
498 + return false; // Don't filter other admin requests
499 + }
500 +
501 + // Apply filtering on frontend
502 + return true;
503 + }
504 +
505 + /**
506 + * Get current admin language for multilingual sites
507 + * This is specifically for admin context where we need to detect
508 + * the language being used for editing terms/posts
509 + *
510 + * @return string|null Current admin language code
511 + */
512 + public static function get_current_admin_language() {
513 + $current_language = null;
514 +
515 + // Explicit language passed by the admin client takes priority.
516 + // Covers AJAX (POST) and REST/admin requests (GET) where WPML may
517 + // otherwise resolve to the site's default language instead of the
518 + // admin UI language.
519 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only UI language hint, sanitized; not a state-changing form submission.
520 + if ( isset( $_POST['lang'] ) && ! empty( $_POST['lang'] ) ) {
521 + return self::sanitize_language_code( wp_unslash( $_POST['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see note above.
522 + }
523 +
524 + // Limit GET handling to admin/REST contexts so a frontend ?lang= switch
525 + // doesn't hijack admin meta-key resolution.
526 + if ( isset( $_GET['lang'] ) && ! empty( $_GET['lang'] )
527 + && ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) ) {
528 + return self::sanitize_language_code( wp_unslash( $_GET['lang'] ) );
529 + }
530 +
531 + // WPML Support - Admin language detection
532 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
533 + global $sitepress;
534 + if ( $sitepress && $sitepress->is_setup_complete() ) {
535 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
536 + $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
537 + // For term editing, check if we have a specific term language
538 + if ( $tag_id && function_exists( 'wpml_get_language_information' ) ) {
539 + $term_info = wpml_get_language_information( null, $tag_id );
540 + if ( ! is_wp_error( $term_info ) && $term_info && isset( $term_info['language_code'] ) ) {
541 + $current_language = $term_info['language_code'];
542 + }
543 +
544 + }
545 +
546 + // Check for language parameter in URL
547 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
548 + if ( ! $current_language && isset( $_GET['lang'] ) ) {
549 + $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
550 + }
551 +
552 + // Check WPML admin language cookie (persists during AJAX)
553 + if ( ! $current_language && isset( $_COOKIE['_icl_current_admin_language'] ) ) {
554 + $current_language = sanitize_text_field( wp_unslash( $_COOKIE['_icl_current_admin_language'] ) );
555 + }
556 +
557 + // Fallback to admin language or current language
558 + if ( ! $current_language ) {
559 + $current_language = defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : $sitepress->get_current_language();
560 + }
561 + }
562 + }
563 + // Polylang Support - Admin language detection
564 + elseif ( function_exists( 'pll_current_language' ) ) {
565 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
566 + $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
567 + // For term editing, get language from term ID
568 + if ( $tag_id && function_exists( 'pll_get_term_language' ) ) {
569 + $term_lang = pll_get_term_language( $tag_id );
570 + if ( $term_lang ) {
571 + $current_language = $term_lang;
572 + }
573 + }
574 +
575 + // Check for language parameter in URL
576 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
577 + if ( ! $current_language && isset( $_GET['lang'] ) ) {
578 + $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
579 + }
580 +
581 + // Fallback to current admin language
582 + if ( ! $current_language ) {
583 + $current_language = pll_current_language( 'slug' );
584 + }
585 + }
586 + // Other multilingual plugins
587 + elseif ( function_exists( 'qtranxf_getLanguage' ) ) {
588 + $current_language = qtranxf_getLanguage();
589 + }
590 + elseif ( function_exists( 'weglot_get_current_language' ) ) {
591 + $current_language = weglot_get_current_language();
592 + }
593 + elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
594 + $current_language = trp_get_current_language();
595 + }
596 +
597 + return self::sanitize_language_code( $current_language );
598 + }
599 +
600 + /**
601 + * Normalize a language code to the character set real language codes use
602 + * (`en`, `en_US`, `zh-Hans`). Values reach this from `?lang=`, `$_POST['lang']`
603 + * and the WPML admin cookie, and `sanitize_text_field()` leaves quotes intact —
604 + * so anything used to build a meta key or SQL fragment must be narrowed here.
605 + * Defense in depth: callers that reach SQL must still bind their values.
606 + *
607 + * @param string|null $language Raw language code.
608 + * @return string|null Normalized code, or null when nothing usable remains.
609 + */
610 + private static function sanitize_language_code( $language ) {
611 + if ( ! is_string( $language ) || '' === $language ) {
612 + return null;
613 + }
614 +
615 + $language = preg_replace( '/[^A-Za-z0-9_-]/', '', $language );
616 +
617 + return '' !== $language ? $language : null;
618 + }
619 +
620 + /**
621 + * Generate language-specific meta key for category ordering
622 + * Always falls back to base key if language-specific key doesn't exist
623 + *
624 + * @param string $base_key The base meta key (e.g., 'doc_category_order')
625 + * @param string|null $language Language code, if null will auto-detect
626 + * @return string Language-specific meta key or base key as fallback
627 + */
628 + public static function get_language_specific_meta_key( $base_key, $language = null ) {
629 + // If no multilingual plugin is active, return the base key
630 + if ( ! self::is_multilingual_active() ) {
631 + return $base_key;
632 + }
633 +
634 + // Get current admin language if not provided
635 + if ( $language === null ) {
636 + $language = self::get_current_admin_language();
637 + }
638 +
639 + // If no language detected, return base key for backward compatibility
640 + if ( ! $language ) {
641 + return $base_key;
642 + }
643 +
644 + // Always return base key for now - we'll handle fallback in the query functions
645 + // This ensures compatibility without requiring migration
646 + return $base_key;
647 + }
648 +
649 + /**
650 + * Get the meta key to write to.
651 + *
652 + * Unlike `get_meta_key_with_fallback`, this never falls back to the base
653 + * key when the language-specific key is empty — that fallback is what
654 + * caused secondary-language drag-and-drop saves to clobber the base meta
655 + * (and on WPML setups that copy term meta from the original language,
656 + * the next read would re-overwrite it from the primary language).
657 + *
658 + * @param string $base_key The base meta key.
659 + * @param string|null $language Language code, auto-detected when null.
660 + * @return string Language-specific key when multilingual + language known, else base.
661 + */
662 + public static function get_meta_key_for_save( $base_key, $language = null ) {
663 + if ( ! self::is_multilingual_active() ) {
664 + return $base_key;
665 + }
666 +
667 + if ( $language === null ) {
668 + $language = self::get_current_admin_language();
669 + }
670 +
671 + if ( ! $language ) {
672 + return $base_key;
673 + }
674 +
675 + return $base_key . '_' . $language;
676 + }
677 +
678 + /**
679 + * Get the appropriate meta key with fallback logic
680 + * This function checks if language-specific meta exists, if not falls back to base key
681 + *
682 + * @param string $base_key The base meta key
683 + * @param int $term_id The term ID to check
684 + * @param string|null $language Language code
685 + * @return string The meta key to use
686 + */
687 + public static function get_meta_key_with_fallback( $base_key, $term_id = null, $language = null ) {
688 + // If no multilingual plugin is active, return the base key
689 + if ( ! self::is_multilingual_active() ) {
690 + return $base_key;
691 + }
692 +
693 + // Get current admin language if not provided
694 + if ( $language === null ) {
695 + $language = self::get_current_admin_language();
696 + }
697 +
698 + // If no language detected, return base key
699 + if ( ! $language ) {
700 + return $base_key;
701 + }
702 +
703 + $lang_meta_key = $base_key . '_' . $language;
704 +
705 + // If we have a specific term ID, check if language-specific meta exists
706 + if ( $term_id ) {
707 + $lang_value = get_term_meta( $term_id, $lang_meta_key, true );
708 + if ( ! empty( $lang_value ) ) {
709 + return $lang_meta_key;
710 + }
711 + // Fall back to base key if language-specific doesn't exist
712 + return $base_key;
713 + }
714 +
715 + // For queries without specific term ID, we need to check if ANY terms have language-specific meta
716 + global $wpdb;
717 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live multilingual meta-key resolution; result varies per active language.
718 + $has_lang_meta = $wpdb->get_var( $wpdb->prepare(
719 + "SELECT COUNT(*) FROM {$wpdb->termmeta} tm
720 + INNER JOIN {$wpdb->term_taxonomy} tt ON tm.term_id = tt.term_id
721 + WHERE tm.meta_key = %s AND tt.taxonomy = 'doc_category' AND tm.meta_value != ''",
722 + $lang_meta_key
723 + ) );
724 +
725 + // If language-specific meta exists for some terms, use it (terms without it will have empty values)
726 + // Otherwise, fall back to base key
727 + return $has_lang_meta > 0 ? $lang_meta_key : $base_key;
728 + }
729 +
730 + /**
731 + * Migrate existing category orders to language-specific meta keys
732 + * This should be called when a multilingual plugin is activated
733 + *
734 + * @param string $base_key The base meta key (e.g., 'doc_category_order')
735 + * @param string $taxonomy The taxonomy to migrate
736 + * @return bool Success status
737 + */
738 + public static function migrate_category_orders_to_multilingual( $base_key = 'doc_category_order', $taxonomy = 'doc_category' ) {
739 + // Only run if multilingual plugin is active
740 + if ( ! self::is_multilingual_active() ) {
741 + return false;
742 + }
743 +
744 + global $wpdb;
745 +
746 + // Get all terms with the base meta key
747 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
748 + $terms_with_order = $wpdb->get_results( $wpdb->prepare(
749 + "SELECT tm.term_id, tm.meta_value, t.slug
750 + FROM {$wpdb->termmeta} tm
751 + INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
752 + INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
753 + WHERE tm.meta_key = %s AND tt.taxonomy = %s",
754 + $base_key,
755 + $taxonomy
756 + ) );
757 +
758 + if ( empty( $terms_with_order ) ) {
759 + return true; // Nothing to migrate
760 + }
761 +
762 + // Get available languages
763 + $languages = self::get_available_languages();
764 +
765 + if ( empty( $languages ) ) {
766 + return false; // No languages found
767 + }
768 +
769 + // Migrate orders for each language
770 + foreach ( $languages as $language ) {
771 + $language_meta_key = $base_key . '_' . $language;
772 +
773 + foreach ( $terms_with_order as $term_data ) {
774 + // Check if language-specific meta already exists
775 + $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
776 +
777 + if ( empty( $existing_value ) ) {
778 + // Copy the base order to language-specific key
779 + update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
780 + }
781 + }
782 + }
783 +
784 + return true;
785 + }
786 +
787 + /**
788 + * Migrate existing document orders to language-specific meta keys
789 + * This should be called when a multilingual plugin is activated
790 + *
791 + * @param string $base_key The base meta key (e.g., '_docs_order')
792 + * @param string $taxonomy The taxonomy to migrate
793 + * @return bool Success status
794 + */
795 + public static function migrate_docs_orders_to_multilingual( $base_key = '_docs_order', $taxonomy = 'doc_category' ) {
796 + // Only run if multilingual plugin is active
797 + if ( ! self::is_multilingual_active() ) {
798 + return false;
799 + }
800 +
801 + global $wpdb;
802 +
803 + // Get all terms with the base meta key for document ordering
804 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
805 + $terms_with_docs_order = $wpdb->get_results( $wpdb->prepare(
806 + "SELECT tm.term_id, tm.meta_value, t.slug
807 + FROM {$wpdb->termmeta} tm
808 + INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
809 + INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
810 + WHERE tm.meta_key = %s AND tt.taxonomy = %s AND tm.meta_value != ''",
811 + $base_key,
812 + $taxonomy
813 + ) );
814 +
815 + if ( empty( $terms_with_docs_order ) ) {
816 + return true; // Nothing to migrate
817 + }
818 +
819 + // Get available languages
820 + $languages = self::get_available_languages();
821 +
822 + if ( empty( $languages ) ) {
823 + return false; // No languages found
824 + }
825 +
826 + // Migrate document orders for each language
827 + foreach ( $languages as $language ) {
828 + $language_meta_key = $base_key . '_' . $language;
829 +
830 + foreach ( $terms_with_docs_order as $term_data ) {
831 + // Check if language-specific meta already exists
832 + $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
833 +
834 + if ( empty( $existing_value ) ) {
835 + // Copy the base document order to language-specific key
836 + update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
837 + }
838 + }
839 + }
840 +
841 + return true;
842 + }
843 +
844 + /**
845 + * Migrate both category and document orders to multilingual format
846 + * This is a convenience method that runs both migrations
847 + *
848 + * @return bool Success status
849 + */
850 + public static function migrate_all_orders_to_multilingual() {
851 + $category_result = self::migrate_category_orders_to_multilingual();
852 + $docs_result = self::migrate_docs_orders_to_multilingual();
853 +
854 + return $category_result && $docs_result;
855 + }
856 +
857 + /**
858 + * Get available languages from multilingual plugins
859 + *
860 + * @return array Array of language codes
861 + */
862 + public static function get_available_languages() {
863 + $languages = [];
864 +
865 + // WPML Support
866 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
867 + global $sitepress;
868 + if ( $sitepress && $sitepress->is_setup_complete() ) {
869 + $active_languages = $sitepress->get_active_languages();
870 + if ( is_array( $active_languages ) ) {
871 + $languages = array_keys( $active_languages );
872 + }
873 + }
874 + }
875 + // Polylang Support
876 + elseif ( function_exists( 'pll_languages_list' ) ) {
877 + $languages = pll_languages_list();
878 + }
879 +
880 + return $languages;
881 + }
882 +
883 + /**
884 + * Rich list of active site languages for the React admin language bar.
885 + *
886 + * @return array<int,array{code:string,label:string,native:string,flag:string}>
887 + * Empty when no supported multilingual plugin is active.
888 + */
889 + public static function get_admin_languages() {
890 + $languages = [];
891 +
892 + // WPML
893 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
894 + global $sitepress;
895 + if ( $sitepress && $sitepress->is_setup_complete() ) {
896 + $active = $sitepress->get_active_languages();
897 + if ( is_array( $active ) ) {
898 + foreach ( $active as $code => $lang ) {
899 + $languages[] = [
900 + 'code' => $code,
901 + 'label' => isset( $lang['english_name'] ) ? $lang['english_name'] : $code,
902 + 'native' => isset( $lang['native_name'] ) ? $lang['native_name'] : ( isset( $lang['display_name'] ) ? $lang['display_name'] : $code ),
903 + 'flag' => isset( $lang['country_flag_url'] ) ? $lang['country_flag_url'] : '',
904 + ];
905 + }
906 + }
907 + }
908 + }
909 + // Polylang
910 + elseif ( function_exists( 'pll_languages_list' ) ) {
911 + $list = pll_languages_list( [ 'fields' => '' ] ); // full PLL_Language objects
912 + if ( is_array( $list ) ) {
913 + foreach ( $list as $lang ) {
914 + if ( ! is_object( $lang ) ) {
915 + continue;
916 + }
917 + $languages[] = [
918 + 'code' => isset( $lang->slug ) ? $lang->slug : '',
919 + 'label' => isset( $lang->name ) ? $lang->name : ( isset( $lang->slug ) ? $lang->slug : '' ),
920 + 'native' => isset( $lang->name ) ? $lang->name : '',
921 + 'flag' => isset( $lang->flag_url ) ? $lang->flag_url : '',
922 + ];
923 + }
924 + }
925 + }
926 +
927 + return $languages;
928 + }
929 +
930 + /**
931 + * Read a term's language code via the active multilingual plugin.
932 + *
933 + * @param \WP_Term $term
934 + * @return string Language code, or '' when unavailable.
935 + */
936 + public static function get_term_language( $term ) {
937 + if ( ! is_object( $term ) || empty( $term->term_id ) ) {
938 + return '';
939 + }
940 +
941 + // Polylang — takes the term_id.
942 + if ( function_exists( 'pll_get_term_language' ) ) {
943 + $lang = pll_get_term_language( $term->term_id, 'slug' );
944 + return $lang ? $lang : '';
945 + }
946 +
947 + // WPML — element_id is the term_taxonomy_id (NOT the term_id); WPML
948 + // normalizes the element_type to `tax_<taxonomy>` internally.
949 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
950 + $lang = apply_filters( 'wpml_element_language_code', null, [
951 + 'element_id' => $term->term_taxonomy_id,
952 + 'element_type' => isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category',
953 + ] );
954 + return $lang ? $lang : '';
955 + }
956 +
957 + return '';
958 + }
959 +
960 + /**
961 + * Stamp a term's language via the active multilingual plugin. Standalone
962 + * assignment only — it sets/re-stamps the term's own language and does not
963 + * link it into an existing translation group.
964 + *
965 + * @param \WP_Term $term
966 + * @param string $lang_code
967 + */
968 + public static function set_term_language( $term, $lang_code ) {
969 + $lang_code = sanitize_text_field( (string) $lang_code );
970 + if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
971 + return;
972 + }
973 +
974 + // Polylang
975 + if ( function_exists( 'pll_set_term_language' ) ) {
976 + pll_set_term_language( $term->term_id, $lang_code );
977 + return;
978 + }
979 +
980 + // WPML — element_id is the term_taxonomy_id; element_type is tax_<taxonomy>;
981 + // trid=null sets it as a standalone original in the chosen language.
982 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
983 + $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
984 + do_action( 'wpml_set_element_language_details', [
985 + 'element_id' => $term->term_taxonomy_id,
986 + 'element_type' => 'tax_' . $taxonomy,
987 + 'trid' => null,
988 + 'language_code' => $lang_code,
989 + 'source_language_code' => null,
990 + ] );
991 + }
992 + }
993 +
994 + /**
995 + * The site's default language code, or '' when no multilingual plugin is active.
996 + */
997 + public static function get_default_language() {
998 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
999 + global $sitepress;
1000 + if ( $sitepress ) {
1001 + return (string) $sitepress->get_default_language();
1002 + }
1003 + }
1004 + if ( function_exists( 'pll_default_language' ) ) {
1005 + return (string) pll_default_language( 'slug' );
1006 + }
1007 + return '';
1008 + }
1009 +
1010 + /**
1011 + * All terms in a term's translation group, keyed by language code.
1012 + *
1013 + * @param \WP_Term $term
1014 + * @return array<string,array{term_id:int,name:string}>
1015 + */
1016 + public static function get_term_translations( $term ) {
1017 + if ( ! is_object( $term ) || empty( $term->term_id ) ) {
1018 + return [];
1019 + }
1020 + $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
1021 + $out = [];
1022 +
1023 + // Polylang
1024 + if ( function_exists( 'pll_get_term_translations' ) ) {
1025 + $group = pll_get_term_translations( $term->term_id ); // [lang => term_id]
1026 + if ( is_array( $group ) ) {
1027 + foreach ( $group as $lang => $tid ) {
1028 + $t = get_term( (int) $tid, $taxonomy );
1029 + if ( $t && ! is_wp_error( $t ) ) {
1030 + $out[ $lang ] = [ 'term_id' => (int) $tid, 'name' => $t->name ];
1031 + }
1032 + }
1033 + }
1034 + return $out;
1035 + }
1036 +
1037 + // WPML
1038 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
1039 + $el_type = 'tax_' . $taxonomy;
1040 + $trid = apply_filters( 'wpml_element_trid', null, $term->term_taxonomy_id, $el_type );
1041 + if ( ! $trid ) {
1042 + return $out;
1043 + }
1044 + $translations = apply_filters( 'wpml_get_element_translations', null, $trid, $el_type );
1045 + if ( is_array( $translations ) ) {
1046 + foreach ( $translations as $lang => $tr ) {
1047 + $tid = isset( $tr->term_id ) ? (int) $tr->term_id : 0;
1048 + if ( ! $tid ) {
1049 + continue;
1050 + }
1051 + $t = get_term( $tid, $taxonomy );
1052 + $out[ $lang ] = [
1053 + 'term_id' => $tid,
1054 + 'name' => ( $t && ! is_wp_error( $t ) ) ? $t->name : ( isset( $tr->name ) ? $tr->name : '' ),
1055 + ];
1056 + }
1057 + }
1058 + }
1059 +
1060 + return $out;
1061 + }
1062 +
1063 + /**
1064 + * Candidate source terms for the "This is a translation of" dropdown — terms in
1065 + * $source_lang (default language) that aren't yet translated into $target_lang.
1066 + *
1067 + * @return array<int,array{term_id:int,name:string}>
1068 + */
1069 + public static function get_translation_candidates( $taxonomy, $target_lang, $source_lang ) {
1070 + $candidates = [];
1071 + $target_lang = sanitize_text_field( (string) $target_lang );
1072 + $source_lang = sanitize_text_field( (string) $source_lang );
1073 + if ( $taxonomy === '' || $source_lang === '' ) {
1074 + return $candidates;
1075 + }
1076 +
1077 + // WPML
1078 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1079 + global $sitepress;
1080 + if ( $sitepress && method_exists( $sitepress, 'get_elements_without_translations' ) ) {
1081 + $ttids = $sitepress->get_elements_without_translations( 'tax_' . $taxonomy, $target_lang, $source_lang );
1082 + foreach ( (array) $ttids as $ttid ) {
1083 + $t = get_term_by( 'term_taxonomy_id', (int) $ttid, $taxonomy );
1084 + if ( $t && ! is_wp_error( $t ) ) {
1085 + $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
1086 + }
1087 + }
1088 + }
1089 + return $candidates;
1090 + }
1091 +
1092 + // Polylang — source-lang terms whose group lacks the target language.
1093 + if ( function_exists( 'pll_get_term_translations' ) && function_exists( 'pll_get_term_language' ) ) {
1094 + $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'lang' => $source_lang ] );
1095 + foreach ( (array) $terms as $t ) {
1096 + if ( is_wp_error( $t ) ) {
1097 + continue;
1098 + }
1099 + $group = pll_get_term_translations( $t->term_id );
1100 + if ( ! isset( $group[ $target_lang ] ) ) {
1101 + $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
1102 + }
1103 + }
1104 + }
1105 +
1106 + return $candidates;
1107 + }
1108 +
1109 + /**
1110 + * Set a term's language and (optionally) link it into the translation group of
1111 + * $translation_of_term_id. Empty $translation_of_term_id = standalone.
1112 + *
1113 + * @param \WP_Term $term
1114 + * @param string $lang_code
1115 + * @param int $translation_of_term_id
1116 + */
1117 + public static function link_term_translation( $term, $lang_code, $translation_of_term_id = 0 ) {
1118 + $lang_code = sanitize_text_field( (string) $lang_code );
1119 + if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
1120 + return;
1121 + }
1122 + $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
1123 + $translation_of_term_id = (int) $translation_of_term_id;
1124 +
1125 + // Polylang
1126 + if ( function_exists( 'pll_set_term_language' ) ) {
1127 + pll_set_term_language( $term->term_id, $lang_code );
1128 + if ( $translation_of_term_id && function_exists( 'pll_save_term_translations' ) ) {
1129 + $group = function_exists( 'pll_get_term_translations' )
1130 + ? (array) pll_get_term_translations( $translation_of_term_id )
1131 + : [];
1132 + $group[ $lang_code ] = $term->term_id;
1133 + pll_save_term_translations( $group );
1134 + }
1135 + return;
1136 + }
1137 +
1138 + // WPML
1139 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
1140 + $el_type = 'tax_' . $taxonomy;
1141 + $trid = null;
1142 + $src = null;
1143 +
1144 + if ( $translation_of_term_id ) {
1145 + $source = get_term( $translation_of_term_id, $taxonomy );
1146 + if ( $source && ! is_wp_error( $source ) ) {
1147 + $trid = apply_filters( 'wpml_element_trid', null, $source->term_taxonomy_id, $el_type );
1148 + $src = self::get_term_language( $source );
1149 + }
1150 + }
1151 +
1152 + do_action( 'wpml_set_element_language_details', [
1153 + 'element_id' => $term->term_taxonomy_id,
1154 + 'element_type' => $el_type,
1155 + 'trid' => $trid,
1156 + 'language_code' => $lang_code,
1157 + 'source_language_code' => $src,
1158 + ] );
1159 + }
1160 + }
1161 +
1162 + // NOTE: get_current_letter_docs() and docs_sort_by_letter() used to live here.
1163 + // Encyclopedia is a Pro-only feature and these had no callers in Free at all,
1164 + // so they now ship as BetterDocsPro\Utils\EncyclopediaQuery. The generic
1165 + // helpers they lean on (get_current_language, is_multilingual_active,
1166 + // should_apply_language_filtering, get_custom_excerpt) stay here and are
1167 + // called through this class from Pro.
1168 +
1169 + public static function get_glossaries() {
1170 + global $wpdb;
1171 +
1172 + $lang_join = '';
1173 + $lang_where = '';
1174 +
1175 + // Add language filtering if multilingual plugin is active and we should apply filtering
1176 + $current_language = self::get_current_language();
1177 + if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1178 + // Restrict language code to a safe character set before SQL interpolation.
1179 + $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1180 + // For WPML, use icl_translations table
1181 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1182 + $lang_join = " LEFT JOIN {$wpdb->prefix}icl_translations icl_t ON icl_t.element_id = t.term_id AND icl_t.element_type = 'tax_glossaries'";
1183 + $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1184 + }
1185 + // For Polylang, use term_relationships with language taxonomy
1186 + elseif ( function_exists( 'pll_current_language' ) ) {
1187 + $lang_join = " LEFT JOIN {$wpdb->term_relationships} tr ON t.term_id = tr.object_id LEFT JOIN {$wpdb->term_taxonomy} tt_lang ON tr.term_taxonomy_id = tt_lang.term_taxonomy_id AND tt_lang.taxonomy = 'language' LEFT JOIN {$wpdb->terms} t_lang ON tt_lang.term_id = t_lang.term_id";
1188 + $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1189 + }
10 1190 }
11 1191
12 - $plugins = get_plugins();
13 - return $plugin_basename == null ? $plugins : isset( $plugins[$plugin_basename] );
14 - }
1192 + $query = "
1193 + SELECT t.name
1194 + FROM {$wpdb->terms} t
1195 + INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1196 + $lang_join
1197 + WHERE tt.taxonomy = 'glossaries'
1198 + $lang_where
1199 + ORDER BY t.name ASC
1200 + ";
15 1201
16 - public static function is_plugin_active( $plugin_basename ) {
17 - if ( ! function_exists( 'is_plugin_active' ) ) {
18 - include_once ABSPATH . 'wp-admin/includes/plugin.php';
19 - }
1202 + $glossaries = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
20 1203
21 - return is_plugin_active( $plugin_basename );
22 - }
1204 + return $glossaries;
1205 + }
23 1206
24 - public static function get_tax( $tax = '' ) {
25 - global $wp_query;
1207 + /**
1208 + * Determine live search template layout, when live search is not selected from customizer(this will work when live search template is not selected from customizer)
1209 + *
1210 + * @param string $layout
1211 + * @return string $layout
1212 + */
1213 + public static function determine_search_layout( $layout ) {
1214 + if ( $layout ) {
1215 + return $layout;
1216 + }
26 1217
27 - if ( is_tax( 'knowledge_base' ) ) {
28 - $_taxes = $wp_query->tax_query->queried_terms;
29 - if ( array_key_exists( "doc_category", $_taxes ) ) {
30 - $tax = 'doc_category';
31 - } else {
32 - $tax = 'knowledge_base';
1218 + $search_layout = betterdocs()->customizer->defaults->get( 'betterdocs_search_layout_select' );
1219 + $docs_layout = betterdocs()->customizer->defaults->get( 'betterdocs_docs_layout_select' );
1220 + $archive_page_layout = betterdocs()->customizer->defaults->get( 'betterdocs_archive_layout_select' );
1221 + $single_layout = betterdocs()->customizer->defaults->get( 'betterdocs_single_layout_select' );
1222 +
1223 + if ( is_post_type_archive( 'docs' ) ) {
1224 + if ( $docs_layout != "layout-7" && ! $search_layout ) {
1225 + $layout = 'layout-1';
1226 + } else if ( $docs_layout == 'layout-7' && ! $search_layout ) {
1227 + $layout = 'layout-2';
33 1228 }
34 - } elseif ( is_tax( 'doc_category' ) ) {
35 - $tax = 'doc_category';
36 - } elseif ( is_tax( 'doc_tag' ) ) {
37 - $tax = 'doc_tag';
1229 + } else if ( is_tax( 'doc_tag' ) && ! $search_layout ) {
1230 + $layout = 'layout-1';
1231 + } else if ( is_tax( 'doc_category' ) ) {
1232 + if ( $archive_page_layout != 'layout-7' && $archive_page_layout != 'layout-8' && ! $search_layout ) {
1233 + $layout = 'layout-1';
1234 + } else if ( ( $archive_page_layout == 'layout-7' && ! $search_layout ) || ( $archive_page_layout == 'layout-8' && ! $search_layout ) ) {
1235 + $layout = 'layout-2';
1236 + }
1237 + } else if ( is_singular( 'docs' ) ) {
1238 + if ( $single_layout != 'layout-8' && $single_layout != 'layout-9' && ! $search_layout ) {
1239 + $layout = 'layout-1';
1240 + } else if ( ( $single_layout == 'layout-8' && ! $search_layout ) || ( $single_layout == 'layout-9' && ! $search_layout ) ) {
1241 + $layout = 'layout-2';
1242 + }
38 1243 }
39 1244
40 - return $tax;
1245 + return $layout;
41 1246 }
42 1247
43 - public function is_templates() {
44 - $tax = $this->get_tax();
45 - if ( is_post_type_archive( 'docs' ) || $tax === 'knowledge_base' || $tax === 'doc_category' || $tax === 'doc_tag' || is_singular( 'docs' ) ) {
46 - return true;
1248 + // NOTE: the alphabet-range helpers (get_character_range, unicodeRange,
1249 + // mb_ord_fallback, mb_chr_fallback) moved to BetterDocsPro\Utils\EncyclopediaQuery
1250 + // along with the two methods above — same reason: Encyclopedia is Pro-only
1251 + // and nothing in Free ever called them.
1252 +
1253 + public static function get_the_top_most_parent( $term_id ) {
1254 + while ( $term_id != 0 ) {
1255 + $parent_id = wp_get_term_taxonomy_parent_id( $term_id, 'doc_category' );
1256 +
1257 + if ( $parent_id == 0 ) {
1258 + break;
1259 + }
1260 +
1261 + $term_id = $parent_id;
47 1262 }
1263 + return $term_id;
1264 + }
48 1265
49 - return false;
1266 + public static function get_highest_docs_term() {
1267 + $terms = get_terms( [
1268 + 'taxonomy' => 'doc_category', // Change to your desired taxonomy
1269 + 'hide_empty' => true, // Only show terms with posts
1270 + 'orderby' => 'count', // Order by post count
1271 + 'order' => 'DESC', // Descending order
1272 + 'number' => 1 // Get only the top term
1273 + ] );
1274 + return isset( $terms[0] ) ? $terms[0] : [];
50 1275 }
51 1276
52 - public function is_el_templates() {
53 - $_return_val = betterdocs()->editor->get( 'elementor' )->is_templates();
1277 + public static function delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
1278 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- targeted bulk delete by FAQ category; tax filter is required.
1279 + $args = [
1280 + 'post_type' => 'betterdocs_faq',
1281 + 'posts_per_page' => -1,
1282 + // EVERY status, explicitly. WP_Query defaults to 'publish', so "delete this
1283 + // group and its FAQs" was deleting only the published ones — the drafts (and
1284 + // pending/scheduled/private/trashed FAQs) survived, and the wp_delete_term()
1285 + // that follows then stripped their category, leaving them orphaned under
1286 + // "Uncategorized". Note 'any' is NOT enough here: it excludes trash.
1287 + 'post_status' => [ 'publish', 'draft', 'pending', 'future', 'private', 'trash' ],
1288 + 'tax_query' => [
1289 + [
1290 + 'taxonomy' => $taxonomy,
1291 + 'field' => 'id',
1292 + 'terms' => $term_id,
1293 + 'operator' => 'IN'
1294 + ]
1295 + ],
1296 + 'fields' => 'ids'
1297 + ];
54 1298
55 - if ( $_return_val !== null ) {
56 - return $_return_val;
1299 + $query = new \WP_Query( $args );
1300 +
1301 + if ( $query->have_posts() ) {
1302 + foreach ( $query->posts as $doc_id ) {
1303 + wp_delete_post( $doc_id, true );
1304 + }
57 1305 }
1306 + }
58 1307
59 - $this->is_templates();
60 - }
1308 + /**
1309 + * Function To Normalize Repeater Field For Quick Builder
1310 + *
1311 + * @param array $fields
1312 + * @param array $include_field_keys
1313 + *
1314 + * @return array
1315 + */
1316 + public static function normalize_repeater_field( $fields, $include_field_keys = [] ) {
1317 + if( empty( $include_field_keys ) ) {
1318 + return $fields;
1319 + }
61 1320
62 - /**
63 - * Which tab to show.
64 - *
65 - * 1. Drag and Drop UI
66 - * 2. Post List UI
67 - *
68 - * * 1. dnd
69 - * * 2. classic
70 - *
71 - * look into views/admin/docs-ui directory to know more.
72 - *
73 - * @return string
74 - */
75 - public static function admin_tab() {
76 - $admin_ui = 'grid';
77 - if ( isset( $_GET['mode'], $_GET['page'] ) && $_GET['page'] === 'betterdocs-admin' && ! empty( $_GET['mode'] ) ) {
78 - $admin_ui = $_GET['mode'] === 'grid' ? 'grid' : 'list';
79 - }
1321 + $normalized_fields = [];
80 1322
81 - return $admin_ui;
82 - }
1323 + foreach( $fields as $field ) {
1324 + foreach( $include_field_keys as $field_key ) {
1325 + if( ! isset( $normalized_fields[$field_key] ) ) {
1326 + $normalized_fields[$field_key] = isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [];
1327 + } else {
1328 + array_push( $normalized_fields[$field_key], ...( isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [] ) );
1329 + $normalized_fields[$field_key] = array_unique( $normalized_fields[$field_key] );
1330 + }
1331 + }
1332 + }
83 1333
84 - public static function is_active( $prev, $current, $class = 'active' ) {
85 - if ( $current == $prev ) {
86 - return $class;
1334 + return $normalized_fields;
1335 + }
1336 +
1337 + public static function get_local_plugin_data( $basename = '' ) {
1338 + if ( empty( $basename ) ) {
1339 + return false;
87 1340 }
88 1341
89 - return '';
90 - }
1342 + if ( !function_exists( 'get_plugins' ) ) {
1343 + include_once ABSPATH . 'wp-admin/includes/plugin.php';
1344 + }
91 1345
92 - public function get_users( $args ) {
93 - $cache_key = 'betterdocs_cache_admin_user_roles';
94 - $users = betterdocs()->database->get_cache( $cache_key );
1346 + $plugins = get_plugins();
95 1347
96 - if ( false === $users ) {
97 - $users = get_users( $args );
98 - betterdocs()->database->set_cache( $cache_key, $users );
1348 + if ( !isset( $plugins[ $basename ] ) ) {
1349 + return false;
99 1350 }
100 1351
101 - return $users;
1352 + return $plugins[ $basename ];
102 1353 }
103 1354
104 1355 /**
105 - * Normalize Menu Array
106 - * Menu creator helper
1356 + * Get default file icon based on programming language
107 1357 *
108 - * @since 2.5.0
109 - *
110 - * @param string $title
111 - * @param string $slug
112 - * @param string $cap
113 - * @param array $callback
114 - *
115 - * @return array
1358 + * @param string $language Programming language identifier
1359 + * @return string Emoji icon for the language
116 1360 */
117 - public static function normalize_menu( $title, $slug, $cap = 'edit_docs', $callback = null ) {
118 - $args = [
119 - 'page_title' => $title,
120 - 'menu_title' => $title,
121 - 'capability' => $cap,
122 - 'menu_slug' => $slug
1361 + public static function get_file_icon_by_language( $language ) {
1362 + $icons = [
1363 + 'javascript' => '📄',
1364 + 'typescript' => '📘',
1365 + 'jsx' => '⚛️',
1366 + 'tsx' => '⚛️',
1367 + 'html' => '🌐',
1368 + 'css' => '🎨',
1369 + 'scss' => '🎨',
1370 + 'sass' => '🎨',
1371 + 'less' => '🎨',
1372 + 'php' => '🐘',
1373 + 'python' => '🐍',
1374 + 'java' => '☕',
1375 + 'csharp' => '🔷',
1376 + 'cpp' => '⚙️',
1377 + 'c' => '⚙️',
1378 + 'ruby' => '💎',
1379 + 'go' => '🐹',
1380 + 'rust' => '🦀',
1381 + 'swift' => '🦉',
1382 + 'kotlin' => '🎯',
1383 + 'sql' => '🗃️',
1384 + 'json' => '📋',
1385 + 'yaml' => '📋',
1386 + 'xml' => '📄',
1387 + 'markdown' => '📝',
1388 + 'curl' => '💻',
1389 + 'bash' => '💻',
1390 + 'shell' => '💻',
1391 + 'powershell' => '💻',
1392 + 'dockerfile' => '🐳',
123 1393 ];
124 1394
125 - if ( $callback != null ) {
126 - $args['callback'] = $callback;
127 - }
1395 + return isset( $icons[$language] ) ? $icons[$language] : '📄';
1396 + }
128 1397
129 - return $args;
1398 + /**
1399 + * Echo the copy-to-clipboard button used by the Code Snippet and Code
1400 + * Snippet Tab templates.
1401 + *
1402 + * Both icons ship in the markup and CSS cross-fades between them on
1403 + * `.is-copied`, so the frontend script never rewrites the SVG. The tooltip
1404 + * carries its own strings as data attributes so the script can swap
1405 + * "Copy" → "Copied!" without hard-coding English.
1406 + *
1407 + * @return void
1408 + */
1409 + public static function code_snippet_copy_button() {
1410 + ?>
1411 + <div class="betterdocs-code-snippet-copy-container">
1412 + <button class="betterdocs-code-snippet-copy-button"
1413 + type="button"
1414 + aria-label="<?php esc_attr_e( 'Copy code to clipboard', 'betterdocs' ); ?>">
1415 + <span class="betterdocs-code-snippet-copy-icon" aria-hidden="true">
1416 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1417 + <rect x="9" y="9" width="12.5" height="12.5" rx="3" stroke="currentColor" stroke-width="1.7"/>
1418 + <path d="M15.5 5.75V5A2.5 2.5 0 0 0 13 2.5H5A2.5 2.5 0 0 0 2.5 5v8A2.5 2.5 0 0 0 5 15.5h.75" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
1419 + </svg>
1420 + </span>
1421 + <span class="betterdocs-code-snippet-copied-icon" aria-hidden="true">
1422 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1423 + <path d="M20 6.5 9.5 17 4 11.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
1424 + </svg>
1425 + </span>
1426 + </button>
1427 + <span class="betterdocs-code-snippet-tooltip"
1428 + role="status"
1429 + data-copy-label="<?php esc_attr_e( 'Copy', 'betterdocs' ); ?>"
1430 + data-copied-label="<?php esc_attr_e( 'Copied!', 'betterdocs' ); ?>"
1431 + data-error-label="<?php esc_attr_e( 'Copy failed', 'betterdocs' ); ?>"><?php esc_html_e( 'Copy', 'betterdocs' ); ?></span>
1432 + </div>
1433 + <?php
130 1434 }
131 1435
132 1436 /**
133 - * Check if the current theme is a block theme.
1437 + * Human-readable label for a programming-language identifier, used as the
1438 + * language-dropdown label on multi-language code snippets. Mirrors the
1439 + * block's LANGUAGE_OPTIONS; falls back to an upper-cased identifier.
134 1440 *
135 - * @since x.x.x
136 - * @return bool
1441 + * @param string $language Programming language identifier
1442 + * @return string
137 1443 */
138 - public function current_theme_is_fse_theme() {
139 - if ( function_exists( 'wp_is_block_theme' ) ) {
140 - return (bool) wp_is_block_theme();
1444 + public static function get_language_label( $language ) {
1445 + $labels = [
1446 + 'javascript' => 'JavaScript',
1447 + 'typescript' => 'TypeScript',
1448 + 'php' => 'PHP',
1449 + 'python' => 'Python',
1450 + 'java' => 'Java',
1451 + 'ruby' => 'Ruby',
1452 + 'curl' => 'cURL',
1453 + 'bash' => 'Bash',
1454 + 'shell' => 'Shell',
1455 + 'json' => 'JSON',
1456 + 'yaml' => 'YAML',
1457 + 'html' => 'HTML',
1458 + 'css' => 'CSS',
1459 + 'scss' => 'SCSS',
1460 + 'sql' => 'SQL',
1461 + 'xml' => 'XML',
1462 + 'cpp' => 'C++',
1463 + 'csharp' => 'C#',
1464 + 'c' => 'C',
1465 + 'go' => 'Go',
1466 + 'rust' => 'Rust',
1467 + 'swift' => 'Swift',
1468 + 'kotlin' => 'Kotlin',
1469 + 'markdown' => 'Markdown'
1470 + ];
1471 +
1472 + if ( isset( $labels[ $language ] ) ) {
1473 + return $labels[ $language ];
141 1474 }
142 - if ( function_exists( 'gutenberg_is_fse_theme' ) ) {
143 - return (bool) gutenberg_is_fse_theme();
1475 +
1476 + return ucwords( str_replace( [ '-', '_' ], ' ', (string) $language ) );
1477 + }
1478 +
1479 + /**
1480 + * Check if AI Chatbot is enabled
1481 + *
1482 + * @return bool
1483 + */
1484 + public function is_ai_chatbot_enabled() {
1485 + $chatbot_active = is_plugin_active( 'betterdocs-ai-chatbot/betterdocs-ai-chatbot.php' );
1486 + $chatbot_license_valid = get_option( 'betterdocs_chatbot_software__license_status' ) === 'valid';
1487 + $chatbot_enabled = betterdocs()->settings->get( 'enable_ai_chatbot', false );
1488 +
1489 + // AI Search Suggestions are enabled if all conditions are met
1490 + return $chatbot_active && $chatbot_license_valid && $chatbot_enabled;
1491 + }
1492 +
1493 + /**
1494 + * Check if tags are enabled and post has tags
1495 + *
1496 + * @return bool
1497 + */
1498 + public function is_tag_enabled() {
1499 + global $post;
1500 + $product_terms = wp_get_object_terms( $post->ID, 'doc_tag' );
1501 + $enable_tags = betterdocs()->settings->get( 'enable_tags', false );
1502 + return ! empty( $product_terms ) && $enable_tags;
1503 + }
1504 +
1505 + /**
1506 + * Check if AI Search Suggestions are enabled
1507 + *
1508 + * @return bool
1509 + */
1510 + public function is_ai_search_suggestions_enabled() {
1511 + $ai_search_suggestions_active = is_plugin_active( 'betterdocs-ai-search-suggestions/betterdocs-ai-search-suggestions.php' );
1512 + $ai_search_suggestions_license_valid = get_option( 'betterdocs_ai_search_suggestions_software__license_status' ) === 'valid';
1513 + $ai_search_suggestions_enabled = betterdocs()->settings->get( 'enable_ai_powered_search', false );
1514 +
1515 + return $ai_search_suggestions_active && $ai_search_suggestions_license_valid && $ai_search_suggestions_enabled;
1516 + }
1517 +
1518 + /**
1519 + * Get the maximum order value from the 'doc_category_order' term meta
1520 + *
1521 + * @return int
1522 + */
1523 + public static function get_max_doc_category_order_from_term_meta() {
1524 + global $wpdb;
1525 + $sql = $wpdb->prepare( "SELECT MAX(CAST(meta_value AS UNSIGNED)) AS max FROM {$wpdb->termmeta} WHERE meta_key = %s ", 'doc_category_order' );
1526 + $result = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above.
1527 + return $result;
1528 + }
1529 +
1530 + /**
1531 + * Encyclopedia / glossary query helpers — backward-compatibility shims.
1532 + *
1533 + * These methods were moved out of Free and into BetterDocs Pro's own
1534 + * `EncyclopediaQuery` class as of Pro 4.3.1 (the "move glossaries to Pro"
1535 + * release). BetterDocs Pro 4.3.0 and earlier, however, still call
1536 + * `Helper::get_character_range()` / `get_current_letter_docs()` /
1537 + * `docs_sort_by_letter()` from their encyclopedia blocks, widgets,
1538 + * shortcodes, templates and AJAX callbacks. When a site runs new Free with
1539 + * an older Pro (< 4.3.1) still active, those calls would fatal with
1540 + * "Call to undefined method". Keeping these shims here lets that older Pro
1541 + * keep rendering until it is updated. Pro 4.3.1+ uses its own copy and never
1542 + * touches these, so there is no double-execution or conflict.
1543 + *
1544 + * @deprecated Retained only for BetterDocs Pro < 4.3.1 compatibility.
1545 + */
1546 + public static function get_current_letter_docs( $current_letter, $limit = 0 ) {
1547 + global $wpdb;
1548 +
1549 + $limit = absint( $limit );
1550 + $limit_sql = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
1551 +
1552 + // Check if the encyclopedia_prefix parameter is set
1553 +
1554 + $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1555 + $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1556 + $encyclopedia_root_slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopdia' );
1557 + // Sanitize values that may be interpolated into raw SQL fragments below.
1558 + $encyclopedia_root_slug = sanitize_title( $encyclopedia_root_slug );
1559 +
1560 + // if($enable_glossaries && $encyclopeia_suorce === 'glossaries'){
1561 + if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1562 + $lang_join = '';
1563 + $lang_where = '';
1564 +
1565 + // Add language filtering if multilingual plugin is active and we should apply filtering
1566 + $current_language = self::get_current_language();
1567 + if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1568 + // Restrict language code to a safe character set before SQL interpolation.
1569 + $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1570 + // For WPML, use icl_translations table
1571 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1572 + $lang_join = " LEFT JOIN {$wpdb->prefix}icl_translations icl_t ON icl_t.element_id = t.term_id AND icl_t.element_type = 'tax_glossaries'";
1573 + $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1574 + }
1575 + // For Polylang, use term_relationships with language taxonomy
1576 + elseif ( function_exists( 'pll_current_language' ) ) {
1577 + $lang_join = " LEFT JOIN {$wpdb->term_relationships} tr ON t.term_id = tr.object_id LEFT JOIN {$wpdb->term_taxonomy} tt_lang ON tr.term_taxonomy_id = tt_lang.term_taxonomy_id AND tt_lang.taxonomy = 'language' LEFT JOIN {$wpdb->terms} t_lang ON tt_lang.term_id = t_lang.term_id";
1578 + $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1579 + }
1580 + }
1581 +
1582 + $query = "
1583 + SELECT
1584 + t.term_id,
1585 + t.name AS post_title,
1586 + t.slug as slug,
1587 + '' AS post_excerpt,
1588 + CONCAT('" . get_home_url() . "/$encyclopedia_root_slug/', t.slug) AS permalink,
1589 + tt.description AS post_content,
1590 + JSON_OBJECT(
1591 + 'status', COALESCE(MAX(CASE WHEN m.meta_key = 'status' THEN m.meta_value END), ''),
1592 + 'glossary_term_description', COALESCE(MAX(CASE WHEN m.meta_key = 'glossary_term_description' THEN m.meta_value END), '')
1593 + ) AS meta_data
1594 + FROM
1595 + {$wpdb->terms} t
1596 + INNER JOIN
1597 + {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1598 + LEFT JOIN
1599 + {$wpdb->termmeta} m ON t.term_id = m.term_id
1600 + $lang_join
1601 + WHERE
1602 + tt.taxonomy = 'glossaries'
1603 + AND
1604 + SUBSTRING(t.name, 1, 1) = %s
1605 + $lang_where
1606 + GROUP BY
1607 + t.term_id
1608 + ORDER BY
1609 + t.name ASC
1610 + $limit_sql
1611 + ";
1612 + } else {
1613 + $lang_join = '';
1614 + $lang_where = '';
1615 +
1616 + // Add language filtering for docs if multilingual plugin is active and we should apply filtering
1617 + $current_language = self::get_current_language();
1618 + if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1619 + // Restrict language code to a safe character set before SQL interpolation.
1620 + $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1621 + // For WPML, use icl_translations table
1622 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1623 + $lang_join = " LEFT JOIN {$wpdb->prefix}icl_translations icl_t ON icl_t.element_id = {$wpdb->posts}.ID AND icl_t.element_type = 'post_docs'";
1624 + $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1625 + }
1626 + // For Polylang, use term_relationships with language taxonomy
1627 + elseif ( function_exists( 'pll_current_language' ) ) {
1628 + $lang_join = " LEFT JOIN {$wpdb->term_relationships} tr ON {$wpdb->posts}.ID = tr.object_id LEFT JOIN {$wpdb->term_taxonomy} tt_lang ON tr.term_taxonomy_id = tt_lang.term_taxonomy_id AND tt_lang.taxonomy = 'language' LEFT JOIN {$wpdb->terms} t_lang ON tt_lang.term_id = t_lang.term_id";
1629 + $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1630 + }
1631 + }
1632 +
1633 + $query = "
1634 + SELECT ID, post_title, post_excerpt, guid, post_content
1635 + FROM {$wpdb->posts}
1636 + $lang_join
1637 + WHERE post_type = 'docs'
1638 + AND post_status = 'publish'
1639 + AND SUBSTRING(post_title, 1, 1) = %s
1640 + $lang_where
1641 + ORDER BY post_date DESC
1642 + $limit_sql
1643 + ";
1644 + }
1645 +
1646 + $current_letter_docs = $wpdb->get_results( $wpdb->prepare( $query, $current_letter ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1647 +
1648 + return $current_letter_docs;
1649 + }
1650 +
1651 + public static function docs_sort_by_letter( $limit = 10 ) {
1652 + global $wpdb;
1653 + $enable_non_latin = betterdocs()->settings->get( 'encyclopedia_enable_non_latin' );
1654 + $script = betterdocs()->settings->get( 'encyclopedia_non_latin_option' );
1655 + $letters = Helper::get_character_range( $enable_non_latin, $script );
1656 +
1657 + $docs_by_letter = [];
1658 + $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1659 + $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1660 +
1661 + foreach ( $letters as $letter ) {
1662 + $posts = self::get_current_letter_docs( $letter, $limit );
1663 +
1664 + if ( is_array( $posts ) && ! empty( $posts ) ) {
1665 + foreach ( $posts as $post ) {
1666 + $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : '';
1667 + $glossary_term_description = $description['glossary_term_description'] ?? '';
1668 +
1669 + // Remove any <p> tags or other unwanted HTML tags
1670 + $glossary_term_description = wp_strip_all_tags( $glossary_term_description );
1671 + $post_excerpt = wp_strip_all_tags( $post['post_excerpt'] ?? '' );
1672 +
1673 + // Prepare post data
1674 + if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1675 + // For glossaries
1676 + $permalink = '';
1677 +
1678 + if ( isset( $post['slug'] ) ) {
1679 + $term_link = get_term_link( $post['slug'], 'glossaries' );
1680 +
1681 + if ( ! is_wp_error( $term_link ) ) {
1682 + $permalink = $term_link;
1683 + }
1684 + }
1685 +
1686 + $post_data = [
1687 + 'id' => $post['term_id'] ?? '',
1688 + 'post_title' => $post['post_title'] ?? '',
1689 + 'post_excerpt' => ! empty( $post_excerpt )
1690 + ? $post_excerpt
1691 + : ( ! empty( $glossary_term_description )
1692 + ? self::get_custom_excerpt( $glossary_term_description, 15 )
1693 + : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ) ),
1694 + 'permalink' => $permalink,
1695 + ];
1696 + } else {
1697 + // For docs
1698 + $post_data = [
1699 + 'id' => $post['ID'] ?? '',
1700 + 'post_title' => $post['post_title'] ?? '',
1701 + 'post_excerpt' => ! empty( $post_excerpt )
1702 + ? $post_excerpt
1703 + : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ),
1704 + 'permalink' => isset( $post['ID'] ) ? get_the_permalink( $post['ID'] ) : ''
1705 + ];
1706 + }
1707 +
1708 + $docs_by_letter[$letter][] = $post_data;
1709 + }
1710 + }
144 1711 }
145 1712
146 - return false;
1713 + return $docs_by_letter;
147 1714 }
1715 + public static function mb_ord_fallback( $char ) {
1716 + $code = unpack( 'N', mb_convert_encoding( $char, 'UCS-4BE', 'UTF-8' ) );
1717 + return $code[1];
1718 + }
148 1719
149 - protected static function is_assoc_array( $array ) {
150 - return array_keys( $array ) !== range( 0, count( $array ) - 1 );
1720 + public static function mb_chr_fallback( $code ) {
1721 + return mb_convert_encoding( pack( 'N', $code ), 'UTF-8', 'UCS-4BE' );
151 1722 }
1723 + public static function unicodeRange( $start, $end ) {
1724 + $range = [];
1725 + for ( $i = self::mb_ord_fallback( $start ); $i <= self::mb_ord_fallback( $end ); $i++ ) {
1726 + $range[] = self::mb_chr_fallback( $i );
1727 + }
1728 + return $range;
1729 + }
152 1730
153 - public static function merge( &$array1, &$array2 ) {
154 - $merged = $array1;
155 -
156 - foreach ( $array2 as $key => &$value ) {
157 - if ( is_array( $value ) && self::is_assoc_array( $value ) && isset( $merged[$key] ) && is_array( $merged[$key] ) ) {
158 - $merged[$key] = self::merge( $merged[$key], $value );
159 - } elseif ( is_array( $value ) && isset( $merged[$key] ) && is_array( $merged[$key] ) ) {
160 - $merged[$key] = array_merge( $merged[$key], $value );
161 - } else {
162 - $merged[$key] = $value;
1731 + public static function get_character_range( $enable_non_latin, $script ) {
1732 + if ( $enable_non_latin ) {
1733 + switch ( $script ) {
1734 + case 'arabic':
1735 + return self::unicodeRange( 'ء', 'ي' );
1736 + case 'cyrillic':
1737 + return self::unicodeRange( 'А', 'Я' );
1738 + case 'hebrew':
1739 + return self::unicodeRange( 'א', 'ת' );
1740 + case 'greek':
1741 + return self::unicodeRange( 'Α', 'Ω' );
1742 + default:
1743 + return range( 'A', 'Z' );
163 1744 }
164 1745 }
165 1746
166 - return $merged;
1747 + return range( 'A', 'Z' );
167 1748 }
168 1749 }