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

1,127 lines 40.9 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 public static function get_tax( $tax = '' ) {
56 global $wp_query;
57
58 if ( is_tax( 'knowledge_base' ) ) {
59 $_taxes = $wp_query->tax_query->queried_terms;
60 if ( array_key_exists( 'doc_category', $_taxes ) ) {
61 $tax = 'doc_category';
62 } else {
63 $tax = 'knowledge_base';
64 }
65 } elseif ( is_tax( 'doc_category' ) ) {
66 $tax = 'doc_category';
67 } elseif ( is_tax( 'doc_tag' ) ) {
68 $tax = 'doc_tag';
69 }
70
71 return $tax;
72 }
73
74 public function is_templates() {
75 global $wp_query;
76 $slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopedia' );
77
78 $tax = $this->get_tax();
79 if ( is_post_type_archive( 'docs' ) || $tax === 'knowledge_base' || $tax === 'doc_category' || $tax === 'doc_tag' || is_singular( 'docs' ) || is_tax( 'glossaries' ) ) {
80 return true;
81 }
82
83 if ( isset( $wp_query->query['pagename'] ) && $wp_query->query['pagename'] === $slug ) {
84 return true;
85 }
86
87 return false;
88 }
89
90 public function is_el_templates() {
91 $_return_val = betterdocs()->editor->get( 'elementor' )->is_templates();
92
93 if ( $_return_val !== null ) {
94 return $_return_val;
95 }
96
97 $this->is_templates();
98 }
99
100 /**
101 * Which tab to show.
102 *
103 * 1. Drag and Drop UI
104 * 2. Post List UI
105 *
106 * * 1. dnd
107 * * 2. classic
108 *
109 * look into views/admin/docs-ui directory to know more.
110 *
111 * @return string
112 */
113 public static function admin_tab() {
114 $admin_ui = 'grid';
115 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin UI selection, no state change.
116 $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
117 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin UI selection, no state change.
118 $mode = isset( $_GET['mode'] ) ? sanitize_text_field( wp_unslash( $_GET['mode'] ) ) : '';
119 if ( $page === 'betterdocs-admin' && ! empty( $mode ) ) {
120 $admin_ui = $mode === 'grid' ? 'grid' : 'list';
121 }
122
123 return $admin_ui;
124 }
125
126 public static function is_active( $prev, $current, $class = 'active' ) {
127 if ( $current == $prev ) {
128 return $class;
129 }
130
131 return '';
132 }
133
134 public function get_users( $args ) {
135 $cache_key = 'betterdocs_cache_admin_user_roles';
136 $users = betterdocs()->database->get_cache( $cache_key );
137
138 if ( false === $users ) {
139 $users = get_users( $args );
140 betterdocs()->database->set_cache( $cache_key, $users );
141 }
142
143 return $users;
144 }
145
146 /**
147 * Normalize Menu Array
148 * Menu creator helper
149 *
150 * @since 2.5.0
151 *
152 * @param string $title
153 * @param string $slug
154 * @param string $cap
155 * @param array $callback
156 *
157 * @return array
158 */
159 public static function normalize_menu( $title, $slug, $cap = 'edit_docs', $callback = null, $optional = [] ) {
160 $args = [
161 'page_title' => $title,
162 'menu_title' => $title,
163 'capability' => $cap,
164 'menu_slug' => $slug
165 ];
166
167 if ( $callback != null ) {
168 $args['callback'] = $callback;
169 }
170
171 return wp_parse_args( $optional, $args );
172 }
173
174 /**
175 * Check if the current theme is a block theme.
176 *
177 * @since x.x.x
178 * @return bool
179 */
180 public function current_theme_is_fse_theme() {
181 if ( function_exists( 'wp_is_block_theme' ) ) {
182 return (bool) wp_is_block_theme();
183 }
184 if ( function_exists( 'gutenberg_is_fse_theme' ) ) {
185 return (bool) gutenberg_is_fse_theme();
186 }
187
188 return false;
189 }
190
191 protected static function is_assoc_array( $array ) {
192 return array_keys( $array ) !== range( 0, count( $array ) - 1 );
193 }
194
195 public static function merge( &$array1, &$array2 ) {
196 $merged = $array1;
197
198 foreach ( $array2 as $key => &$value ) {
199 if ( is_array( $value ) && self::is_assoc_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
200 $merged[ $key ] = self::merge( $merged[ $key ], $value );
201 } elseif ( is_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
202 $merged[ $key ] = array_merge( $merged[ $key ], $value );
203 } else {
204 $merged[ $key ] = $value;
205 }
206 }
207
208 return $merged;
209 }
210
211 public static function get_custom_excerpt( $content, $numOfWords ) {
212 $content = strip_shortcodes( $content );
213 $content = wp_strip_all_tags( $content );
214 $words = explode( ' ', $content );
215 $excerptWords = array_slice( $words, 0, $numOfWords );
216 $excerpt = implode( ' ', $excerptWords );
217 if ( count( $words ) > $numOfWords ) {
218 $excerpt .= '...';
219 }
220 return $excerpt;
221 }
222
223 /**
224 * Get current language from various multilingual plugins
225 *
226 * @return string|null Current language code
227 */
228 public static function get_current_language() {
229 $current_language = null;
230
231 // WPML Support
232 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
233 global $sitepress;
234 if ( $sitepress && $sitepress->is_setup_complete() ) {
235 $current_language = defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : $sitepress->get_current_language();
236 }
237 }
238 // Polylang Support
239 elseif ( function_exists( 'pll_current_language' ) ) {
240 $current_language = pll_current_language();
241 }
242 // qTranslate-X Support
243 elseif ( function_exists( 'qtranxf_getLanguage' ) ) {
244 $current_language = qtranxf_getLanguage();
245 }
246 // Weglot Support
247 elseif ( function_exists( 'weglot_get_current_language' ) ) {
248 $current_language = weglot_get_current_language();
249 }
250 // TranslatePress Support
251 elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
252 $current_language = trp_get_current_language();
253 }
254
255 return $current_language;
256 }
257
258 /**
259 * Check if any multilingual plugin is active
260 *
261 * @return bool
262 */
263 public static function is_multilingual_active() {
264 return is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ||
265 function_exists( 'pll_current_language' ) ||
266 function_exists( 'qtranxf_getLanguage' ) ||
267 function_exists( 'weglot_get_current_language' ) ||
268 ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) );
269 }
270
271 /**
272 * Check if we should apply language filtering
273 * Only apply on frontend or when specifically requested
274 *
275 * @return bool
276 */
277 public static function should_apply_language_filtering() {
278 // Don't apply language filtering in admin context unless it's a frontend request
279 if ( is_admin() ) {
280 // Allow language filtering for REST API requests that are frontend-facing
281 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
282 // Check if this is a frontend REST request (not admin)
283 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
284 // Don't filter admin REST requests for glossaries management
285 if ( strpos( $request_uri, '/wp/v2/glossaries' ) !== false ) {
286 return false; // Don't filter admin glossaries management
287 }
288 }
289 return false; // Don't filter other admin requests
290 }
291
292 // Apply filtering on frontend
293 return true;
294 }
295
296 /**
297 * Get current admin language for multilingual sites
298 * This is specifically for admin context where we need to detect
299 * the language being used for editing terms/posts
300 *
301 * @return string|null Current admin language code
302 */
303 public static function get_current_admin_language() {
304 $current_language = null;
305
306 // Explicit language passed by the admin client takes priority.
307 // Covers AJAX (POST) and REST/admin requests (GET) where WPML may
308 // otherwise resolve to the site's default language instead of the
309 // admin UI language.
310 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only UI language hint, sanitized; not a state-changing form submission.
311 if ( isset( $_POST['lang'] ) && ! empty( $_POST['lang'] ) ) {
312 return sanitize_text_field( wp_unslash( $_POST['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see note above.
313 }
314
315 // Limit GET handling to admin/REST contexts so a frontend ?lang= switch
316 // doesn't hijack admin meta-key resolution.
317 if ( isset( $_GET['lang'] ) && ! empty( $_GET['lang'] )
318 && ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) ) {
319 return sanitize_text_field( wp_unslash( $_GET['lang'] ) );
320 }
321
322 // WPML Support - Admin language detection
323 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
324 global $sitepress;
325 if ( $sitepress && $sitepress->is_setup_complete() ) {
326 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
327 $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
328 // For term editing, check if we have a specific term language
329 if ( $tag_id && function_exists( 'wpml_get_language_information' ) ) {
330 $term_info = wpml_get_language_information( null, $tag_id );
331 if ( ! is_wp_error( $term_info ) && $term_info && isset( $term_info['language_code'] ) ) {
332 $current_language = $term_info['language_code'];
333 }
334
335 }
336
337 // Check for language parameter in URL
338 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
339 if ( ! $current_language && isset( $_GET['lang'] ) ) {
340 $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
341 }
342
343 // Check WPML admin language cookie (persists during AJAX)
344 if ( ! $current_language && isset( $_COOKIE['_icl_current_admin_language'] ) ) {
345 $current_language = sanitize_text_field( wp_unslash( $_COOKIE['_icl_current_admin_language'] ) );
346 }
347
348 // Fallback to admin language or current language
349 if ( ! $current_language ) {
350 $current_language = defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : $sitepress->get_current_language();
351 }
352 }
353 }
354 // Polylang Support - Admin language detection
355 elseif ( function_exists( 'pll_current_language' ) ) {
356 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
357 $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
358 // For term editing, get language from term ID
359 if ( $tag_id && function_exists( 'pll_get_term_language' ) ) {
360 $term_lang = pll_get_term_language( $tag_id );
361 if ( $term_lang ) {
362 $current_language = $term_lang;
363 }
364 }
365
366 // Check for language parameter in URL
367 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
368 if ( ! $current_language && isset( $_GET['lang'] ) ) {
369 $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
370 }
371
372 // Fallback to current admin language
373 if ( ! $current_language ) {
374 $current_language = pll_current_language( 'slug' );
375 }
376 }
377 // Other multilingual plugins
378 elseif ( function_exists( 'qtranxf_getLanguage' ) ) {
379 $current_language = qtranxf_getLanguage();
380 }
381 elseif ( function_exists( 'weglot_get_current_language' ) ) {
382 $current_language = weglot_get_current_language();
383 }
384 elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
385 $current_language = trp_get_current_language();
386 }
387
388 return $current_language;
389 }
390
391 /**
392 * Generate language-specific meta key for category ordering
393 * Always falls back to base key if language-specific key doesn't exist
394 *
395 * @param string $base_key The base meta key (e.g., 'doc_category_order')
396 * @param string|null $language Language code, if null will auto-detect
397 * @return string Language-specific meta key or base key as fallback
398 */
399 public static function get_language_specific_meta_key( $base_key, $language = null ) {
400 // If no multilingual plugin is active, return the base key
401 if ( ! self::is_multilingual_active() ) {
402 return $base_key;
403 }
404
405 // Get current admin language if not provided
406 if ( $language === null ) {
407 $language = self::get_current_admin_language();
408 }
409
410 // If no language detected, return base key for backward compatibility
411 if ( ! $language ) {
412 return $base_key;
413 }
414
415 // Always return base key for now - we'll handle fallback in the query functions
416 // This ensures compatibility without requiring migration
417 return $base_key;
418 }
419
420 /**
421 * Get the meta key to write to.
422 *
423 * Unlike `get_meta_key_with_fallback`, this never falls back to the base
424 * key when the language-specific key is empty — that fallback is what
425 * caused secondary-language drag-and-drop saves to clobber the base meta
426 * (and on WPML setups that copy term meta from the original language,
427 * the next read would re-overwrite it from the primary language).
428 *
429 * @param string $base_key The base meta key.
430 * @param string|null $language Language code, auto-detected when null.
431 * @return string Language-specific key when multilingual + language known, else base.
432 */
433 public static function get_meta_key_for_save( $base_key, $language = null ) {
434 if ( ! self::is_multilingual_active() ) {
435 return $base_key;
436 }
437
438 if ( $language === null ) {
439 $language = self::get_current_admin_language();
440 }
441
442 if ( ! $language ) {
443 return $base_key;
444 }
445
446 return $base_key . '_' . $language;
447 }
448
449 /**
450 * Get the appropriate meta key with fallback logic
451 * This function checks if language-specific meta exists, if not falls back to base key
452 *
453 * @param string $base_key The base meta key
454 * @param int $term_id The term ID to check
455 * @param string|null $language Language code
456 * @return string The meta key to use
457 */
458 public static function get_meta_key_with_fallback( $base_key, $term_id = null, $language = null ) {
459 // If no multilingual plugin is active, return the base key
460 if ( ! self::is_multilingual_active() ) {
461 return $base_key;
462 }
463
464 // Get current admin language if not provided
465 if ( $language === null ) {
466 $language = self::get_current_admin_language();
467 }
468
469 // If no language detected, return base key
470 if ( ! $language ) {
471 return $base_key;
472 }
473
474 $lang_meta_key = $base_key . '_' . $language;
475
476 // If we have a specific term ID, check if language-specific meta exists
477 if ( $term_id ) {
478 $lang_value = get_term_meta( $term_id, $lang_meta_key, true );
479 if ( ! empty( $lang_value ) ) {
480 return $lang_meta_key;
481 }
482 // Fall back to base key if language-specific doesn't exist
483 return $base_key;
484 }
485
486 // For queries without specific term ID, we need to check if ANY terms have language-specific meta
487 global $wpdb;
488 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live multilingual meta-key resolution; result varies per active language.
489 $has_lang_meta = $wpdb->get_var( $wpdb->prepare(
490 "SELECT COUNT(*) FROM {$wpdb->termmeta} tm
491 INNER JOIN {$wpdb->term_taxonomy} tt ON tm.term_id = tt.term_id
492 WHERE tm.meta_key = %s AND tt.taxonomy = 'doc_category' AND tm.meta_value != ''",
493 $lang_meta_key
494 ) );
495
496 // If language-specific meta exists for some terms, use it (terms without it will have empty values)
497 // Otherwise, fall back to base key
498 return $has_lang_meta > 0 ? $lang_meta_key : $base_key;
499 }
500
501 /**
502 * Migrate existing category orders to language-specific meta keys
503 * This should be called when a multilingual plugin is activated
504 *
505 * @param string $base_key The base meta key (e.g., 'doc_category_order')
506 * @param string $taxonomy The taxonomy to migrate
507 * @return bool Success status
508 */
509 public static function migrate_category_orders_to_multilingual( $base_key = 'doc_category_order', $taxonomy = 'doc_category' ) {
510 // Only run if multilingual plugin is active
511 if ( ! self::is_multilingual_active() ) {
512 return false;
513 }
514
515 global $wpdb;
516
517 // Get all terms with the base meta key
518 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
519 $terms_with_order = $wpdb->get_results( $wpdb->prepare(
520 "SELECT tm.term_id, tm.meta_value, t.slug
521 FROM {$wpdb->termmeta} tm
522 INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
523 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
524 WHERE tm.meta_key = %s AND tt.taxonomy = %s",
525 $base_key,
526 $taxonomy
527 ) );
528
529 if ( empty( $terms_with_order ) ) {
530 return true; // Nothing to migrate
531 }
532
533 // Get available languages
534 $languages = self::get_available_languages();
535
536 if ( empty( $languages ) ) {
537 return false; // No languages found
538 }
539
540 // Migrate orders for each language
541 foreach ( $languages as $language ) {
542 $language_meta_key = $base_key . '_' . $language;
543
544 foreach ( $terms_with_order as $term_data ) {
545 // Check if language-specific meta already exists
546 $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
547
548 if ( empty( $existing_value ) ) {
549 // Copy the base order to language-specific key
550 update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
551 }
552 }
553 }
554
555 return true;
556 }
557
558 /**
559 * Migrate existing document orders to language-specific meta keys
560 * This should be called when a multilingual plugin is activated
561 *
562 * @param string $base_key The base meta key (e.g., '_docs_order')
563 * @param string $taxonomy The taxonomy to migrate
564 * @return bool Success status
565 */
566 public static function migrate_docs_orders_to_multilingual( $base_key = '_docs_order', $taxonomy = 'doc_category' ) {
567 // Only run if multilingual plugin is active
568 if ( ! self::is_multilingual_active() ) {
569 return false;
570 }
571
572 global $wpdb;
573
574 // Get all terms with the base meta key for document ordering
575 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
576 $terms_with_docs_order = $wpdb->get_results( $wpdb->prepare(
577 "SELECT tm.term_id, tm.meta_value, t.slug
578 FROM {$wpdb->termmeta} tm
579 INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
580 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
581 WHERE tm.meta_key = %s AND tt.taxonomy = %s AND tm.meta_value != ''",
582 $base_key,
583 $taxonomy
584 ) );
585
586 if ( empty( $terms_with_docs_order ) ) {
587 return true; // Nothing to migrate
588 }
589
590 // Get available languages
591 $languages = self::get_available_languages();
592
593 if ( empty( $languages ) ) {
594 return false; // No languages found
595 }
596
597 // Migrate document orders for each language
598 foreach ( $languages as $language ) {
599 $language_meta_key = $base_key . '_' . $language;
600
601 foreach ( $terms_with_docs_order as $term_data ) {
602 // Check if language-specific meta already exists
603 $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
604
605 if ( empty( $existing_value ) ) {
606 // Copy the base document order to language-specific key
607 update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
608 }
609 }
610 }
611
612 return true;
613 }
614
615 /**
616 * Migrate both category and document orders to multilingual format
617 * This is a convenience method that runs both migrations
618 *
619 * @return bool Success status
620 */
621 public static function migrate_all_orders_to_multilingual() {
622 $category_result = self::migrate_category_orders_to_multilingual();
623 $docs_result = self::migrate_docs_orders_to_multilingual();
624
625 return $category_result && $docs_result;
626 }
627
628 /**
629 * Get available languages from multilingual plugins
630 *
631 * @return array Array of language codes
632 */
633 public static function get_available_languages() {
634 $languages = [];
635
636 // WPML Support
637 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
638 global $sitepress;
639 if ( $sitepress && $sitepress->is_setup_complete() ) {
640 $active_languages = $sitepress->get_active_languages();
641 if ( is_array( $active_languages ) ) {
642 $languages = array_keys( $active_languages );
643 }
644 }
645 }
646 // Polylang Support
647 elseif ( function_exists( 'pll_languages_list' ) ) {
648 $languages = pll_languages_list();
649 }
650
651 return $languages;
652 }
653
654 public static function get_current_letter_docs( $current_letter, $limit = 0 ) {
655 global $wpdb;
656
657 $limit = absint( $limit );
658 $limit_sql = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
659
660 // Check if the encyclopedia_prefix parameter is set
661
662 $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
663 $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
664 $encyclopedia_root_slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopdia' );
665 // Sanitize values that may be interpolated into raw SQL fragments below.
666 $encyclopedia_root_slug = sanitize_title( $encyclopedia_root_slug );
667
668 // if($enable_glossaries && $encyclopeia_suorce === 'glossaries'){
669 if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
670 $lang_join = '';
671 $lang_where = '';
672
673 // Add language filtering if multilingual plugin is active and we should apply filtering
674 $current_language = self::get_current_language();
675 if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
676 // Restrict language code to a safe character set before SQL interpolation.
677 $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
678 // For WPML, use icl_translations table
679 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
680 $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'";
681 $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
682 }
683 // For Polylang, use term_relationships with language taxonomy
684 elseif ( function_exists( 'pll_current_language' ) ) {
685 $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";
686 $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
687 }
688 }
689
690 $query = "
691 SELECT
692 t.term_id,
693 t.name AS post_title,
694 t.slug as slug,
695 '' AS post_excerpt,
696 CONCAT('" . get_home_url() . "/$encyclopedia_root_slug/', t.slug) AS permalink,
697 tt.description AS post_content,
698 JSON_OBJECT(
699 'status', COALESCE(MAX(CASE WHEN m.meta_key = 'status' THEN m.meta_value END), ''),
700 'glossary_term_description', COALESCE(MAX(CASE WHEN m.meta_key = 'glossary_term_description' THEN m.meta_value END), '')
701 ) AS meta_data
702 FROM
703 {$wpdb->terms} t
704 INNER JOIN
705 {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
706 LEFT JOIN
707 {$wpdb->termmeta} m ON t.term_id = m.term_id
708 $lang_join
709 WHERE
710 tt.taxonomy = 'glossaries'
711 AND
712 SUBSTRING(t.name, 1, 1) = %s
713 $lang_where
714 GROUP BY
715 t.term_id
716 ORDER BY
717 t.name ASC
718 $limit_sql
719 ";
720 } else {
721 $lang_join = '';
722 $lang_where = '';
723
724 // Add language filtering for docs if multilingual plugin is active and we should apply filtering
725 $current_language = self::get_current_language();
726 if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
727 // Restrict language code to a safe character set before SQL interpolation.
728 $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
729 // For WPML, use icl_translations table
730 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
731 $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'";
732 $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
733 }
734 // For Polylang, use term_relationships with language taxonomy
735 elseif ( function_exists( 'pll_current_language' ) ) {
736 $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";
737 $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
738 }
739 }
740
741 $query = "
742 SELECT ID, post_title, post_excerpt, guid, post_content
743 FROM {$wpdb->posts}
744 $lang_join
745 WHERE post_type = 'docs'
746 AND post_status = 'publish'
747 AND SUBSTRING(post_title, 1, 1) = %s
748 $lang_where
749 ORDER BY post_date DESC
750 $limit_sql
751 ";
752 }
753
754 $current_letter_docs = $wpdb->get_results( $wpdb->prepare( $query, $current_letter ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
755
756 return $current_letter_docs;
757 }
758
759 public static function docs_sort_by_letter( $limit = 10 ) {
760 global $wpdb;
761 $enable_non_latin = betterdocs()->settings->get( 'encyclopedia_enable_non_latin' );
762 $script = betterdocs()->settings->get( 'encyclopedia_non_latin_option' );
763 $letters = Helper::get_character_range( $enable_non_latin, $script );
764
765 $docs_by_letter = [];
766 $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
767 $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
768
769 foreach ( $letters as $letter ) {
770 $posts = self::get_current_letter_docs( $letter, $limit );
771
772 if ( is_array( $posts ) && ! empty( $posts ) ) {
773 foreach ( $posts as $post ) {
774 $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : '';
775 $glossary_term_description = $description['glossary_term_description'] ?? '';
776
777 // Remove any <p> tags or other unwanted HTML tags
778 $glossary_term_description = wp_strip_all_tags( $glossary_term_description );
779 $post_excerpt = wp_strip_all_tags( $post['post_excerpt'] ?? '' );
780
781 // Prepare post data
782 if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
783 // For glossaries
784 $permalink = '';
785
786 if ( isset( $post['slug'] ) ) {
787 $term_link = get_term_link( $post['slug'], 'glossaries' );
788
789 if ( ! is_wp_error( $term_link ) ) {
790 $permalink = $term_link;
791 }
792 }
793
794 $post_data = [
795 'id' => $post['term_id'] ?? '',
796 'post_title' => $post['post_title'] ?? '',
797 'post_excerpt' => ! empty( $post_excerpt )
798 ? $post_excerpt
799 : ( ! empty( $glossary_term_description )
800 ? self::get_custom_excerpt( $glossary_term_description, 15 )
801 : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ) ),
802 'permalink' => $permalink,
803 ];
804 } else {
805 // For docs
806 $post_data = [
807 'id' => $post['ID'] ?? '',
808 'post_title' => $post['post_title'] ?? '',
809 'post_excerpt' => ! empty( $post_excerpt )
810 ? $post_excerpt
811 : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ),
812 'permalink' => isset( $post['ID'] ) ? get_the_permalink( $post['ID'] ) : ''
813 ];
814 }
815
816 $docs_by_letter[$letter][] = $post_data;
817 }
818 }
819 }
820
821 return $docs_by_letter;
822 }
823
824 public static function get_glossaries() {
825 global $wpdb;
826
827 $lang_join = '';
828 $lang_where = '';
829
830 // Add language filtering if multilingual plugin is active and we should apply filtering
831 $current_language = self::get_current_language();
832 if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
833 // Restrict language code to a safe character set before SQL interpolation.
834 $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
835 // For WPML, use icl_translations table
836 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
837 $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'";
838 $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
839 }
840 // For Polylang, use term_relationships with language taxonomy
841 elseif ( function_exists( 'pll_current_language' ) ) {
842 $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";
843 $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
844 }
845 }
846
847 $query = "
848 SELECT t.name
849 FROM {$wpdb->terms} t
850 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
851 $lang_join
852 WHERE tt.taxonomy = 'glossaries'
853 $lang_where
854 ORDER BY t.name ASC
855 ";
856
857 $glossaries = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
858
859 return $glossaries;
860 }
861
862 /**
863 * 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)
864 *
865 * @param string $layout
866 * @return string $layout
867 */
868 public static function determine_search_layout( $layout ) {
869 if ( $layout ) {
870 return $layout;
871 }
872
873 $search_layout = betterdocs()->customizer->defaults->get( 'betterdocs_search_layout_select' );
874 $docs_layout = betterdocs()->customizer->defaults->get( 'betterdocs_docs_layout_select' );
875 $archive_page_layout = betterdocs()->customizer->defaults->get( 'betterdocs_archive_layout_select' );
876 $single_layout = betterdocs()->customizer->defaults->get( 'betterdocs_single_layout_select' );
877
878 if ( is_post_type_archive( 'docs' ) ) {
879 if ( $docs_layout != "layout-7" && ! $search_layout ) {
880 $layout = 'layout-1';
881 } else if ( $docs_layout == 'layout-7' && ! $search_layout ) {
882 $layout = 'layout-2';
883 }
884 } else if ( is_tax( 'doc_tag' ) && ! $search_layout ) {
885 $layout = 'layout-1';
886 } else if ( is_tax( 'doc_category' ) ) {
887 if ( $archive_page_layout != 'layout-7' && $archive_page_layout != 'layout-8' && ! $search_layout ) {
888 $layout = 'layout-1';
889 } else if ( ( $archive_page_layout == 'layout-7' && ! $search_layout ) || ( $archive_page_layout == 'layout-8' && ! $search_layout ) ) {
890 $layout = 'layout-2';
891 }
892 } else if ( is_singular( 'docs' ) ) {
893 if ( $single_layout != 'layout-8' && $single_layout != 'layout-9' && ! $search_layout ) {
894 $layout = 'layout-1';
895 } else if ( ( $single_layout == 'layout-8' && ! $search_layout ) || ( $single_layout == 'layout-9' && ! $search_layout ) ) {
896 $layout = 'layout-2';
897 }
898 }
899
900 return $layout;
901 }
902 public static function mb_ord_fallback( $char ) {
903 $code = unpack( 'N', mb_convert_encoding( $char, 'UCS-4BE', 'UTF-8' ) );
904 return $code[1];
905 }
906
907 public static function mb_chr_fallback( $code ) {
908 return mb_convert_encoding( pack( 'N', $code ), 'UTF-8', 'UCS-4BE' );
909 }
910
911 public static function unicodeRange( $start, $end ) {
912 $range = [];
913 for ( $i = self::mb_ord_fallback( $start ); $i <= self::mb_ord_fallback( $end ); $i++ ) {
914 $range[] = self::mb_chr_fallback( $i );
915 }
916 return $range;
917 }
918
919 public static function get_character_range( $enable_non_latin, $script ) {
920 if ( $enable_non_latin ) {
921 switch ( $script ) {
922 case 'arabic':
923 return self::unicodeRange( 'ء', 'ي' );
924 case 'cyrillic':
925 return self::unicodeRange( 'А', 'Я' );
926 case 'hebrew':
927 return self::unicodeRange( 'א', 'ת' );
928 case 'greek':
929 return self::unicodeRange( 'Α', 'Ω' );
930 default:
931 return range( 'A', 'Z' );
932 }
933 }
934
935 return range( 'A', 'Z' );
936 }
937
938 public static function get_the_top_most_parent( $term_id ) {
939 while ( $term_id != 0 ) {
940 $parent_id = wp_get_term_taxonomy_parent_id( $term_id, 'doc_category' );
941
942 if ( $parent_id == 0 ) {
943 break;
944 }
945
946 $term_id = $parent_id;
947 }
948 return $term_id;
949 }
950
951 public static function get_highest_docs_term() {
952 $terms = get_terms( [
953 'taxonomy' => 'doc_category', // Change to your desired taxonomy
954 'hide_empty' => true, // Only show terms with posts
955 'orderby' => 'count', // Order by post count
956 'order' => 'DESC', // Descending order
957 'number' => 1 // Get only the top term
958 ] );
959 return isset( $terms[0] ) ? $terms[0] : [];
960 }
961
962 public static function delete_specific_faq_posts_by_faq_category( $term_id ) {
963 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- targeted bulk delete by FAQ category; tax filter is required.
964 $args = [
965 'post_type' => 'betterdocs_faq',
966 'posts_per_page' => -1,
967 'tax_query' => [
968 [
969 'taxonomy' => 'betterdocs_faq_category',
970 'field' => 'id',
971 'terms' => $term_id,
972 'operator' => 'IN'
973 ]
974 ],
975 'fields' => 'ids'
976 ];
977
978 $query = new \WP_Query( $args );
979
980 if ( $query->have_posts() ) {
981 foreach ( $query->posts as $doc_id ) {
982 wp_delete_post( $doc_id, true );
983 }
984 }
985 }
986
987 /**
988 * Function To Normalize Repeater Field For Quick Builder
989 *
990 * @param array $fields
991 * @param array $include_field_keys
992 *
993 * @return array
994 */
995 public static function normalize_repeater_field( $fields, $include_field_keys = [] ) {
996 if( empty( $include_field_keys ) ) {
997 return $fields;
998 }
999
1000 $normalized_fields = [];
1001
1002 foreach( $fields as $field ) {
1003 foreach( $include_field_keys as $field_key ) {
1004 if( ! isset( $normalized_fields[$field_key] ) ) {
1005 $normalized_fields[$field_key] = isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [];
1006 } else {
1007 array_push( $normalized_fields[$field_key], ...( isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [] ) );
1008 $normalized_fields[$field_key] = array_unique( $normalized_fields[$field_key] );
1009 }
1010 }
1011 }
1012
1013 return $normalized_fields;
1014 }
1015
1016 public static function get_local_plugin_data( $basename = '' ) {
1017 if ( empty( $basename ) ) {
1018 return false;
1019 }
1020
1021 if ( !function_exists( 'get_plugins' ) ) {
1022 include_once ABSPATH . 'wp-admin/includes/plugin.php';
1023 }
1024
1025 $plugins = get_plugins();
1026
1027 if ( !isset( $plugins[ $basename ] ) ) {
1028 return false;
1029 }
1030
1031 return $plugins[ $basename ];
1032 }
1033
1034 /**
1035 * Get default file icon based on programming language
1036 *
1037 * @param string $language Programming language identifier
1038 * @return string Emoji icon for the language
1039 */
1040 public static function get_file_icon_by_language( $language ) {
1041 $icons = [
1042 'javascript' => '📄',
1043 'typescript' => '📘',
1044 'jsx' => '⚛️',
1045 'tsx' => '⚛️',
1046 'html' => '🌐',
1047 'css' => '🎨',
1048 'scss' => '🎨',
1049 'sass' => '🎨',
1050 'less' => '🎨',
1051 'php' => '🐘',
1052 'python' => '🐍',
1053 'java' => '',
1054 'csharp' => '🔷',
1055 'cpp' => '⚙️',
1056 'c' => '⚙️',
1057 'ruby' => '💎',
1058 'go' => '🐹',
1059 'rust' => '🦀',
1060 'swift' => '🦉',
1061 'kotlin' => '🎯',
1062 'sql' => '🗃️',
1063 'json' => '📋',
1064 'yaml' => '📋',
1065 'xml' => '📄',
1066 'markdown' => '📝',
1067 'bash' => '💻',
1068 'shell' => '💻',
1069 'powershell' => '💻',
1070 'dockerfile' => '🐳',
1071 ];
1072
1073 return isset( $icons[$language] ) ? $icons[$language] : '📄';
1074 }
1075
1076 /**
1077 * Check if AI Chatbot is enabled
1078 *
1079 * @return bool
1080 */
1081 public function is_ai_chatbot_enabled() {
1082 $chatbot_active = is_plugin_active( 'betterdocs-ai-chatbot/betterdocs-ai-chatbot.php' );
1083 $chatbot_license_valid = get_option( 'betterdocs_chatbot_software__license_status' ) === 'valid';
1084 $chatbot_enabled = betterdocs()->settings->get( 'enable_ai_chatbot', false );
1085
1086 // AI Search Suggestions are enabled if all conditions are met
1087 return $chatbot_active && $chatbot_license_valid && $chatbot_enabled;
1088 }
1089
1090 /**
1091 * Check if tags are enabled and post has tags
1092 *
1093 * @return bool
1094 */
1095 public function is_tag_enabled() {
1096 global $post;
1097 $product_terms = wp_get_object_terms( $post->ID, 'doc_tag' );
1098 $enable_tags = betterdocs()->settings->get( 'enable_tags', false );
1099 return ! empty( $product_terms ) && $enable_tags;
1100 }
1101
1102 /**
1103 * Check if AI Search Suggestions are enabled
1104 *
1105 * @return bool
1106 */
1107 public function is_ai_search_suggestions_enabled() {
1108 $ai_search_suggestions_active = is_plugin_active( 'betterdocs-ai-search-suggestions/betterdocs-ai-search-suggestions.php' );
1109 $ai_search_suggestions_license_valid = get_option( 'betterdocs_ai_search_suggestions_software__license_status' ) === 'valid';
1110 $ai_search_suggestions_enabled = betterdocs()->settings->get( 'enable_ai_powered_search', false );
1111
1112 return $ai_search_suggestions_active && $ai_search_suggestions_license_valid && $ai_search_suggestions_enabled;
1113 }
1114
1115 /**
1116 * Get the maximum order value from the 'doc_category_order' term meta
1117 *
1118 * @return int
1119 */
1120 public static function get_max_doc_category_order_from_term_meta() {
1121 global $wpdb;
1122 $sql = $wpdb->prepare( "SELECT MAX(CAST(meta_value AS UNSIGNED)) AS max FROM {$wpdb->termmeta} WHERE meta_key = %s ", 'doc_category_order' );
1123 $result = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above.
1124 return $result;
1125 }
1126 }
1127