PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.7.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.7.0
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 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Utils / Helper.php

Helper.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.7.0, at includes/Utils/Helper.php

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