PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 3.8.9
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v3.8.9
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Utils/Helper.php +26 -1240 4.9.03.8.9 View file →
@@ -1,87 +1,12 @@
1 1 <?php
2 2
3 3 namespace WPDeveloper\BetterDocs\Utils;
4 4
5 -// Helper utilities mix per-language URL detection (read-only $_GET reads),
6 -// dynamic alphabet-letter / glossary queries composed via $wpdb->prepare,
7 -// and meta-key term lookups that are core to BetterDocs functionality.
8 -// phpcs:disable WordPress.Security.NonceVerification.Recommended
9 -// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
10 -// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
11 -// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
12 -// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
13 -// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
14 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
15 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
16 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
17 -
18 5 use function BetterLinksPro\Dependencies\GuzzleHttp\json_decode;
19 -use function WPML\PHP\Logger\error;
20 6
21 7 class Helper extends Base {
22 8
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 9 public static function get_plugins( $plugin_basename = null ) {
85 10 if ( ! function_exists( 'get_plugins' ) ) {
86 11 include_once ABSPATH . 'wp-admin/includes/plugin.php';
87 12 }
@@ -97,41 +22,8 @@
97 22
98 23 return is_plugin_active( $plugin_basename );
99 24 }
100 25
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 26 public static function get_tax( $tax = '' ) {
135 27 global $wp_query;
136 28
137 29 if ( is_tax( 'knowledge_base' ) ) {
@@ -190,14 +82,10 @@
190 82 * @return string
191 83 */
192 84 public static function admin_tab() {
193 85 $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';
86 + if ( isset( $_GET['mode'], $_GET['page'] ) && $_GET['page'] === 'betterdocs-admin' && ! empty( $_GET['mode'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
87 + $admin_ui = $_GET['mode'] === 'grid' ? 'grid' : 'list'; // phpcs:ignore WordPress.Security.NonceVerification.Missing
200 88 }
201 89
202 90 return $admin_ui;
203 91 }
@@ -298,824 +186,19 @@
298 186 }
299 187 return $excerpt;
300 188 }
301 189
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
190 + public static function get_current_letter_docs( $current_letter, $limit = '' ) {
636 191 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 192
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 193 // Check if the encyclopedia_prefix parameter is set
1089 194
1090 195 $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1091 196 $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1092 197 $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 198
1096 199 // if($enable_glossaries && $encyclopeia_suorce === 'glossaries'){
1097 200 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 201 $query = "
1119 202 SELECT
1120 203 t.term_id,
1121 204 t.name AS post_title,
@@ -1132,51 +215,27 @@
1132 215 INNER JOIN
1133 216 {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1134 217 LEFT JOIN
1135 218 {$wpdb->termmeta} m ON t.term_id = m.term_id
1136 - $lang_join
1137 219 WHERE
1138 220 tt.taxonomy = 'glossaries'
1139 221 AND
1140 - SUBSTRING(t.name, 1, 1) = %s
1141 - $lang_where
222 + LEFT(t.name, 1) = %s
1142 223 GROUP BY
1143 224 t.term_id
1144 225 ORDER BY
1145 226 t.name ASC
1146 - $limit_sql
227 + $limit
1147 228 ";
1148 229 } 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 230 $query = "
1170 231 SELECT ID, post_title, post_excerpt, guid, post_content
1171 232 FROM {$wpdb->posts}
1172 - $lang_join
1173 233 WHERE post_type = 'docs'
1174 234 AND post_status = 'publish'
1175 - AND SUBSTRING(post_title, 1, 1) = %s
1176 - $lang_where
235 + AND LEFT(post_title, 1) = %s
1177 236 ORDER BY post_date DESC
1178 - $limit_sql
237 + $limit
1179 238 ";
1180 239 }
1181 240
1182 241 $current_letter_docs = $wpdb->get_results( $wpdb->prepare( $query, $current_letter ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
@@ -1194,9 +253,9 @@
1194 253 $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1195 254 $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1196 255
1197 256 foreach ( $letters as $letter ) {
1198 - $posts = self::get_current_letter_docs( $letter, $limit );
257 + $posts = self::get_current_letter_docs( $letter, "LIMIT $limit" );
1199 258
1200 259 if ( is_array( $posts ) && ! empty( $posts ) ) {
1201 260 foreach ( $posts as $post ) {
1202 261 $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : '';
@@ -1202,44 +261,26 @@
1202 261 $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : '';
1203 262 $glossary_term_description = $description['glossary_term_description'] ?? '';
1204 263
1205 264 // 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'] ?? '' );
265 + $glossary_term_description = strip_tags( $glossary_term_description );
266 + $post_excerpt = strip_tags( $post['post_excerpt'] ?? '' );
1208 267
1209 268 // Prepare post data
269 + $post_data = [
270 + 'id' => $post['ID'] ?? '',
271 + 'post_title' => $post['post_title'] ?? '',
272 + 'post_excerpt' => ! empty( $post_excerpt )
273 + ? $post_excerpt
274 + : ( ! empty( $glossary_term_description )
275 + ? self::get_custom_excerpt( $glossary_term_description, 15 )
276 + : self::get_custom_excerpt( strip_tags( $post['post_content'] ?? '' ), 15 ) )
277 + ];
278 +
1210 279 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 - ];
280 + $post_data['permalink'] = isset( $post['slug'] ) ? get_term_link( $post['slug'], 'glossaries' ) : '';
1232 281 } 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 - ];
282 + $post_data['permalink'] = isset( $post['ID'] ) ? get_the_permalink( $post['ID'] ) : '';
1242 283 }
1243 284
1244 285 $docs_by_letter[$letter][] = $post_data;
1245 286 }
@@ -1251,35 +292,13 @@
1251 292
1252 293 public static function get_glossaries() {
1253 294 global $wpdb;
1254 295
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 296 $query = "
1276 297 SELECT t.name
1277 298 FROM {$wpdb->terms} t
1278 299 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1279 - $lang_join
1280 300 WHERE tt.taxonomy = 'glossaries'
1281 - $lang_where
1282 301 ORDER BY t.name ASC
1283 302 ";
1284 303
1285 304 $glossaries = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
@@ -1293,12 +312,8 @@
1293 312 * @param string $layout
1294 313 * @return string $layout
1295 314 */
1296 315 public static function determine_search_layout( $layout ) {
1297 - if ( $layout ) {
1298 - return $layout;
1299 - }
1300 -
1301 316 $search_layout = betterdocs()->customizer->defaults->get( 'betterdocs_search_layout_select' );
1302 317 $docs_layout = betterdocs()->customizer->defaults->get( 'betterdocs_docs_layout_select' );
1303 318 $archive_page_layout = betterdocs()->customizer->defaults->get( 'betterdocs_archive_layout_select' );
1304 319 $single_layout = betterdocs()->customizer->defaults->get( 'betterdocs_single_layout_select' );
@@ -1311,11 +326,11 @@
1311 326 }
1312 327 } else if ( is_tax( 'doc_tag' ) && ! $search_layout ) {
1313 328 $layout = 'layout-1';
1314 329 } else if ( is_tax( 'doc_category' ) ) {
1315 - if ( $archive_page_layout != 'layout-7' && $archive_page_layout != 'layout-8' && ! $search_layout ) {
330 + if ( $archive_page_layout != 'layout-7' && ! $search_layout ) {
1316 331 $layout = 'layout-1';
1317 - } else if ( ( $archive_page_layout == 'layout-7' && ! $search_layout ) || ( $archive_page_layout == 'layout-8' && ! $search_layout ) ) {
332 + } else if ( $archive_page_layout == 'layout-7' && ! $search_layout ) {
1318 333 $layout = 'layout-2';
1319 334 }
1320 335 } else if ( is_singular( 'docs' ) ) {
1321 336 if ( $single_layout != 'layout-8' && $single_layout != 'layout-9' && ! $search_layout ) {
@@ -1386,22 +401,15 @@
1386 401 ] );
1387 402 return isset( $terms[0] ) ? $terms[0] : [];
1388 403 }
1389 404
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.
405 + public static function delete_specific_faq_posts_by_faq_category( $term_id ) {
1392 406 $args = [
1393 407 'post_type' => 'betterdocs_faq',
1394 408 '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 409 'tax_query' => [
1402 410 [
1403 - 'taxonomy' => $taxonomy,
411 + 'taxonomy' => 'betterdocs_faq_category',
1404 412 'field' => 'id',
1405 413 'terms' => $term_id,
1406 414 'operator' => 'IN'
1407 415 ]
@@ -1416,227 +424,5 @@
1416 424 wp_delete_post( $doc_id, true );
1417 425 }
1418 426 }
1419 427 }
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 428 }