PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 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 All 201 releases
← All changes | includes/Utils/Helper.php +490 -205 4.6.2 → 4.9.3 View file →
@@ -20,10 +20,16 @@
20 20
21 21 class Helper extends Base {
22 22
23 23 /**
24 - * Mask an API key for safe display: first 3 chars + 8 asterisks + last 4 chars.
25 - * Fixed asterisk count avoids leaking the real key length.
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.
26 32 */
27 33 public static function mask_api_key( $key ) {
28 34 if ( ! is_string( $key ) || $key === '' ) {
29 35 return '';
@@ -28,8 +34,21 @@
28 34 if ( ! is_string( $key ) || $key === '' ) {
29 35 return '';
30 36 }
31 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 +
32 51 if ( strlen( $key ) < 8 ) {
33 52 return str_repeat( '*', strlen( $key ) );
34 53 }
35 54 return substr( $key, 0, 3 ) . str_repeat( '*', 8 ) . substr( $key, -4 );
@@ -34,8 +53,115 @@
34 53 }
35 54 return substr( $key, 0, 3 ) . str_repeat( '*', 8 ) . substr( $key, -4 );
36 55 }
37 56
57 + /**
58 + * Drop the scheme + host from an absolute URL so it stays same-origin.
59 + *
60 + * Returns the ROOT-RELATIVE form of $url ("/wp-admin/admin-ajax.php",
61 + * "/wp-json/wp/v2/docs?search=foo", "/subdir/..." for a WP install in a
62 + * sub-directory) so the browser resolves it against the CURRENT page's origin
63 + * instead of the host baked into the URL. Path, query and fragment are kept
64 + * verbatim — only scheme+host are dropped — so sub-directory, multisite and
65 + * plain-permalink (?rest_route=) URLs all survive intact. A URL with no usable
66 + * path is returned unchanged.
67 + *
68 + * This is the origin-agnostic primitive behind frontend_ajax_url(); callers
69 + * whose output is embedded on a THIRD-PARTY site (e.g. the Instant Answer
70 + * cross-domain snippet) must keep the absolute URL and simply not call this.
71 + *
72 + * @param string $url Absolute URL.
73 + * @return string Root-relative URL, or $url unchanged when it has no path.
74 + */
75 + public static function relative_url( $url ) {
76 + if ( ! is_string( $url ) || $url === '' ) {
77 + return $url;
78 + }
79 +
80 + $parts = wp_parse_url( $url );
81 +
82 + if ( ! is_array( $parts ) || empty( $parts['path'] ) ) {
83 + return $url;
84 + }
85 +
86 + $relative = $parts['path'];
87 +
88 + if ( isset( $parts['query'] ) && $parts['query'] !== '' ) {
89 + $relative .= '?' . $parts['query'];
90 + }
91 +
92 + if ( isset( $parts['fragment'] ) && $parts['fragment'] !== '' ) {
93 + $relative .= '#' . $parts['fragment'];
94 + }
95 +
96 + return $relative;
97 + }
98 +
99 + /**
100 + * Same-origin admin-ajax URL for front-end requests (live search, etc.).
101 + *
102 + * admin_url() always resolves to the configured Site Address host, so when a
103 + * knowledge base is served on a HOST different from WP's Site Address — a
104 + * subdomain (e.g. faq.example.com), a domain alias, or a reverse proxy — the
105 + * AJAX request becomes cross-origin and is silently blocked or redirected by
106 + * the browser (the live search then returns no results).
107 + *
108 + * Emitting a ROOT-RELATIVE path ("/wp-admin/admin-ajax.php", or
109 + * "/subdir/wp-admin/admin-ajax.php" for a WP install in a sub-directory) lets
110 + * the browser resolve it against the CURRENT page's origin, so the request
111 + * always stays same-origin regardless of the Site Address. The path component
112 + * is taken verbatim from admin_url(), so sub-directory and multisite install
113 + * paths are preserved; only the scheme+host is dropped. When the parsed path
114 + * is empty, or in the admin area, the absolute URL is returned unchanged so
115 + * nothing else is affected.
116 + *
117 + * Override with the `betterdocs_frontend_ajax_url` filter if a site genuinely
118 + * needs an absolute or a different endpoint.
119 + *
120 + * @return string Root-relative admin-ajax path on the front end, else the absolute URL.
121 + */
122 + public static function frontend_ajax_url() {
123 + $ajax_url = admin_url( 'admin-ajax.php' );
124 +
125 + if ( ! is_admin() ) {
126 + $ajax_url = self::relative_url( $ajax_url );
127 + }
128 +
129 + /**
130 + * Filter the front-end admin-ajax URL used by BetterDocs live search.
131 + *
132 + * @param string $ajax_url Root-relative path (front end) or absolute URL.
133 + */
134 + return apply_filters( 'betterdocs_frontend_ajax_url', $ajax_url );
135 + }
136 +
137 + /**
138 + * Resolve the WPML-translated base slug of a taxonomy for the CURRENT language.
139 + *
140 + * WPML registers each translatable taxonomy's rewrite slug as a string named
141 + * "URL <taxonomy> tax slug" in the "WordPress" domain (e.g. "URL doc_tag tax slug").
142 + * BetterDocs stores only the default-language slug in its settings, so routing and
143 + * term links must read the translated value back here. Returns the trimmed default
144 + * slug unchanged when WPML is inactive or the string has no translation.
145 + *
146 + * @param string $taxonomy Taxonomy key, e.g. 'doc_tag'.
147 + * @param string $default_slug Default-language base slug from settings.
148 + * @return string Translated base slug for the active language (falls back to default).
149 + */
150 + public static function wpml_translated_tax_slug( $taxonomy, $default_slug ) {
151 + $default_slug = trim( (string) $default_slug, '/' );
152 +
153 + if ( $default_slug === '' || ! has_filter( 'wpml_translate_single_string' ) ) {
154 + return $default_slug;
155 + }
156 +
157 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML-owned filter; name must be used verbatim.
158 + $translated = apply_filters( 'wpml_translate_single_string', $default_slug, 'WordPress', 'URL ' . $taxonomy . ' tax slug' );
159 + $translated = trim( (string) $translated, '/' );
160 +
161 + return $translated !== '' ? $translated : $default_slug;
162 + }
163 +
38 164 public static function get_plugins( $plugin_basename = null ) {
39 165 if ( ! function_exists( 'get_plugins' ) ) {
40 166 include_once ABSPATH . 'wp-admin/includes/plugin.php';
41 167 }
@@ -301,8 +427,58 @@
301 427 ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) );
302 428 }
303 429
304 430 /**
431 + * Configured/active languages from whichever multilingual plugin is present.
432 + *
433 + * Returns a list of { value, label } pairs (language code + display name).
434 + * Used to populate the optional language selector in the Write-with-AI modal;
435 + * returns an empty array when no multilingual plugin is active so the
436 + * selector stays hidden. Mirrors the Pro cross-domain language options.
437 + *
438 + * @return array<int,array{value:string,label:string}>
439 + */
440 + public static function get_active_languages() {
441 + $options = array();
442 +
443 + // WPML
444 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
445 + global $sitepress;
446 + if ( $sitepress && method_exists( $sitepress, 'get_active_languages' ) ) {
447 + $active_languages = $sitepress->get_active_languages();
448 + if ( is_array( $active_languages ) ) {
449 + foreach ( $active_languages as $code => $lang ) {
450 + $options[] = array(
451 + 'value' => (string) $code,
452 + 'label' => isset( $lang['native_name'] ) ? $lang['native_name'] : (string) $code,
453 + );
454 + }
455 + }
456 + }
457 + } elseif ( function_exists( 'pll_languages_list' ) ) {
458 + // Polylang
459 + $languages = pll_languages_list( array( 'fields' => array() ) );
460 + if ( is_array( $languages ) ) {
461 + foreach ( $languages as $lang ) {
462 + if ( is_object( $lang ) && isset( $lang->slug ) ) {
463 + $options[] = array(
464 + 'value' => (string) $lang->slug,
465 + 'label' => isset( $lang->name ) ? $lang->name : (string) $lang->slug,
466 + );
467 + }
468 + }
469 + }
470 + }
471 +
472 + /**
473 + * Filter the language options exposed to the Write-with-AI modal.
474 + *
475 + * @param array $options List of { value, label } language pairs.
476 + */
477 + return apply_filters( 'betterdocs_active_languages', $options );
478 + }
479 +
480 + /**
305 481 * Check if we should apply language filtering
306 482 * Only apply on frontend or when specifically requested
307 483 *
308 484 * @return bool
@@ -982,178 +1158,15 @@
982 1158 ] );
983 1159 }
984 1160 }
985 1161
986 - public static function get_current_letter_docs( $current_letter, $limit = 0 ) {
987 - global $wpdb;
1162 + // NOTE: get_current_letter_docs() and docs_sort_by_letter() used to live here.
1163 + // Encyclopedia is a Pro-only feature and these had no callers in Free at all,
1164 + // so they now ship as BetterDocsPro\Utils\EncyclopediaQuery. The generic
1165 + // helpers they lean on (get_current_language, is_multilingual_active,
1166 + // should_apply_language_filtering, get_custom_excerpt) stay here and are
1167 + // called through this class from Pro.
988 1168
989 - $limit = absint( $limit );
990 - $limit_sql = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
991 -
992 - // Check if the encyclopedia_prefix parameter is set
993 -
994 - $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
995 - $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
996 - $encyclopedia_root_slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopdia' );
997 - // Sanitize values that may be interpolated into raw SQL fragments below.
998 - $encyclopedia_root_slug = sanitize_title( $encyclopedia_root_slug );
999 -
1000 - // if($enable_glossaries && $encyclopeia_suorce === 'glossaries'){
1001 - if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1002 - $lang_join = '';
1003 - $lang_where = '';
1004 -
1005 - // Add language filtering if multilingual plugin is active and we should apply filtering
1006 - $current_language = self::get_current_language();
1007 - if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1008 - // Restrict language code to a safe character set before SQL interpolation.
1009 - $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1010 - // For WPML, use icl_translations table
1011 - if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1012 - $lang_join = " LEFT JOIN {$wpdb->prefix}icl_translations icl_t ON icl_t.element_id = t.term_id AND icl_t.element_type = 'tax_glossaries'";
1013 - $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1014 - }
1015 - // For Polylang, use term_relationships with language taxonomy
1016 - elseif ( function_exists( 'pll_current_language' ) ) {
1017 - $lang_join = " LEFT JOIN {$wpdb->term_relationships} tr ON t.term_id = tr.object_id LEFT JOIN {$wpdb->term_taxonomy} tt_lang ON tr.term_taxonomy_id = tt_lang.term_taxonomy_id AND tt_lang.taxonomy = 'language' LEFT JOIN {$wpdb->terms} t_lang ON tt_lang.term_id = t_lang.term_id";
1018 - $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1019 - }
1020 - }
1021 -
1022 - $query = "
1023 - SELECT
1024 - t.term_id,
1025 - t.name AS post_title,
1026 - t.slug as slug,
1027 - '' AS post_excerpt,
1028 - CONCAT('" . get_home_url() . "/$encyclopedia_root_slug/', t.slug) AS permalink,
1029 - tt.description AS post_content,
1030 - JSON_OBJECT(
1031 - 'status', COALESCE(MAX(CASE WHEN m.meta_key = 'status' THEN m.meta_value END), ''),
1032 - 'glossary_term_description', COALESCE(MAX(CASE WHEN m.meta_key = 'glossary_term_description' THEN m.meta_value END), '')
1033 - ) AS meta_data
1034 - FROM
1035 - {$wpdb->terms} t
1036 - INNER JOIN
1037 - {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1038 - LEFT JOIN
1039 - {$wpdb->termmeta} m ON t.term_id = m.term_id
1040 - $lang_join
1041 - WHERE
1042 - tt.taxonomy = 'glossaries'
1043 - AND
1044 - SUBSTRING(t.name, 1, 1) = %s
1045 - $lang_where
1046 - GROUP BY
1047 - t.term_id
1048 - ORDER BY
1049 - t.name ASC
1050 - $limit_sql
1051 - ";
1052 - } else {
1053 - $lang_join = '';
1054 - $lang_where = '';
1055 -
1056 - // Add language filtering for docs if multilingual plugin is active and we should apply filtering
1057 - $current_language = self::get_current_language();
1058 - if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1059 - // Restrict language code to a safe character set before SQL interpolation.
1060 - $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1061 - // For WPML, use icl_translations table
1062 - if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1063 - $lang_join = " LEFT JOIN {$wpdb->prefix}icl_translations icl_t ON icl_t.element_id = {$wpdb->posts}.ID AND icl_t.element_type = 'post_docs'";
1064 - $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1065 - }
1066 - // For Polylang, use term_relationships with language taxonomy
1067 - elseif ( function_exists( 'pll_current_language' ) ) {
1068 - $lang_join = " LEFT JOIN {$wpdb->term_relationships} tr ON {$wpdb->posts}.ID = tr.object_id LEFT JOIN {$wpdb->term_taxonomy} tt_lang ON tr.term_taxonomy_id = tt_lang.term_taxonomy_id AND tt_lang.taxonomy = 'language' LEFT JOIN {$wpdb->terms} t_lang ON tt_lang.term_id = t_lang.term_id";
1069 - $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1070 - }
1071 - }
1072 -
1073 - $query = "
1074 - SELECT ID, post_title, post_excerpt, guid, post_content
1075 - FROM {$wpdb->posts}
1076 - $lang_join
1077 - WHERE post_type = 'docs'
1078 - AND post_status = 'publish'
1079 - AND SUBSTRING(post_title, 1, 1) = %s
1080 - $lang_where
1081 - ORDER BY post_date DESC
1082 - $limit_sql
1083 - ";
1084 - }
1085 -
1086 - $current_letter_docs = $wpdb->get_results( $wpdb->prepare( $query, $current_letter ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1087 -
1088 - return $current_letter_docs;
1089 - }
1090 -
1091 - public static function docs_sort_by_letter( $limit = 10 ) {
1092 - global $wpdb;
1093 - $enable_non_latin = betterdocs()->settings->get( 'encyclopedia_enable_non_latin' );
1094 - $script = betterdocs()->settings->get( 'encyclopedia_non_latin_option' );
1095 - $letters = Helper::get_character_range( $enable_non_latin, $script );
1096 -
1097 - $docs_by_letter = [];
1098 - $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1099 - $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1100 -
1101 - foreach ( $letters as $letter ) {
1102 - $posts = self::get_current_letter_docs( $letter, $limit );
1103 -
1104 - if ( is_array( $posts ) && ! empty( $posts ) ) {
1105 - foreach ( $posts as $post ) {
1106 - $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : '';
1107 - $glossary_term_description = $description['glossary_term_description'] ?? '';
1108 -
1109 - // Remove any <p> tags or other unwanted HTML tags
1110 - $glossary_term_description = wp_strip_all_tags( $glossary_term_description );
1111 - $post_excerpt = wp_strip_all_tags( $post['post_excerpt'] ?? '' );
1112 -
1113 - // Prepare post data
1114 - if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1115 - // For glossaries
1116 - $permalink = '';
1117 -
1118 - if ( isset( $post['slug'] ) ) {
1119 - $term_link = get_term_link( $post['slug'], 'glossaries' );
1120 -
1121 - if ( ! is_wp_error( $term_link ) ) {
1122 - $permalink = $term_link;
1123 - }
1124 - }
1125 -
1126 - $post_data = [
1127 - 'id' => $post['term_id'] ?? '',
1128 - 'post_title' => $post['post_title'] ?? '',
1129 - 'post_excerpt' => ! empty( $post_excerpt )
1130 - ? $post_excerpt
1131 - : ( ! empty( $glossary_term_description )
1132 - ? self::get_custom_excerpt( $glossary_term_description, 15 )
1133 - : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ) ),
1134 - 'permalink' => $permalink,
1135 - ];
1136 - } else {
1137 - // For docs
1138 - $post_data = [
1139 - 'id' => $post['ID'] ?? '',
1140 - 'post_title' => $post['post_title'] ?? '',
1141 - 'post_excerpt' => ! empty( $post_excerpt )
1142 - ? $post_excerpt
1143 - : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ),
1144 - 'permalink' => isset( $post['ID'] ) ? get_the_permalink( $post['ID'] ) : ''
1145 - ];
1146 - }
1147 -
1148 - $docs_by_letter[$letter][] = $post_data;
1149 - }
1150 - }
1151 - }
1152 -
1153 - return $docs_by_letter;
1154 - }
1155 -
1156 1169 public static function get_glossaries() {
1157 1170 global $wpdb;
1158 1171
1159 1172 $lang_join = '';
@@ -1230,44 +1243,14 @@
1230 1243 }
1231 1244
1232 1245 return $layout;
1233 1246 }
1234 - public static function mb_ord_fallback( $char ) {
1235 - $code = unpack( 'N', mb_convert_encoding( $char, 'UCS-4BE', 'UTF-8' ) );
1236 - return $code[1];
1237 - }
1238 1247
1239 - public static function mb_chr_fallback( $code ) {
1240 - return mb_convert_encoding( pack( 'N', $code ), 'UTF-8', 'UCS-4BE' );
1241 - }
1248 + // NOTE: the alphabet-range helpers (get_character_range, unicodeRange,
1249 + // mb_ord_fallback, mb_chr_fallback) moved to BetterDocsPro\Utils\EncyclopediaQuery
1250 + // along with the two methods above — same reason: Encyclopedia is Pro-only
1251 + // and nothing in Free ever called them.
1242 1252
1243 - public static function unicodeRange( $start, $end ) {
1244 - $range = [];
1245 - for ( $i = self::mb_ord_fallback( $start ); $i <= self::mb_ord_fallback( $end ); $i++ ) {
1246 - $range[] = self::mb_chr_fallback( $i );
1247 - }
1248 - return $range;
1249 - }
1250 -
1251 - public static function get_character_range( $enable_non_latin, $script ) {
1252 - if ( $enable_non_latin ) {
1253 - switch ( $script ) {
1254 - case 'arabic':
1255 - return self::unicodeRange( 'ء', 'ي' );
1256 - case 'cyrillic':
1257 - return self::unicodeRange( 'А', 'Я' );
1258 - case 'hebrew':
1259 - return self::unicodeRange( 'א', 'ת' );
1260 - case 'greek':
1261 - return self::unicodeRange( 'Α', 'Ω' );
1262 - default:
1263 - return range( 'A', 'Z' );
1264 - }
1265 - }
1266 -
1267 - return range( 'A', 'Z' );
1268 - }
1269 -
1270 1253 public static function get_the_top_most_parent( $term_id ) {
1271 1254 while ( $term_id != 0 ) {
1272 1255 $parent_id = wp_get_term_taxonomy_parent_id( $term_id, 'doc_category' );
1273 1256
@@ -1401,8 +1384,9 @@
1401 1384 'json' => '📋',
1402 1385 'yaml' => '📋',
1403 1386 'xml' => '📄',
1404 1387 'markdown' => '📝',
1388 + 'curl' => '💻',
1405 1389 'bash' => '💻',
1406 1390 'shell' => '💻',
1407 1391 'powershell' => '💻',
1408 1392 'dockerfile' => '🐳',
@@ -1410,8 +1394,89 @@
1410 1394
1411 1395 return isset( $icons[$language] ) ? $icons[$language] : '📄';
1412 1396 }
1413 1397
1398 + /**
1399 + * Echo the copy-to-clipboard button used by the Code Snippet and Code
1400 + * Snippet Tab templates.
1401 + *
1402 + * Both icons ship in the markup and CSS cross-fades between them on
1403 + * `.is-copied`, so the frontend script never rewrites the SVG. The tooltip
1404 + * carries its own strings as data attributes so the script can swap
1405 + * "Copy" → "Copied!" without hard-coding English.
1406 + *
1407 + * @return void
1408 + */
1409 + public static function code_snippet_copy_button() {
1410 + ?>
1411 + <div class="betterdocs-code-snippet-copy-container">
1412 + <button class="betterdocs-code-snippet-copy-button"
1413 + type="button"
1414 + aria-label="<?php esc_attr_e( 'Copy code to clipboard', 'betterdocs' ); ?>">
1415 + <span class="betterdocs-code-snippet-copy-icon" aria-hidden="true">
1416 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1417 + <rect x="9" y="9" width="12.5" height="12.5" rx="3" stroke="currentColor" stroke-width="1.7"/>
1418 + <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"/>
1419 + </svg>
1420 + </span>
1421 + <span class="betterdocs-code-snippet-copied-icon" aria-hidden="true">
1422 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1423 + <path d="M20 6.5 9.5 17 4 11.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
1424 + </svg>
1425 + </span>
1426 + </button>
1427 + <span class="betterdocs-code-snippet-tooltip"
1428 + role="status"
1429 + data-copy-label="<?php esc_attr_e( 'Copy', 'betterdocs' ); ?>"
1430 + data-copied-label="<?php esc_attr_e( 'Copied!', 'betterdocs' ); ?>"
1431 + data-error-label="<?php esc_attr_e( 'Copy failed', 'betterdocs' ); ?>"><?php esc_html_e( 'Copy', 'betterdocs' ); ?></span>
1432 + </div>
1433 + <?php
1434 + }
1435 +
1436 + /**
1437 + * Human-readable label for a programming-language identifier, used as the
1438 + * language-dropdown label on multi-language code snippets. Mirrors the
1439 + * block's LANGUAGE_OPTIONS; falls back to an upper-cased identifier.
1440 + *
1441 + * @param string $language Programming language identifier
1442 + * @return string
1443 + */
1444 + public static function get_language_label( $language ) {
1445 + $labels = [
1446 + 'javascript' => 'JavaScript',
1447 + 'typescript' => 'TypeScript',
1448 + 'php' => 'PHP',
1449 + 'python' => 'Python',
1450 + 'java' => 'Java',
1451 + 'ruby' => 'Ruby',
1452 + 'curl' => 'cURL',
1453 + 'bash' => 'Bash',
1454 + 'shell' => 'Shell',
1455 + 'json' => 'JSON',
1456 + 'yaml' => 'YAML',
1457 + 'html' => 'HTML',
1458 + 'css' => 'CSS',
1459 + 'scss' => 'SCSS',
1460 + 'sql' => 'SQL',
1461 + 'xml' => 'XML',
1462 + 'cpp' => 'C++',
1463 + 'csharp' => 'C#',
1464 + 'c' => 'C',
1465 + 'go' => 'Go',
1466 + 'rust' => 'Rust',
1467 + 'swift' => 'Swift',
1468 + 'kotlin' => 'Kotlin',
1469 + 'markdown' => 'Markdown'
1470 + ];
1471 +
1472 + if ( isset( $labels[ $language ] ) ) {
1473 + return $labels[ $language ];
1474 + }
1475 +
1476 + return ucwords( str_replace( [ '-', '_' ], ' ', (string) $language ) );
1477 + }
1478 +
1414 1479 /**
1415 1480 * Check if AI Chatbot is enabled
1416 1481 *
1417 1482 * @return bool
@@ -1460,5 +1525,225 @@
1460 1525 $sql = $wpdb->prepare( "SELECT MAX(CAST(meta_value AS UNSIGNED)) AS max FROM {$wpdb->termmeta} WHERE meta_key = %s ", 'doc_category_order' );
1461 1526 $result = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above.
1462 1527 return $result;
1463 1528 }
1529 +
1530 + /**
1531 + * Encyclopedia / glossary query helpers — backward-compatibility shims.
1532 + *
1533 + * These methods were moved out of Free and into BetterDocs Pro's own
1534 + * `EncyclopediaQuery` class as of Pro 4.3.1 (the "move glossaries to Pro"
1535 + * release). BetterDocs Pro 4.3.0 and earlier, however, still call
1536 + * `Helper::get_character_range()` / `get_current_letter_docs()` /
1537 + * `docs_sort_by_letter()` from their encyclopedia blocks, widgets,
1538 + * shortcodes, templates and AJAX callbacks. When a site runs new Free with
1539 + * an older Pro (< 4.3.1) still active, those calls would fatal with
1540 + * "Call to undefined method". Keeping these shims here lets that older Pro
1541 + * keep rendering until it is updated. Pro 4.3.1+ uses its own copy and never
1542 + * touches these, so there is no double-execution or conflict.
1543 + *
1544 + * @deprecated Retained only for BetterDocs Pro < 4.3.1 compatibility.
1545 + */
1546 + public static function get_current_letter_docs( $current_letter, $limit = 0 ) {
1547 + global $wpdb;
1548 +
1549 + $limit = absint( $limit );
1550 + $limit_sql = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
1551 +
1552 + // Check if the encyclopedia_prefix parameter is set
1553 +
1554 + $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1555 + $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1556 + $encyclopedia_root_slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopdia' );
1557 + // Sanitize values that may be interpolated into raw SQL fragments below.
1558 + $encyclopedia_root_slug = sanitize_title( $encyclopedia_root_slug );
1559 +
1560 + // if($enable_glossaries && $encyclopeia_suorce === 'glossaries'){
1561 + if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1562 + $lang_join = '';
1563 + $lang_where = '';
1564 +
1565 + // Add language filtering if multilingual plugin is active and we should apply filtering
1566 + $current_language = self::get_current_language();
1567 + if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1568 + // Restrict language code to a safe character set before SQL interpolation.
1569 + $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1570 + // For WPML, use icl_translations table
1571 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1572 + $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'";
1573 + $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1574 + }
1575 + // For Polylang, use term_relationships with language taxonomy
1576 + elseif ( function_exists( 'pll_current_language' ) ) {
1577 + $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";
1578 + $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1579 + }
1580 + }
1581 +
1582 + $query = "
1583 + SELECT
1584 + t.term_id,
1585 + t.name AS post_title,
1586 + t.slug as slug,
1587 + '' AS post_excerpt,
1588 + CONCAT('" . get_home_url() . "/$encyclopedia_root_slug/', t.slug) AS permalink,
1589 + tt.description AS post_content,
1590 + JSON_OBJECT(
1591 + 'status', COALESCE(MAX(CASE WHEN m.meta_key = 'status' THEN m.meta_value END), ''),
1592 + 'glossary_term_description', COALESCE(MAX(CASE WHEN m.meta_key = 'glossary_term_description' THEN m.meta_value END), '')
1593 + ) AS meta_data
1594 + FROM
1595 + {$wpdb->terms} t
1596 + INNER JOIN
1597 + {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1598 + LEFT JOIN
1599 + {$wpdb->termmeta} m ON t.term_id = m.term_id
1600 + $lang_join
1601 + WHERE
1602 + tt.taxonomy = 'glossaries'
1603 + AND
1604 + SUBSTRING(t.name, 1, 1) = %s
1605 + $lang_where
1606 + GROUP BY
1607 + t.term_id
1608 + ORDER BY
1609 + t.name ASC
1610 + $limit_sql
1611 + ";
1612 + } else {
1613 + $lang_join = '';
1614 + $lang_where = '';
1615 +
1616 + // Add language filtering for docs if multilingual plugin is active and we should apply filtering
1617 + $current_language = self::get_current_language();
1618 + if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1619 + // Restrict language code to a safe character set before SQL interpolation.
1620 + $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1621 + // For WPML, use icl_translations table
1622 + if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1623 + $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'";
1624 + $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1625 + }
1626 + // For Polylang, use term_relationships with language taxonomy
1627 + elseif ( function_exists( 'pll_current_language' ) ) {
1628 + $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";
1629 + $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1630 + }
1631 + }
1632 +
1633 + $query = "
1634 + SELECT ID, post_title, post_excerpt, guid, post_content
1635 + FROM {$wpdb->posts}
1636 + $lang_join
1637 + WHERE post_type = 'docs'
1638 + AND post_status = 'publish'
1639 + AND SUBSTRING(post_title, 1, 1) = %s
1640 + $lang_where
1641 + ORDER BY post_date DESC
1642 + $limit_sql
1643 + ";
1644 + }
1645 +
1646 + $current_letter_docs = $wpdb->get_results( $wpdb->prepare( $query, $current_letter ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1647 +
1648 + return $current_letter_docs;
1649 + }
1650 +
1651 + public static function docs_sort_by_letter( $limit = 10 ) {
1652 + global $wpdb;
1653 + $enable_non_latin = betterdocs()->settings->get( 'encyclopedia_enable_non_latin' );
1654 + $script = betterdocs()->settings->get( 'encyclopedia_non_latin_option' );
1655 + $letters = Helper::get_character_range( $enable_non_latin, $script );
1656 +
1657 + $docs_by_letter = [];
1658 + $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' );
1659 + $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false );
1660 +
1661 + foreach ( $letters as $letter ) {
1662 + $posts = self::get_current_letter_docs( $letter, $limit );
1663 +
1664 + if ( is_array( $posts ) && ! empty( $posts ) ) {
1665 + foreach ( $posts as $post ) {
1666 + $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : '';
1667 + $glossary_term_description = $description['glossary_term_description'] ?? '';
1668 +
1669 + // Remove any <p> tags or other unwanted HTML tags
1670 + $glossary_term_description = wp_strip_all_tags( $glossary_term_description );
1671 + $post_excerpt = wp_strip_all_tags( $post['post_excerpt'] ?? '' );
1672 +
1673 + // Prepare post data
1674 + if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) {
1675 + // For glossaries
1676 + $permalink = '';
1677 +
1678 + if ( isset( $post['slug'] ) ) {
1679 + $term_link = get_term_link( $post['slug'], 'glossaries' );
1680 +
1681 + if ( ! is_wp_error( $term_link ) ) {
1682 + $permalink = $term_link;
1683 + }
1684 + }
1685 +
1686 + $post_data = [
1687 + 'id' => $post['term_id'] ?? '',
1688 + 'post_title' => $post['post_title'] ?? '',
1689 + 'post_excerpt' => ! empty( $post_excerpt )
1690 + ? $post_excerpt
1691 + : ( ! empty( $glossary_term_description )
1692 + ? self::get_custom_excerpt( $glossary_term_description, 15 )
1693 + : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ) ),
1694 + 'permalink' => $permalink,
1695 + ];
1696 + } else {
1697 + // For docs
1698 + $post_data = [
1699 + 'id' => $post['ID'] ?? '',
1700 + 'post_title' => $post['post_title'] ?? '',
1701 + 'post_excerpt' => ! empty( $post_excerpt )
1702 + ? $post_excerpt
1703 + : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ),
1704 + 'permalink' => isset( $post['ID'] ) ? get_the_permalink( $post['ID'] ) : ''
1705 + ];
1706 + }
1707 +
1708 + $docs_by_letter[$letter][] = $post_data;
1709 + }
1710 + }
1711 + }
1712 +
1713 + return $docs_by_letter;
1714 + }
1715 + public static function mb_ord_fallback( $char ) {
1716 + $code = unpack( 'N', mb_convert_encoding( $char, 'UCS-4BE', 'UTF-8' ) );
1717 + return $code[1];
1718 + }
1719 +
1720 + public static function mb_chr_fallback( $code ) {
1721 + return mb_convert_encoding( pack( 'N', $code ), 'UTF-8', 'UCS-4BE' );
1722 + }
1723 + public static function unicodeRange( $start, $end ) {
1724 + $range = [];
1725 + for ( $i = self::mb_ord_fallback( $start ); $i <= self::mb_ord_fallback( $end ); $i++ ) {
1726 + $range[] = self::mb_chr_fallback( $i );
1727 + }
1728 + return $range;
1729 + }
1730 +
1731 + public static function get_character_range( $enable_non_latin, $script ) {
1732 + if ( $enable_non_latin ) {
1733 + switch ( $script ) {
1734 + case 'arabic':
1735 + return self::unicodeRange( 'ء', 'ي' );
1736 + case 'cyrillic':
1737 + return self::unicodeRange( 'А', 'Я' );
1738 + case 'hebrew':
1739 + return self::unicodeRange( 'א', 'ת' );
1740 + case 'greek':
1741 + return self::unicodeRange( 'Α', 'Ω' );
1742 + default:
1743 + return range( 'A', 'Z' );
1744 + }
1745 + }
1746 +
1747 + return range( 'A', 'Z' );
1748 + }
1464 1749 }