| @@ -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 | } |
| @@ -1032,178 +1158,15 @@ | ||
| 1032 | 1158 | ] ); |
| 1033 | 1159 | } |
| 1034 | 1160 | } |
| 1035 | 1161 | |
| 1036 | - public static function get_current_letter_docs( $current_letter, $limit = 0 ) { | |
| 1037 | - 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. | |
| 1038 | 1168 | |
| 1039 | - $limit = absint( $limit ); | |
| 1040 | - $limit_sql = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : ''; | |
| 1041 | - | |
| 1042 | - // Check if the encyclopedia_prefix parameter is set | |
| 1043 | - | |
| 1044 | - $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' ); | |
| 1045 | - $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false ); | |
| 1046 | - $encyclopedia_root_slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopdia' ); | |
| 1047 | - // Sanitize values that may be interpolated into raw SQL fragments below. | |
| 1048 | - $encyclopedia_root_slug = sanitize_title( $encyclopedia_root_slug ); | |
| 1049 | - | |
| 1050 | - // if($enable_glossaries && $encyclopeia_suorce === 'glossaries'){ | |
| 1051 | - if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) { | |
| 1052 | - $lang_join = ''; | |
| 1053 | - $lang_where = ''; | |
| 1054 | - | |
| 1055 | - // Add language filtering if multilingual plugin is active and we should apply filtering | |
| 1056 | - $current_language = self::get_current_language(); | |
| 1057 | - if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) { | |
| 1058 | - // Restrict language code to a safe character set before SQL interpolation. | |
| 1059 | - $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language ); | |
| 1060 | - // For WPML, use icl_translations table | |
| 1061 | - if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) { | |
| 1062 | - $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'"; | |
| 1063 | - $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)"; | |
| 1064 | - } | |
| 1065 | - // For Polylang, use term_relationships with language taxonomy | |
| 1066 | - elseif ( function_exists( 'pll_current_language' ) ) { | |
| 1067 | - $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"; | |
| 1068 | - $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)"; | |
| 1069 | - } | |
| 1070 | - } | |
| 1071 | - | |
| 1072 | - $query = " | |
| 1073 | - SELECT | |
| 1074 | - t.term_id, | |
| 1075 | - t.name AS post_title, | |
| 1076 | - t.slug as slug, | |
| 1077 | - '' AS post_excerpt, | |
| 1078 | - CONCAT('" . get_home_url() . "/$encyclopedia_root_slug/', t.slug) AS permalink, | |
| 1079 | - tt.description AS post_content, | |
| 1080 | - JSON_OBJECT( | |
| 1081 | - 'status', COALESCE(MAX(CASE WHEN m.meta_key = 'status' THEN m.meta_value END), ''), | |
| 1082 | - 'glossary_term_description', COALESCE(MAX(CASE WHEN m.meta_key = 'glossary_term_description' THEN m.meta_value END), '') | |
| 1083 | - ) AS meta_data | |
| 1084 | - FROM | |
| 1085 | - {$wpdb->terms} t | |
| 1086 | - INNER JOIN | |
| 1087 | - {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id | |
| 1088 | - LEFT JOIN | |
| 1089 | - {$wpdb->termmeta} m ON t.term_id = m.term_id | |
| 1090 | - $lang_join | |
| 1091 | - WHERE | |
| 1092 | - tt.taxonomy = 'glossaries' | |
| 1093 | - AND | |
| 1094 | - SUBSTRING(t.name, 1, 1) = %s | |
| 1095 | - $lang_where | |
| 1096 | - GROUP BY | |
| 1097 | - t.term_id | |
| 1098 | - ORDER BY | |
| 1099 | - t.name ASC | |
| 1100 | - $limit_sql | |
| 1101 | - "; | |
| 1102 | - } else { | |
| 1103 | - $lang_join = ''; | |
| 1104 | - $lang_where = ''; | |
| 1105 | - | |
| 1106 | - // Add language filtering for docs if multilingual plugin is active and we should apply filtering | |
| 1107 | - $current_language = self::get_current_language(); | |
| 1108 | - if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) { | |
| 1109 | - // Restrict language code to a safe character set before SQL interpolation. | |
| 1110 | - $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language ); | |
| 1111 | - // For WPML, use icl_translations table | |
| 1112 | - if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) { | |
| 1113 | - $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'"; | |
| 1114 | - $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)"; | |
| 1115 | - } | |
| 1116 | - // For Polylang, use term_relationships with language taxonomy | |
| 1117 | - elseif ( function_exists( 'pll_current_language' ) ) { | |
| 1118 | - $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"; | |
| 1119 | - $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)"; | |
| 1120 | - } | |
| 1121 | - } | |
| 1122 | - | |
| 1123 | - $query = " | |
| 1124 | - SELECT ID, post_title, post_excerpt, guid, post_content | |
| 1125 | - FROM {$wpdb->posts} | |
| 1126 | - $lang_join | |
| 1127 | - WHERE post_type = 'docs' | |
| 1128 | - AND post_status = 'publish' | |
| 1129 | - AND SUBSTRING(post_title, 1, 1) = %s | |
| 1130 | - $lang_where | |
| 1131 | - ORDER BY post_date DESC | |
| 1132 | - $limit_sql | |
| 1133 | - "; | |
| 1134 | - } | |
| 1135 | - | |
| 1136 | - $current_letter_docs = $wpdb->get_results( $wpdb->prepare( $query, $current_letter ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared | |
| 1137 | - | |
| 1138 | - return $current_letter_docs; | |
| 1139 | - } | |
| 1140 | - | |
| 1141 | - public static function docs_sort_by_letter( $limit = 10 ) { | |
| 1142 | - global $wpdb; | |
| 1143 | - $enable_non_latin = betterdocs()->settings->get( 'encyclopedia_enable_non_latin' ); | |
| 1144 | - $script = betterdocs()->settings->get( 'encyclopedia_non_latin_option' ); | |
| 1145 | - $letters = Helper::get_character_range( $enable_non_latin, $script ); | |
| 1146 | - | |
| 1147 | - $docs_by_letter = []; | |
| 1148 | - $encyclopeia_suorce = betterdocs()->settings->get( 'encyclopedia_source', 'docs' ); | |
| 1149 | - $enable_glossaries = betterdocs()->settings->get( 'enable_glossaries', false ); | |
| 1150 | - | |
| 1151 | - foreach ( $letters as $letter ) { | |
| 1152 | - $posts = self::get_current_letter_docs( $letter, $limit ); | |
| 1153 | - | |
| 1154 | - if ( is_array( $posts ) && ! empty( $posts ) ) { | |
| 1155 | - foreach ( $posts as $post ) { | |
| 1156 | - $description = isset($post['meta_data']) ? \json_decode( $post['meta_data'], true ) : ''; | |
| 1157 | - $glossary_term_description = $description['glossary_term_description'] ?? ''; | |
| 1158 | - | |
| 1159 | - // Remove any <p> tags or other unwanted HTML tags | |
| 1160 | - $glossary_term_description = wp_strip_all_tags( $glossary_term_description ); | |
| 1161 | - $post_excerpt = wp_strip_all_tags( $post['post_excerpt'] ?? '' ); | |
| 1162 | - | |
| 1163 | - // Prepare post data | |
| 1164 | - if ( $enable_glossaries && $encyclopeia_suorce === 'glossaries' ) { | |
| 1165 | - // For glossaries | |
| 1166 | - $permalink = ''; | |
| 1167 | - | |
| 1168 | - if ( isset( $post['slug'] ) ) { | |
| 1169 | - $term_link = get_term_link( $post['slug'], 'glossaries' ); | |
| 1170 | - | |
| 1171 | - if ( ! is_wp_error( $term_link ) ) { | |
| 1172 | - $permalink = $term_link; | |
| 1173 | - } | |
| 1174 | - } | |
| 1175 | - | |
| 1176 | - $post_data = [ | |
| 1177 | - 'id' => $post['term_id'] ?? '', | |
| 1178 | - 'post_title' => $post['post_title'] ?? '', | |
| 1179 | - 'post_excerpt' => ! empty( $post_excerpt ) | |
| 1180 | - ? $post_excerpt | |
| 1181 | - : ( ! empty( $glossary_term_description ) | |
| 1182 | - ? self::get_custom_excerpt( $glossary_term_description, 15 ) | |
| 1183 | - : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ) ), | |
| 1184 | - 'permalink' => $permalink, | |
| 1185 | - ]; | |
| 1186 | - } else { | |
| 1187 | - // For docs | |
| 1188 | - $post_data = [ | |
| 1189 | - 'id' => $post['ID'] ?? '', | |
| 1190 | - 'post_title' => $post['post_title'] ?? '', | |
| 1191 | - 'post_excerpt' => ! empty( $post_excerpt ) | |
| 1192 | - ? $post_excerpt | |
| 1193 | - : self::get_custom_excerpt( wp_strip_all_tags( $post['post_content'] ?? '' ), 15 ), | |
| 1194 | - 'permalink' => isset( $post['ID'] ) ? get_the_permalink( $post['ID'] ) : '' | |
| 1195 | - ]; | |
| 1196 | - } | |
| 1197 | - | |
| 1198 | - $docs_by_letter[$letter][] = $post_data; | |
| 1199 | - } | |
| 1200 | - } | |
| 1201 | - } | |
| 1202 | - | |
| 1203 | - return $docs_by_letter; | |
| 1204 | - } | |
| 1205 | - | |
| 1206 | 1169 | public static function get_glossaries() { |
| 1207 | 1170 | global $wpdb; |
| 1208 | 1171 | |
| 1209 | 1172 | $lang_join = ''; |
| @@ -1280,44 +1243,14 @@ | ||
| 1280 | 1243 | } |
| 1281 | 1244 | |
| 1282 | 1245 | return $layout; |
| 1283 | 1246 | } |
| 1284 | - public static function mb_ord_fallback( $char ) { | |
| 1285 | - $code = unpack( 'N', mb_convert_encoding( $char, 'UCS-4BE', 'UTF-8' ) ); | |
| 1286 | - return $code[1]; | |
| 1287 | - } | |
| 1288 | 1247 | |
| 1289 | - public static function mb_chr_fallback( $code ) { | |
| 1290 | - return mb_convert_encoding( pack( 'N', $code ), 'UTF-8', 'UCS-4BE' ); | |
| 1291 | - } | |
| 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. | |
| 1292 | 1252 | |
| 1293 | - public static function unicodeRange( $start, $end ) { | |
| 1294 | - $range = []; | |
| 1295 | - for ( $i = self::mb_ord_fallback( $start ); $i <= self::mb_ord_fallback( $end ); $i++ ) { | |
| 1296 | - $range[] = self::mb_chr_fallback( $i ); | |
| 1297 | - } | |
| 1298 | - return $range; | |
| 1299 | - } | |
| 1300 | - | |
| 1301 | - public static function get_character_range( $enable_non_latin, $script ) { | |
| 1302 | - if ( $enable_non_latin ) { | |
| 1303 | - switch ( $script ) { | |
| 1304 | - case 'arabic': | |
| 1305 | - return self::unicodeRange( 'ء', 'ي' ); | |
| 1306 | - case 'cyrillic': | |
| 1307 | - return self::unicodeRange( 'А', 'Я' ); | |
| 1308 | - case 'hebrew': | |
| 1309 | - return self::unicodeRange( 'א', 'ת' ); | |
| 1310 | - case 'greek': | |
| 1311 | - return self::unicodeRange( 'Α', 'Ω' ); | |
| 1312 | - default: | |
| 1313 | - return range( 'A', 'Z' ); | |
| 1314 | - } | |
| 1315 | - } | |
| 1316 | - | |
| 1317 | - return range( 'A', 'Z' ); | |
| 1318 | - } | |
| 1319 | - | |
| 1320 | 1253 | public static function get_the_top_most_parent( $term_id ) { |
| 1321 | 1254 | while ( $term_id != 0 ) { |
| 1322 | 1255 | $parent_id = wp_get_term_taxonomy_parent_id( $term_id, 'doc_category' ); |
| 1323 | 1256 | |
| @@ -1451,8 +1384,9 @@ | ||
| 1451 | 1384 | 'json' => '📋', |
| 1452 | 1385 | 'yaml' => '📋', |
| 1453 | 1386 | 'xml' => '📄', |
| 1454 | 1387 | 'markdown' => '📝', |
| 1388 | + 'curl' => '💻', | |
| 1455 | 1389 | 'bash' => '💻', |
| 1456 | 1390 | 'shell' => '💻', |
| 1457 | 1391 | 'powershell' => '💻', |
| 1458 | 1392 | 'dockerfile' => '🐳', |
| @@ -1460,8 +1394,89 @@ | ||
| 1460 | 1394 | |
| 1461 | 1395 | return isset( $icons[$language] ) ? $icons[$language] : '📄'; |
| 1462 | 1396 | } |
| 1463 | 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 | + | |
| 1464 | 1479 | /** |
| 1465 | 1480 | * Check if AI Chatbot is enabled |
| 1466 | 1481 | * |
| 1467 | 1482 | * @return bool |
| @@ -1510,5 +1525,225 @@ | ||
| 1510 | 1525 | $sql = $wpdb->prepare( "SELECT MAX(CAST(meta_value AS UNSIGNED)) AS max FROM {$wpdb->termmeta} WHERE meta_key = %s ", 'doc_category_order' ); |
| 1511 | 1526 | $result = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above. |
| 1512 | 1527 | return $result; |
| 1513 | 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 | + } | |
| 1514 | 1749 | } |