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

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