PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.6.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.6.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 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.6.2, at includes/Utils/Helper.php

1,465 lines 52.6 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 * Check if we should apply language filtering
306 * Only apply on frontend or when specifically requested
307 *
308 * @return bool
309 */
310 public static function should_apply_language_filtering() {
311 // Don't apply language filtering in admin context unless it's a frontend request
312 if ( is_admin() ) {
313 // Allow language filtering for REST API requests that are frontend-facing
314 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
315 // Check if this is a frontend REST request (not admin)
316 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
317 // Don't filter admin REST requests for glossaries management
318 if ( strpos( $request_uri, '/wp/v2/glossaries' ) !== false ) {
319 return false; // Don't filter admin glossaries management
320 }
321 }
322 return false; // Don't filter other admin requests
323 }
324
325 // Apply filtering on frontend
326 return true;
327 }
328
329 /**
330 * Get current admin language for multilingual sites
331 * This is specifically for admin context where we need to detect
332 * the language being used for editing terms/posts
333 *
334 * @return string|null Current admin language code
335 */
336 public static function get_current_admin_language() {
337 $current_language = null;
338
339 // Explicit language passed by the admin client takes priority.
340 // Covers AJAX (POST) and REST/admin requests (GET) where WPML may
341 // otherwise resolve to the site's default language instead of the
342 // admin UI language.
343 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only UI language hint, sanitized; not a state-changing form submission.
344 if ( isset( $_POST['lang'] ) && ! empty( $_POST['lang'] ) ) {
345 return self::sanitize_language_code( wp_unslash( $_POST['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see note above.
346 }
347
348 // Limit GET handling to admin/REST contexts so a frontend ?lang= switch
349 // doesn't hijack admin meta-key resolution.
350 if ( isset( $_GET['lang'] ) && ! empty( $_GET['lang'] )
351 && ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) ) {
352 return self::sanitize_language_code( wp_unslash( $_GET['lang'] ) );
353 }
354
355 // WPML Support - Admin language detection
356 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
357 global $sitepress;
358 if ( $sitepress && $sitepress->is_setup_complete() ) {
359 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
360 $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
361 // For term editing, check if we have a specific term language
362 if ( $tag_id && function_exists( 'wpml_get_language_information' ) ) {
363 $term_info = wpml_get_language_information( null, $tag_id );
364 if ( ! is_wp_error( $term_info ) && $term_info && isset( $term_info['language_code'] ) ) {
365 $current_language = $term_info['language_code'];
366 }
367
368 }
369
370 // Check for language parameter in URL
371 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
372 if ( ! $current_language && isset( $_GET['lang'] ) ) {
373 $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
374 }
375
376 // Check WPML admin language cookie (persists during AJAX)
377 if ( ! $current_language && isset( $_COOKIE['_icl_current_admin_language'] ) ) {
378 $current_language = sanitize_text_field( wp_unslash( $_COOKIE['_icl_current_admin_language'] ) );
379 }
380
381 // Fallback to admin language or current language
382 if ( ! $current_language ) {
383 $current_language = defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : $sitepress->get_current_language();
384 }
385 }
386 }
387 // Polylang Support - Admin language detection
388 elseif ( function_exists( 'pll_current_language' ) ) {
389 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
390 $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
391 // For term editing, get language from term ID
392 if ( $tag_id && function_exists( 'pll_get_term_language' ) ) {
393 $term_lang = pll_get_term_language( $tag_id );
394 if ( $term_lang ) {
395 $current_language = $term_lang;
396 }
397 }
398
399 // Check for language parameter in URL
400 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
401 if ( ! $current_language && isset( $_GET['lang'] ) ) {
402 $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
403 }
404
405 // Fallback to current admin language
406 if ( ! $current_language ) {
407 $current_language = pll_current_language( 'slug' );
408 }
409 }
410 // Other multilingual plugins
411 elseif ( function_exists( 'qtranxf_getLanguage' ) ) {
412 $current_language = qtranxf_getLanguage();
413 }
414 elseif ( function_exists( 'weglot_get_current_language' ) ) {
415 $current_language = weglot_get_current_language();
416 }
417 elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
418 $current_language = trp_get_current_language();
419 }
420
421 return self::sanitize_language_code( $current_language );
422 }
423
424 /**
425 * Normalize a language code to the character set real language codes use
426 * (`en`, `en_US`, `zh-Hans`). Values reach this from `?lang=`, `$_POST['lang']`
427 * and the WPML admin cookie, and `sanitize_text_field()` leaves quotes intact —
428 * so anything used to build a meta key or SQL fragment must be narrowed here.
429 * Defense in depth: callers that reach SQL must still bind their values.
430 *
431 * @param string|null $language Raw language code.
432 * @return string|null Normalized code, or null when nothing usable remains.
433 */
434 private static function sanitize_language_code( $language ) {
435 if ( ! is_string( $language ) || '' === $language ) {
436 return null;
437 }
438
439 $language = preg_replace( '/[^A-Za-z0-9_-]/', '', $language );
440
441 return '' !== $language ? $language : null;
442 }
443
444 /**
445 * Generate language-specific meta key for category ordering
446 * Always falls back to base key if language-specific key doesn't exist
447 *
448 * @param string $base_key The base meta key (e.g., 'doc_category_order')
449 * @param string|null $language Language code, if null will auto-detect
450 * @return string Language-specific meta key or base key as fallback
451 */
452 public static function get_language_specific_meta_key( $base_key, $language = null ) {
453 // If no multilingual plugin is active, return the base key
454 if ( ! self::is_multilingual_active() ) {
455 return $base_key;
456 }
457
458 // Get current admin language if not provided
459 if ( $language === null ) {
460 $language = self::get_current_admin_language();
461 }
462
463 // If no language detected, return base key for backward compatibility
464 if ( ! $language ) {
465 return $base_key;
466 }
467
468 // Always return base key for now - we'll handle fallback in the query functions
469 // This ensures compatibility without requiring migration
470 return $base_key;
471 }
472
473 /**
474 * Get the meta key to write to.
475 *
476 * Unlike `get_meta_key_with_fallback`, this never falls back to the base
477 * key when the language-specific key is empty — that fallback is what
478 * caused secondary-language drag-and-drop saves to clobber the base meta
479 * (and on WPML setups that copy term meta from the original language,
480 * the next read would re-overwrite it from the primary language).
481 *
482 * @param string $base_key The base meta key.
483 * @param string|null $language Language code, auto-detected when null.
484 * @return string Language-specific key when multilingual + language known, else base.
485 */
486 public static function get_meta_key_for_save( $base_key, $language = null ) {
487 if ( ! self::is_multilingual_active() ) {
488 return $base_key;
489 }
490
491 if ( $language === null ) {
492 $language = self::get_current_admin_language();
493 }
494
495 if ( ! $language ) {
496 return $base_key;
497 }
498
499 return $base_key . '_' . $language;
500 }
501
502 /**
503 * Get the appropriate meta key with fallback logic
504 * This function checks if language-specific meta exists, if not falls back to base key
505 *
506 * @param string $base_key The base meta key
507 * @param int $term_id The term ID to check
508 * @param string|null $language Language code
509 * @return string The meta key to use
510 */
511 public static function get_meta_key_with_fallback( $base_key, $term_id = null, $language = null ) {
512 // If no multilingual plugin is active, return the base key
513 if ( ! self::is_multilingual_active() ) {
514 return $base_key;
515 }
516
517 // Get current admin language if not provided
518 if ( $language === null ) {
519 $language = self::get_current_admin_language();
520 }
521
522 // If no language detected, return base key
523 if ( ! $language ) {
524 return $base_key;
525 }
526
527 $lang_meta_key = $base_key . '_' . $language;
528
529 // If we have a specific term ID, check if language-specific meta exists
530 if ( $term_id ) {
531 $lang_value = get_term_meta( $term_id, $lang_meta_key, true );
532 if ( ! empty( $lang_value ) ) {
533 return $lang_meta_key;
534 }
535 // Fall back to base key if language-specific doesn't exist
536 return $base_key;
537 }
538
539 // For queries without specific term ID, we need to check if ANY terms have language-specific meta
540 global $wpdb;
541 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live multilingual meta-key resolution; result varies per active language.
542 $has_lang_meta = $wpdb->get_var( $wpdb->prepare(
543 "SELECT COUNT(*) FROM {$wpdb->termmeta} tm
544 INNER JOIN {$wpdb->term_taxonomy} tt ON tm.term_id = tt.term_id
545 WHERE tm.meta_key = %s AND tt.taxonomy = 'doc_category' AND tm.meta_value != ''",
546 $lang_meta_key
547 ) );
548
549 // If language-specific meta exists for some terms, use it (terms without it will have empty values)
550 // Otherwise, fall back to base key
551 return $has_lang_meta > 0 ? $lang_meta_key : $base_key;
552 }
553
554 /**
555 * Migrate existing category orders to language-specific meta keys
556 * This should be called when a multilingual plugin is activated
557 *
558 * @param string $base_key The base meta key (e.g., 'doc_category_order')
559 * @param string $taxonomy The taxonomy to migrate
560 * @return bool Success status
561 */
562 public static function migrate_category_orders_to_multilingual( $base_key = 'doc_category_order', $taxonomy = 'doc_category' ) {
563 // Only run if multilingual plugin is active
564 if ( ! self::is_multilingual_active() ) {
565 return false;
566 }
567
568 global $wpdb;
569
570 // Get all terms with the base meta key
571 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
572 $terms_with_order = $wpdb->get_results( $wpdb->prepare(
573 "SELECT tm.term_id, tm.meta_value, t.slug
574 FROM {$wpdb->termmeta} tm
575 INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
576 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
577 WHERE tm.meta_key = %s AND tt.taxonomy = %s",
578 $base_key,
579 $taxonomy
580 ) );
581
582 if ( empty( $terms_with_order ) ) {
583 return true; // Nothing to migrate
584 }
585
586 // Get available languages
587 $languages = self::get_available_languages();
588
589 if ( empty( $languages ) ) {
590 return false; // No languages found
591 }
592
593 // Migrate orders for each language
594 foreach ( $languages as $language ) {
595 $language_meta_key = $base_key . '_' . $language;
596
597 foreach ( $terms_with_order as $term_data ) {
598 // Check if language-specific meta already exists
599 $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
600
601 if ( empty( $existing_value ) ) {
602 // Copy the base order to language-specific key
603 update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
604 }
605 }
606 }
607
608 return true;
609 }
610
611 /**
612 * Migrate existing document orders to language-specific meta keys
613 * This should be called when a multilingual plugin is activated
614 *
615 * @param string $base_key The base meta key (e.g., '_docs_order')
616 * @param string $taxonomy The taxonomy to migrate
617 * @return bool Success status
618 */
619 public static function migrate_docs_orders_to_multilingual( $base_key = '_docs_order', $taxonomy = 'doc_category' ) {
620 // Only run if multilingual plugin is active
621 if ( ! self::is_multilingual_active() ) {
622 return false;
623 }
624
625 global $wpdb;
626
627 // Get all terms with the base meta key for document ordering
628 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
629 $terms_with_docs_order = $wpdb->get_results( $wpdb->prepare(
630 "SELECT tm.term_id, tm.meta_value, t.slug
631 FROM {$wpdb->termmeta} tm
632 INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
633 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
634 WHERE tm.meta_key = %s AND tt.taxonomy = %s AND tm.meta_value != ''",
635 $base_key,
636 $taxonomy
637 ) );
638
639 if ( empty( $terms_with_docs_order ) ) {
640 return true; // Nothing to migrate
641 }
642
643 // Get available languages
644 $languages = self::get_available_languages();
645
646 if ( empty( $languages ) ) {
647 return false; // No languages found
648 }
649
650 // Migrate document orders for each language
651 foreach ( $languages as $language ) {
652 $language_meta_key = $base_key . '_' . $language;
653
654 foreach ( $terms_with_docs_order as $term_data ) {
655 // Check if language-specific meta already exists
656 $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
657
658 if ( empty( $existing_value ) ) {
659 // Copy the base document order to language-specific key
660 update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
661 }
662 }
663 }
664
665 return true;
666 }
667
668 /**
669 * Migrate both category and document orders to multilingual format
670 * This is a convenience method that runs both migrations
671 *
672 * @return bool Success status
673 */
674 public static function migrate_all_orders_to_multilingual() {
675 $category_result = self::migrate_category_orders_to_multilingual();
676 $docs_result = self::migrate_docs_orders_to_multilingual();
677
678 return $category_result && $docs_result;
679 }
680
681 /**
682 * Get available languages from multilingual plugins
683 *
684 * @return array Array of language codes
685 */
686 public static function get_available_languages() {
687 $languages = [];
688
689 // WPML Support
690 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
691 global $sitepress;
692 if ( $sitepress && $sitepress->is_setup_complete() ) {
693 $active_languages = $sitepress->get_active_languages();
694 if ( is_array( $active_languages ) ) {
695 $languages = array_keys( $active_languages );
696 }
697 }
698 }
699 // Polylang Support
700 elseif ( function_exists( 'pll_languages_list' ) ) {
701 $languages = pll_languages_list();
702 }
703
704 return $languages;
705 }
706
707 /**
708 * Rich list of active site languages for the React admin language bar.
709 *
710 * @return array<int,array{code:string,label:string,native:string,flag:string}>
711 * Empty when no supported multilingual plugin is active.
712 */
713 public static function get_admin_languages() {
714 $languages = [];
715
716 // WPML
717 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
718 global $sitepress;
719 if ( $sitepress && $sitepress->is_setup_complete() ) {
720 $active = $sitepress->get_active_languages();
721 if ( is_array( $active ) ) {
722 foreach ( $active as $code => $lang ) {
723 $languages[] = [
724 'code' => $code,
725 'label' => isset( $lang['english_name'] ) ? $lang['english_name'] : $code,
726 'native' => isset( $lang['native_name'] ) ? $lang['native_name'] : ( isset( $lang['display_name'] ) ? $lang['display_name'] : $code ),
727 'flag' => isset( $lang['country_flag_url'] ) ? $lang['country_flag_url'] : '',
728 ];
729 }
730 }
731 }
732 }
733 // Polylang
734 elseif ( function_exists( 'pll_languages_list' ) ) {
735 $list = pll_languages_list( [ 'fields' => '' ] ); // full PLL_Language objects
736 if ( is_array( $list ) ) {
737 foreach ( $list as $lang ) {
738 if ( ! is_object( $lang ) ) {
739 continue;
740 }
741 $languages[] = [
742 'code' => isset( $lang->slug ) ? $lang->slug : '',
743 'label' => isset( $lang->name ) ? $lang->name : ( isset( $lang->slug ) ? $lang->slug : '' ),
744 'native' => isset( $lang->name ) ? $lang->name : '',
745 'flag' => isset( $lang->flag_url ) ? $lang->flag_url : '',
746 ];
747 }
748 }
749 }
750
751 return $languages;
752 }
753
754 /**
755 * Read a term's language code via the active multilingual plugin.
756 *
757 * @param \WP_Term $term
758 * @return string Language code, or '' when unavailable.
759 */
760 public static function get_term_language( $term ) {
761 if ( ! is_object( $term ) || empty( $term->term_id ) ) {
762 return '';
763 }
764
765 // Polylang — takes the term_id.
766 if ( function_exists( 'pll_get_term_language' ) ) {
767 $lang = pll_get_term_language( $term->term_id, 'slug' );
768 return $lang ? $lang : '';
769 }
770
771 // WPML — element_id is the term_taxonomy_id (NOT the term_id); WPML
772 // normalizes the element_type to `tax_<taxonomy>` internally.
773 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
774 $lang = apply_filters( 'wpml_element_language_code', null, [
775 'element_id' => $term->term_taxonomy_id,
776 'element_type' => isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category',
777 ] );
778 return $lang ? $lang : '';
779 }
780
781 return '';
782 }
783
784 /**
785 * Stamp a term's language via the active multilingual plugin. Standalone
786 * assignment only — it sets/re-stamps the term's own language and does not
787 * link it into an existing translation group.
788 *
789 * @param \WP_Term $term
790 * @param string $lang_code
791 */
792 public static function set_term_language( $term, $lang_code ) {
793 $lang_code = sanitize_text_field( (string) $lang_code );
794 if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
795 return;
796 }
797
798 // Polylang
799 if ( function_exists( 'pll_set_term_language' ) ) {
800 pll_set_term_language( $term->term_id, $lang_code );
801 return;
802 }
803
804 // WPML — element_id is the term_taxonomy_id; element_type is tax_<taxonomy>;
805 // trid=null sets it as a standalone original in the chosen language.
806 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
807 $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
808 do_action( 'wpml_set_element_language_details', [
809 'element_id' => $term->term_taxonomy_id,
810 'element_type' => 'tax_' . $taxonomy,
811 'trid' => null,
812 'language_code' => $lang_code,
813 'source_language_code' => null,
814 ] );
815 }
816 }
817
818 /**
819 * The site's default language code, or '' when no multilingual plugin is active.
820 */
821 public static function get_default_language() {
822 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
823 global $sitepress;
824 if ( $sitepress ) {
825 return (string) $sitepress->get_default_language();
826 }
827 }
828 if ( function_exists( 'pll_default_language' ) ) {
829 return (string) pll_default_language( 'slug' );
830 }
831 return '';
832 }
833
834 /**
835 * All terms in a term's translation group, keyed by language code.
836 *
837 * @param \WP_Term $term
838 * @return array<string,array{term_id:int,name:string}>
839 */
840 public static function get_term_translations( $term ) {
841 if ( ! is_object( $term ) || empty( $term->term_id ) ) {
842 return [];
843 }
844 $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
845 $out = [];
846
847 // Polylang
848 if ( function_exists( 'pll_get_term_translations' ) ) {
849 $group = pll_get_term_translations( $term->term_id ); // [lang => term_id]
850 if ( is_array( $group ) ) {
851 foreach ( $group as $lang => $tid ) {
852 $t = get_term( (int) $tid, $taxonomy );
853 if ( $t && ! is_wp_error( $t ) ) {
854 $out[ $lang ] = [ 'term_id' => (int) $tid, 'name' => $t->name ];
855 }
856 }
857 }
858 return $out;
859 }
860
861 // WPML
862 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
863 $el_type = 'tax_' . $taxonomy;
864 $trid = apply_filters( 'wpml_element_trid', null, $term->term_taxonomy_id, $el_type );
865 if ( ! $trid ) {
866 return $out;
867 }
868 $translations = apply_filters( 'wpml_get_element_translations', null, $trid, $el_type );
869 if ( is_array( $translations ) ) {
870 foreach ( $translations as $lang => $tr ) {
871 $tid = isset( $tr->term_id ) ? (int) $tr->term_id : 0;
872 if ( ! $tid ) {
873 continue;
874 }
875 $t = get_term( $tid, $taxonomy );
876 $out[ $lang ] = [
877 'term_id' => $tid,
878 'name' => ( $t && ! is_wp_error( $t ) ) ? $t->name : ( isset( $tr->name ) ? $tr->name : '' ),
879 ];
880 }
881 }
882 }
883
884 return $out;
885 }
886
887 /**
888 * Candidate source terms for the "This is a translation of" dropdown — terms in
889 * $source_lang (default language) that aren't yet translated into $target_lang.
890 *
891 * @return array<int,array{term_id:int,name:string}>
892 */
893 public static function get_translation_candidates( $taxonomy, $target_lang, $source_lang ) {
894 $candidates = [];
895 $target_lang = sanitize_text_field( (string) $target_lang );
896 $source_lang = sanitize_text_field( (string) $source_lang );
897 if ( $taxonomy === '' || $source_lang === '' ) {
898 return $candidates;
899 }
900
901 // WPML
902 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
903 global $sitepress;
904 if ( $sitepress && method_exists( $sitepress, 'get_elements_without_translations' ) ) {
905 $ttids = $sitepress->get_elements_without_translations( 'tax_' . $taxonomy, $target_lang, $source_lang );
906 foreach ( (array) $ttids as $ttid ) {
907 $t = get_term_by( 'term_taxonomy_id', (int) $ttid, $taxonomy );
908 if ( $t && ! is_wp_error( $t ) ) {
909 $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
910 }
911 }
912 }
913 return $candidates;
914 }
915
916 // Polylang — source-lang terms whose group lacks the target language.
917 if ( function_exists( 'pll_get_term_translations' ) && function_exists( 'pll_get_term_language' ) ) {
918 $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'lang' => $source_lang ] );
919 foreach ( (array) $terms as $t ) {
920 if ( is_wp_error( $t ) ) {
921 continue;
922 }
923 $group = pll_get_term_translations( $t->term_id );
924 if ( ! isset( $group[ $target_lang ] ) ) {
925 $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
926 }
927 }
928 }
929
930 return $candidates;
931 }
932
933 /**
934 * Set a term's language and (optionally) link it into the translation group of
935 * $translation_of_term_id. Empty $translation_of_term_id = standalone.
936 *
937 * @param \WP_Term $term
938 * @param string $lang_code
939 * @param int $translation_of_term_id
940 */
941 public static function link_term_translation( $term, $lang_code, $translation_of_term_id = 0 ) {
942 $lang_code = sanitize_text_field( (string) $lang_code );
943 if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
944 return;
945 }
946 $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
947 $translation_of_term_id = (int) $translation_of_term_id;
948
949 // Polylang
950 if ( function_exists( 'pll_set_term_language' ) ) {
951 pll_set_term_language( $term->term_id, $lang_code );
952 if ( $translation_of_term_id && function_exists( 'pll_save_term_translations' ) ) {
953 $group = function_exists( 'pll_get_term_translations' )
954 ? (array) pll_get_term_translations( $translation_of_term_id )
955 : [];
956 $group[ $lang_code ] = $term->term_id;
957 pll_save_term_translations( $group );
958 }
959 return;
960 }
961
962 // WPML
963 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
964 $el_type = 'tax_' . $taxonomy;
965 $trid = null;
966 $src = null;
967
968 if ( $translation_of_term_id ) {
969 $source = get_term( $translation_of_term_id, $taxonomy );
970 if ( $source && ! is_wp_error( $source ) ) {
971 $trid = apply_filters( 'wpml_element_trid', null, $source->term_taxonomy_id, $el_type );
972 $src = self::get_term_language( $source );
973 }
974 }
975
976 do_action( 'wpml_set_element_language_details', [
977 'element_id' => $term->term_taxonomy_id,
978 'element_type' => $el_type,
979 'trid' => $trid,
980 'language_code' => $lang_code,
981 'source_language_code' => $src,
982 ] );
983 }
984 }
985
986 public static function get_current_letter_docs( $current_letter, $limit = 0 ) {
987 global $wpdb;
988
989 $limit = absint( $limit );
990 $limit_sql = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
991
992 // Check if the encyclopedia_prefix parameter is set
993
994 $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
995 $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
996 $encyclopedia_root_slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopdia' );
997 // Sanitize values that may be interpolated into raw SQL fragments below.
998 $encyclopedia_root_slug = sanitize_title( $encyclopedia_root_slug );
999
1000 // if($enable_glossaries && $encyclopeia_suorce === 'glossaries'){
1001 if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1002 $lang_join = '';
1003 $lang_where = '';
1004
1005 // Add language filtering if multilingual plugin is active and we should apply filtering
1006 $current_language = self::get_current_language();
1007 if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1008 // Restrict language code to a safe character set before SQL interpolation.
1009 $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1010 // For WPML, use icl_translations table
1011 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1012 $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'";
1013 $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1014 }
1015 // For Polylang, use term_relationships with language taxonomy
1016 elseif ( function_exists( 'pll_current_language' ) ) {
1017 $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";
1018 $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1019 }
1020 }
1021
1022 $query = "
1023 SELECT
1024 t.term_id,
1025 t.name AS post_title,
1026 t.slug as slug,
1027 '' AS post_excerpt,
1028 CONCAT('" . get_home_url() . "/$encyclopedia_root_slug/', t.slug) AS permalink,
1029 tt.description AS post_content,
1030 JSON_OBJECT(
1031 'status', COALESCE(MAX(CASE WHEN m.meta_key = 'status' THEN m.meta_value END), ''),
1032 'glossary_term_description', COALESCE(MAX(CASE WHEN m.meta_key = 'glossary_term_description' THEN m.meta_value END), '')
1033 ) AS meta_data
1034 FROM
1035 {$wpdb->terms} t
1036 INNER JOIN
1037 {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1038 LEFT JOIN
1039 {$wpdb->termmeta} m ON t.term_id = m.term_id
1040 $lang_join
1041 WHERE
1042 tt.taxonomy = 'glossaries'
1043 AND
1044 SUBSTRING(t.name, 1, 1) = %s
1045 $lang_where
1046 GROUP BY
1047 t.term_id
1048 ORDER BY
1049 t.name ASC
1050 $limit_sql
1051 ";
1052 } else {
1053 $lang_join = '';
1054 $lang_where = '';
1055
1056 // Add language filtering for docs if multilingual plugin is active and we should apply filtering
1057 $current_language = self::get_current_language();
1058 if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1059 // Restrict language code to a safe character set before SQL interpolation.
1060 $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1061 // For WPML, use icl_translations table
1062 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1063 $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'";
1064 $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1065 }
1066 // For Polylang, use term_relationships with language taxonomy
1067 elseif ( function_exists( 'pll_current_language' ) ) {
1068 $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";
1069 $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1070 }
1071 }
1072
1073 $query = "
1074 SELECT ID, post_title, post_excerpt, guid, post_content
1075 FROM {$wpdb->posts}
1076 $lang_join
1077 WHERE post_type = 'docs'
1078 AND post_status = 'publish'
1079 AND SUBSTRING(post_title, 1, 1) = %s
1080 $lang_where
1081 ORDER BY post_date DESC
1082 $limit_sql
1083 ";
1084 }
1085
1086 $current_letter_docs = $wpdb->get_results( $wpdb->prepare( $query, $current_letter ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1087
1088 return $current_letter_docs;
1089 }
1090
1091 public static function docs_sort_by_letter( $limit = 10 ) {
1092 global $wpdb;
1093 $enable_non_latin = betterdocs()->settings->get( 'encyclopedia_enable_non_latin' );
1094 $script = betterdocs()->settings->get( 'encyclopedia_non_latin_option' );
1095 $letters = Helper::get_character_range( $enable_non_latin, $script );
1096
1097 $docs_by_letter = [];
1098 $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1099 $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1100
1101 foreach ( $letters as $letter ) {
1102 $posts = self::get_current_letter_docs( $letter, $limit );
1103
1104 if ( is_array( $posts ) && ! empty( $posts ) ) {
1105 foreach ( $posts as $post ) {
1106 $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : '';
1107 $glossary_term_description = $description['glossary_term_description'] ?? '';
1108
1109 // Remove any <p> tags or other unwanted HTML tags
1110 $glossary_term_description = wp_strip_all_tags( $glossary_term_description );
1111 $post_excerpt = wp_strip_all_tags( $post['post_excerpt'] ?? '' );
1112
1113 // Prepare post data
1114 if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1115 // For glossaries
1116 $permalink = '';
1117
1118 if ( isset( $post['slug'] ) ) {
1119 $term_link = get_term_link( $post['slug'], 'glossaries' );
1120
1121 if ( ! is_wp_error( $term_link ) ) {
1122 $permalink = $term_link;
1123 }
1124 }
1125
1126 $post_data = [
1127 'id' => $post['term_id'] ?? '',
1128 'post_title' => $post['post_title'] ?? '',
1129 'post_excerpt' => ! empty( $post_excerpt )
1130 ? $post_excerpt
1131 : ( ! empty( $glossary_term_description )
1132 ? self::get_custom_excerpt( $glossary_term_description, 15 )
1133 : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ) ),
1134 'permalink' => $permalink,
1135 ];
1136 } else {
1137 // For docs
1138 $post_data = [
1139 'id' => $post['ID'] ?? '',
1140 'post_title' => $post['post_title'] ?? '',
1141 'post_excerpt' => ! empty( $post_excerpt )
1142 ? $post_excerpt
1143 : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ),
1144 'permalink' => isset( $post['ID'] ) ? get_the_permalink( $post['ID'] ) : ''
1145 ];
1146 }
1147
1148 $docs_by_letter[$letter][] = $post_data;
1149 }
1150 }
1151 }
1152
1153 return $docs_by_letter;
1154 }
1155
1156 public static function get_glossaries() {
1157 global $wpdb;
1158
1159 $lang_join = '';
1160 $lang_where = '';
1161
1162 // Add language filtering if multilingual plugin is active and we should apply filtering
1163 $current_language = self::get_current_language();
1164 if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1165 // Restrict language code to a safe character set before SQL interpolation.
1166 $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1167 // For WPML, use icl_translations table
1168 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1169 $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'";
1170 $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1171 }
1172 // For Polylang, use term_relationships with language taxonomy
1173 elseif ( function_exists( 'pll_current_language' ) ) {
1174 $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";
1175 $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1176 }
1177 }
1178
1179 $query = "
1180 SELECT t.name
1181 FROM {$wpdb->terms} t
1182 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1183 $lang_join
1184 WHERE tt.taxonomy = 'glossaries'
1185 $lang_where
1186 ORDER BY t.name ASC
1187 ";
1188
1189 $glossaries = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1190
1191 return $glossaries;
1192 }
1193
1194 /**
1195 * 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)
1196 *
1197 * @param string $layout
1198 * @return string $layout
1199 */
1200 public static function determine_search_layout( $layout ) {
1201 if ( $layout ) {
1202 return $layout;
1203 }
1204
1205 $search_layout = betterdocs()->customizer->defaults->get( 'betterdocs_search_layout_select' );
1206 $docs_layout = betterdocs()->customizer->defaults->get( 'betterdocs_docs_layout_select' );
1207 $archive_page_layout = betterdocs()->customizer->defaults->get( 'betterdocs_archive_layout_select' );
1208 $single_layout = betterdocs()->customizer->defaults->get( 'betterdocs_single_layout_select' );
1209
1210 if ( is_post_type_archive( 'docs' ) ) {
1211 if ( $docs_layout != "layout-7" && ! $search_layout ) {
1212 $layout = 'layout-1';
1213 } else if ( $docs_layout == 'layout-7' && ! $search_layout ) {
1214 $layout = 'layout-2';
1215 }
1216 } else if ( is_tax( 'doc_tag' ) && ! $search_layout ) {
1217 $layout = 'layout-1';
1218 } else if ( is_tax( 'doc_category' ) ) {
1219 if ( $archive_page_layout != 'layout-7' && $archive_page_layout != 'layout-8' && ! $search_layout ) {
1220 $layout = 'layout-1';
1221 } else if ( ( $archive_page_layout == 'layout-7' && ! $search_layout ) || ( $archive_page_layout == 'layout-8' && ! $search_layout ) ) {
1222 $layout = 'layout-2';
1223 }
1224 } else if ( is_singular( 'docs' ) ) {
1225 if ( $single_layout != 'layout-8' && $single_layout != 'layout-9' && ! $search_layout ) {
1226 $layout = 'layout-1';
1227 } else if ( ( $single_layout == 'layout-8' && ! $search_layout ) || ( $single_layout == 'layout-9' && ! $search_layout ) ) {
1228 $layout = 'layout-2';
1229 }
1230 }
1231
1232 return $layout;
1233 }
1234 public static function mb_ord_fallback( $char ) {
1235 $code = unpack( 'N', mb_convert_encoding( $char, 'UCS-4BE', 'UTF-8' ) );
1236 return $code[1];
1237 }
1238
1239 public static function mb_chr_fallback( $code ) {
1240 return mb_convert_encoding( pack( 'N', $code ), 'UTF-8', 'UCS-4BE' );
1241 }
1242
1243 public static function unicodeRange( $start, $end ) {
1244 $range = [];
1245 for ( $i = self::mb_ord_fallback( $start ); $i <= self::mb_ord_fallback( $end ); $i++ ) {
1246 $range[] = self::mb_chr_fallback( $i );
1247 }
1248 return $range;
1249 }
1250
1251 public static function get_character_range( $enable_non_latin, $script ) {
1252 if ( $enable_non_latin ) {
1253 switch ( $script ) {
1254 case 'arabic':
1255 return self::unicodeRange( 'ء', 'ي' );
1256 case 'cyrillic':
1257 return self::unicodeRange( 'А', 'Я' );
1258 case 'hebrew':
1259 return self::unicodeRange( 'א', 'ת' );
1260 case 'greek':
1261 return self::unicodeRange( 'Α', 'Ω' );
1262 default:
1263 return range( 'A', 'Z' );
1264 }
1265 }
1266
1267 return range( 'A', 'Z' );
1268 }
1269
1270 public static function get_the_top_most_parent( $term_id ) {
1271 while ( $term_id != 0 ) {
1272 $parent_id = wp_get_term_taxonomy_parent_id( $term_id, 'doc_category' );
1273
1274 if ( $parent_id == 0 ) {
1275 break;
1276 }
1277
1278 $term_id = $parent_id;
1279 }
1280 return $term_id;
1281 }
1282
1283 public static function get_highest_docs_term() {
1284 $terms = get_terms( [
1285 'taxonomy' => 'doc_category', // Change to your desired taxonomy
1286 'hide_empty' => true, // Only show terms with posts
1287 'orderby' => 'count', // Order by post count
1288 'order' => 'DESC', // Descending order
1289 'number' => 1 // Get only the top term
1290 ] );
1291 return isset( $terms[0] ) ? $terms[0] : [];
1292 }
1293
1294 public static function delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
1295 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- targeted bulk delete by FAQ category; tax filter is required.
1296 $args = [
1297 'post_type' => 'betterdocs_faq',
1298 'posts_per_page' => -1,
1299 // EVERY status, explicitly. WP_Query defaults to 'publish', so "delete this
1300 // group and its FAQs" was deleting only the published ones — the drafts (and
1301 // pending/scheduled/private/trashed FAQs) survived, and the wp_delete_term()
1302 // that follows then stripped their category, leaving them orphaned under
1303 // "Uncategorized". Note 'any' is NOT enough here: it excludes trash.
1304 'post_status' => [ 'publish', 'draft', 'pending', 'future', 'private', 'trash' ],
1305 'tax_query' => [
1306 [
1307 'taxonomy' => $taxonomy,
1308 'field' => 'id',
1309 'terms' => $term_id,
1310 'operator' => 'IN'
1311 ]
1312 ],
1313 'fields' => 'ids'
1314 ];
1315
1316 $query = new \WP_Query( $args );
1317
1318 if ( $query->have_posts() ) {
1319 foreach ( $query->posts as $doc_id ) {
1320 wp_delete_post( $doc_id, true );
1321 }
1322 }
1323 }
1324
1325 /**
1326 * Function To Normalize Repeater Field For Quick Builder
1327 *
1328 * @param array $fields
1329 * @param array $include_field_keys
1330 *
1331 * @return array
1332 */
1333 public static function normalize_repeater_field( $fields, $include_field_keys = [] ) {
1334 if( empty( $include_field_keys ) ) {
1335 return $fields;
1336 }
1337
1338 $normalized_fields = [];
1339
1340 foreach( $fields as $field ) {
1341 foreach( $include_field_keys as $field_key ) {
1342 if( ! isset( $normalized_fields[$field_key] ) ) {
1343 $normalized_fields[$field_key] = isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [];
1344 } else {
1345 array_push( $normalized_fields[$field_key], ...( isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [] ) );
1346 $normalized_fields[$field_key] = array_unique( $normalized_fields[$field_key] );
1347 }
1348 }
1349 }
1350
1351 return $normalized_fields;
1352 }
1353
1354 public static function get_local_plugin_data( $basename = '' ) {
1355 if ( empty( $basename ) ) {
1356 return false;
1357 }
1358
1359 if ( !function_exists( 'get_plugins' ) ) {
1360 include_once ABSPATH . 'wp-admin/includes/plugin.php';
1361 }
1362
1363 $plugins = get_plugins();
1364
1365 if ( !isset( $plugins[ $basename ] ) ) {
1366 return false;
1367 }
1368
1369 return $plugins[ $basename ];
1370 }
1371
1372 /**
1373 * Get default file icon based on programming language
1374 *
1375 * @param string $language Programming language identifier
1376 * @return string Emoji icon for the language
1377 */
1378 public static function get_file_icon_by_language( $language ) {
1379 $icons = [
1380 'javascript' => '📄',
1381 'typescript' => '📘',
1382 'jsx' => '⚛️',
1383 'tsx' => '⚛️',
1384 'html' => '🌐',
1385 'css' => '🎨',
1386 'scss' => '🎨',
1387 'sass' => '🎨',
1388 'less' => '🎨',
1389 'php' => '🐘',
1390 'python' => '🐍',
1391 'java' => '',
1392 'csharp' => '🔷',
1393 'cpp' => '⚙️',
1394 'c' => '⚙️',
1395 'ruby' => '💎',
1396 'go' => '🐹',
1397 'rust' => '🦀',
1398 'swift' => '🦉',
1399 'kotlin' => '🎯',
1400 'sql' => '🗃️',
1401 'json' => '📋',
1402 'yaml' => '📋',
1403 'xml' => '📄',
1404 'markdown' => '📝',
1405 'bash' => '💻',
1406 'shell' => '💻',
1407 'powershell' => '💻',
1408 'dockerfile' => '🐳',
1409 ];
1410
1411 return isset( $icons[$language] ) ? $icons[$language] : '📄';
1412 }
1413
1414 /**
1415 * Check if AI Chatbot is enabled
1416 *
1417 * @return bool
1418 */
1419 public function is_ai_chatbot_enabled() {
1420 $chatbot_active = is_plugin_active( 'betterdocs-ai-chatbot/betterdocs-ai-chatbot.php' );
1421 $chatbot_license_valid = get_option( 'betterdocs_chatbot_software__license_status' ) === 'valid';
1422 $chatbot_enabled = betterdocs()->settings->get( 'enable_ai_chatbot', false );
1423
1424 // AI Search Suggestions are enabled if all conditions are met
1425 return $chatbot_active && $chatbot_license_valid && $chatbot_enabled;
1426 }
1427
1428 /**
1429 * Check if tags are enabled and post has tags
1430 *
1431 * @return bool
1432 */
1433 public function is_tag_enabled() {
1434 global $post;
1435 $product_terms = wp_get_object_terms( $post->ID, 'doc_tag' );
1436 $enable_tags = betterdocs()->settings->get( 'enable_tags', false );
1437 return ! empty( $product_terms ) && $enable_tags;
1438 }
1439
1440 /**
1441 * Check if AI Search Suggestions are enabled
1442 *
1443 * @return bool
1444 */
1445 public function is_ai_search_suggestions_enabled() {
1446 $ai_search_suggestions_active = is_plugin_active( 'betterdocs-ai-search-suggestions/betterdocs-ai-search-suggestions.php' );
1447 $ai_search_suggestions_license_valid = get_option( 'betterdocs_ai_search_suggestions_software__license_status' ) === 'valid';
1448 $ai_search_suggestions_enabled = betterdocs()->settings->get( 'enable_ai_powered_search', false );
1449
1450 return $ai_search_suggestions_active && $ai_search_suggestions_license_valid && $ai_search_suggestions_enabled;
1451 }
1452
1453 /**
1454 * Get the maximum order value from the 'doc_category_order' term meta
1455 *
1456 * @return int
1457 */
1458 public static function get_max_doc_category_order_from_term_meta() {
1459 global $wpdb;
1460 $sql = $wpdb->prepare( "SELECT MAX(CAST(meta_value AS UNSIGNED)) AS max FROM {$wpdb->termmeta} WHERE meta_key = %s ", 'doc_category_order' );
1461 $result = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above.
1462 return $result;
1463 }
1464 }
1465