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
betterdocs / includes / Utils / Helper.php

Helper.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.3, at includes/Utils/Helper.php

1,750 lines 64.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Utils;
4
5 // Helper utilities mix per-language URL detection (read-only $_GET reads),
6 // dynamic alphabet-letter / glossary queries composed via $wpdb->prepare,
7 // and meta-key term lookups that are core to BetterDocs functionality.
8 // phpcs:disable WordPress.Security.NonceVerification.Recommended
9 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
10 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
11 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
12 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
13 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
14 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query
15 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
16 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
17
18 use function BetterLinksPro\Dependencies\GuzzleHttp\json_decode;
19 use function WPML\PHP\Logger\error;
20
21 class Helper extends Base {
22
23 /**
24 * Mask an API key for safe display.
25 *
26 * Prefix-aware: when the key carries a recognizable provider prefix
27 * (OpenAI sk-/sk-proj-, Anthropic sk-ant-/sk-ant-api03-, Gemini AIza) that
28 * prefix is kept visible so an admin can tell which provider/key is set,
29 * then a fixed 8-asterisk block, then the last 4 chars. Keys without a known
30 * prefix fall back to first 3 + 8 asterisks + last 4. The asterisk count is
31 * always fixed so the real key length is never leaked.
32 */
33 public static function mask_api_key( $key ) {
34 if ( ! is_string( $key ) || $key === '' ) {
35 return '';
36 }
37 $key = trim( $key );
38 if ( $key === '' ) {
39 return '';
40 }
41
42 // Longest prefixes first so sk-proj-/sk-ant- win over the bare sk-.
43 $prefixes = array( 'sk-ant-api03-', 'sk-ant-', 'sk-proj-', 'sk-', 'AIza' );
44 foreach ( $prefixes as $prefix ) {
45 if ( strncmp( $key, $prefix, strlen( $prefix ) ) === 0
46 && strlen( $key ) >= strlen( $prefix ) + 4 ) {
47 return $prefix . str_repeat( '*', 8 ) . substr( $key, -4 );
48 }
49 }
50
51 if ( strlen( $key ) < 8 ) {
52 return str_repeat( '*', strlen( $key ) );
53 }
54 return substr( $key, 0, 3 ) . str_repeat( '*', 8 ) . substr( $key, -4 );
55 }
56
57 /**
58 * 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
164 public static function get_plugins( $plugin_basename = null ) {
165 if ( ! function_exists( 'get_plugins' ) ) {
166 include_once ABSPATH . 'wp-admin/includes/plugin.php';
167 }
168
169 $plugins = get_plugins();
170 return $plugin_basename == null ? $plugins : isset( $plugins[ $plugin_basename ] );
171 }
172
173 public static function is_plugin_active( $plugin_basename ) {
174 if ( ! function_exists( 'is_plugin_active' ) ) {
175 include_once ABSPATH . 'wp-admin/includes/plugin.php';
176 }
177
178 return is_plugin_active( $plugin_basename );
179 }
180
181 /**
182 * Whether an SEO plugin already emits FAQPage schema on the current page.
183 *
184 * True only when Yoast or Rank Math is active AND its FAQ block is present
185 * in the post's content, so BetterDocs can skip its own FAQPage JSON-LD and
186 * avoid duplicate structured data. Defaults to the queried object when no
187 * post is given.
188 *
189 * @param int|\WP_Post|null $post
190 * @return bool
191 */
192 public static function seo_plugin_outputs_faq_schema( $post = null ) {
193 if ( null === $post ) {
194 $post = get_queried_object();
195 }
196
197 $post = get_post( $post );
198 if ( ! $post instanceof \WP_Post ) {
199 return false;
200 }
201
202 if ( self::is_plugin_active( 'wordpress-seo/wp-seo.php' ) && has_block( 'yoast/faq-block', $post ) ) {
203 return true;
204 }
205
206 if ( self::is_plugin_active( 'seo-by-rank-math/rank-math.php' ) && has_block( 'rank-math/faq-block', $post ) ) {
207 return true;
208 }
209
210 // Extension seam for Pro / other SEO integrations.
211 return (bool) apply_filters( 'betterdocs_seo_plugin_outputs_faq_schema', false, $post );
212 }
213
214 public static function get_tax( $tax = '' ) {
215 global $wp_query;
216
217 if ( is_tax( 'knowledge_base' ) ) {
218 $_taxes = $wp_query->tax_query->queried_terms;
219 if ( array_key_exists( 'doc_category', $_taxes ) ) {
220 $tax = 'doc_category';
221 } else {
222 $tax = 'knowledge_base';
223 }
224 } elseif ( is_tax( 'doc_category' ) ) {
225 $tax = 'doc_category';
226 } elseif ( is_tax( 'doc_tag' ) ) {
227 $tax = 'doc_tag';
228 }
229
230 return $tax;
231 }
232
233 public function is_templates() {
234 global $wp_query;
235 $slug = betterdocs()->settings->get( 'encyclopedia_root_slug', 'encyclopedia' );
236
237 $tax = $this->get_tax();
238 if ( is_post_type_archive( 'docs' ) || $tax === 'knowledge_base' || $tax === 'doc_category' || $tax === 'doc_tag' || is_singular( 'docs' ) || is_tax( 'glossaries' ) ) {
239 return true;
240 }
241
242 if ( isset( $wp_query->query['pagename'] ) && $wp_query->query['pagename'] === $slug ) {
243 return true;
244 }
245
246 return false;
247 }
248
249 public function is_el_templates() {
250 $_return_val = betterdocs()->editor->get( 'elementor' )->is_templates();
251
252 if ( $_return_val !== null ) {
253 return $_return_val;
254 }
255
256 $this->is_templates();
257 }
258
259 /**
260 * Which tab to show.
261 *
262 * 1. Drag and Drop UI
263 * 2. Post List UI
264 *
265 * * 1. dnd
266 * * 2. classic
267 *
268 * look into views/admin/docs-ui directory to know more.
269 *
270 * @return string
271 */
272 public static function admin_tab() {
273 $admin_ui = 'grid';
274 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin UI selection, no state change.
275 $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
276 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin UI selection, no state change.
277 $mode = isset( $_GET['mode'] ) ? sanitize_text_field( wp_unslash( $_GET['mode'] ) ) : '';
278 if ( $page === 'betterdocs-admin' && ! empty( $mode ) ) {
279 $admin_ui = $mode === 'grid' ? 'grid' : 'list';
280 }
281
282 return $admin_ui;
283 }
284
285 public static function is_active( $prev, $current, $class = 'active' ) {
286 if ( $current == $prev ) {
287 return $class;
288 }
289
290 return '';
291 }
292
293 public function get_users( $args ) {
294 $cache_key = 'betterdocs_cache_admin_user_roles';
295 $users = betterdocs()->database->get_cache( $cache_key );
296
297 if ( false === $users ) {
298 $users = get_users( $args );
299 betterdocs()->database->set_cache( $cache_key, $users );
300 }
301
302 return $users;
303 }
304
305 /**
306 * Normalize Menu Array
307 * Menu creator helper
308 *
309 * @since 2.5.0
310 *
311 * @param string $title
312 * @param string $slug
313 * @param string $cap
314 * @param array $callback
315 *
316 * @return array
317 */
318 public static function normalize_menu( $title, $slug, $cap = 'edit_docs', $callback = null, $optional = [] ) {
319 $args = [
320 'page_title' => $title,
321 'menu_title' => $title,
322 'capability' => $cap,
323 'menu_slug' => $slug
324 ];
325
326 if ( $callback != null ) {
327 $args['callback'] = $callback;
328 }
329
330 return wp_parse_args( $optional, $args );
331 }
332
333 /**
334 * Check if the current theme is a block theme.
335 *
336 * @since x.x.x
337 * @return bool
338 */
339 public function current_theme_is_fse_theme() {
340 if ( function_exists( 'wp_is_block_theme' ) ) {
341 return (bool) wp_is_block_theme();
342 }
343 if ( function_exists( 'gutenberg_is_fse_theme' ) ) {
344 return (bool) gutenberg_is_fse_theme();
345 }
346
347 return false;
348 }
349
350 protected static function is_assoc_array( $array ) {
351 return array_keys( $array ) !== range( 0, count( $array ) - 1 );
352 }
353
354 public static function merge( &$array1, &$array2 ) {
355 $merged = $array1;
356
357 foreach ( $array2 as $key => &$value ) {
358 if ( is_array( $value ) && self::is_assoc_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
359 $merged[ $key ] = self::merge( $merged[ $key ], $value );
360 } elseif ( is_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
361 $merged[ $key ] = array_merge( $merged[ $key ], $value );
362 } else {
363 $merged[ $key ] = $value;
364 }
365 }
366
367 return $merged;
368 }
369
370 public static function get_custom_excerpt( $content, $numOfWords ) {
371 $content = strip_shortcodes( $content );
372 $content = wp_strip_all_tags( $content );
373 $words = explode( ' ', $content );
374 $excerptWords = array_slice( $words, 0, $numOfWords );
375 $excerpt = implode( ' ', $excerptWords );
376 if ( count( $words ) > $numOfWords ) {
377 $excerpt .= '...';
378 }
379 return $excerpt;
380 }
381
382 /**
383 * Get current language from various multilingual plugins
384 *
385 * @return string|null Current language code
386 */
387 public static function get_current_language() {
388 $current_language = null;
389
390 // WPML Support
391 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
392 global $sitepress;
393 if ( $sitepress && $sitepress->is_setup_complete() ) {
394 $current_language = defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : $sitepress->get_current_language();
395 }
396 }
397 // Polylang Support
398 elseif ( function_exists( 'pll_current_language' ) ) {
399 $current_language = pll_current_language();
400 }
401 // qTranslate-X Support
402 elseif ( function_exists( 'qtranxf_getLanguage' ) ) {
403 $current_language = qtranxf_getLanguage();
404 }
405 // Weglot Support
406 elseif ( function_exists( 'weglot_get_current_language' ) ) {
407 $current_language = weglot_get_current_language();
408 }
409 // TranslatePress Support
410 elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
411 $current_language = trp_get_current_language();
412 }
413
414 return $current_language;
415 }
416
417 /**
418 * Check if any multilingual plugin is active
419 *
420 * @return bool
421 */
422 public static function is_multilingual_active() {
423 return is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ||
424 function_exists( 'pll_current_language' ) ||
425 function_exists( 'qtranxf_getLanguage' ) ||
426 function_exists( 'weglot_get_current_language' ) ||
427 ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) );
428 }
429
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 /**
481 * Check if we should apply language filtering
482 * Only apply on frontend or when specifically requested
483 *
484 * @return bool
485 */
486 public static function should_apply_language_filtering() {
487 // Don't apply language filtering in admin context unless it's a frontend request
488 if ( is_admin() ) {
489 // Allow language filtering for REST API requests that are frontend-facing
490 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
491 // Check if this is a frontend REST request (not admin)
492 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
493 // Don't filter admin REST requests for glossaries management
494 if ( strpos( $request_uri, '/wp/v2/glossaries' ) !== false ) {
495 return false; // Don't filter admin glossaries management
496 }
497 }
498 return false; // Don't filter other admin requests
499 }
500
501 // Apply filtering on frontend
502 return true;
503 }
504
505 /**
506 * Get current admin language for multilingual sites
507 * This is specifically for admin context where we need to detect
508 * the language being used for editing terms/posts
509 *
510 * @return string|null Current admin language code
511 */
512 public static function get_current_admin_language() {
513 $current_language = null;
514
515 // Explicit language passed by the admin client takes priority.
516 // Covers AJAX (POST) and REST/admin requests (GET) where WPML may
517 // otherwise resolve to the site's default language instead of the
518 // admin UI language.
519 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only UI language hint, sanitized; not a state-changing form submission.
520 if ( isset( $_POST['lang'] ) && ! empty( $_POST['lang'] ) ) {
521 return self::sanitize_language_code( wp_unslash( $_POST['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see note above.
522 }
523
524 // Limit GET handling to admin/REST contexts so a frontend ?lang= switch
525 // doesn't hijack admin meta-key resolution.
526 if ( isset( $_GET['lang'] ) && ! empty( $_GET['lang'] )
527 && ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) ) {
528 return self::sanitize_language_code( wp_unslash( $_GET['lang'] ) );
529 }
530
531 // WPML Support - Admin language detection
532 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
533 global $sitepress;
534 if ( $sitepress && $sitepress->is_setup_complete() ) {
535 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
536 $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
537 // For term editing, check if we have a specific term language
538 if ( $tag_id && function_exists( 'wpml_get_language_information' ) ) {
539 $term_info = wpml_get_language_information( null, $tag_id );
540 if ( ! is_wp_error( $term_info ) && $term_info && isset( $term_info['language_code'] ) ) {
541 $current_language = $term_info['language_code'];
542 }
543
544 }
545
546 // Check for language parameter in URL
547 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
548 if ( ! $current_language && isset( $_GET['lang'] ) ) {
549 $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
550 }
551
552 // Check WPML admin language cookie (persists during AJAX)
553 if ( ! $current_language && isset( $_COOKIE['_icl_current_admin_language'] ) ) {
554 $current_language = sanitize_text_field( wp_unslash( $_COOKIE['_icl_current_admin_language'] ) );
555 }
556
557 // Fallback to admin language or current language
558 if ( ! $current_language ) {
559 $current_language = defined( 'ICL_LANGUAGE_CODE' ) ? ICL_LANGUAGE_CODE : $sitepress->get_current_language();
560 }
561 }
562 }
563 // Polylang Support - Admin language detection
564 elseif ( function_exists( 'pll_current_language' ) ) {
565 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
566 $tag_id = isset( $_GET['tag_ID'] ) ? (int) $_GET['tag_ID'] : 0;
567 // For term editing, get language from term ID
568 if ( $tag_id && function_exists( 'pll_get_term_language' ) ) {
569 $term_lang = pll_get_term_language( $tag_id );
570 if ( $term_lang ) {
571 $current_language = $term_lang;
572 }
573 }
574
575 // Check for language parameter in URL
576 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language detection from URL.
577 if ( ! $current_language && isset( $_GET['lang'] ) ) {
578 $current_language = sanitize_text_field( wp_unslash( $_GET['lang'] ) );
579 }
580
581 // Fallback to current admin language
582 if ( ! $current_language ) {
583 $current_language = pll_current_language( 'slug' );
584 }
585 }
586 // Other multilingual plugins
587 elseif ( function_exists( 'qtranxf_getLanguage' ) ) {
588 $current_language = qtranxf_getLanguage();
589 }
590 elseif ( function_exists( 'weglot_get_current_language' ) ) {
591 $current_language = weglot_get_current_language();
592 }
593 elseif ( class_exists( 'TRP_Translate_Press' ) && function_exists( 'trp_get_current_language' ) ) {
594 $current_language = trp_get_current_language();
595 }
596
597 return self::sanitize_language_code( $current_language );
598 }
599
600 /**
601 * Normalize a language code to the character set real language codes use
602 * (`en`, `en_US`, `zh-Hans`). Values reach this from `?lang=`, `$_POST['lang']`
603 * and the WPML admin cookie, and `sanitize_text_field()` leaves quotes intact —
604 * so anything used to build a meta key or SQL fragment must be narrowed here.
605 * Defense in depth: callers that reach SQL must still bind their values.
606 *
607 * @param string|null $language Raw language code.
608 * @return string|null Normalized code, or null when nothing usable remains.
609 */
610 private static function sanitize_language_code( $language ) {
611 if ( ! is_string( $language ) || '' === $language ) {
612 return null;
613 }
614
615 $language = preg_replace( '/[^A-Za-z0-9_-]/', '', $language );
616
617 return '' !== $language ? $language : null;
618 }
619
620 /**
621 * Generate language-specific meta key for category ordering
622 * Always falls back to base key if language-specific key doesn't exist
623 *
624 * @param string $base_key The base meta key (e.g., 'doc_category_order')
625 * @param string|null $language Language code, if null will auto-detect
626 * @return string Language-specific meta key or base key as fallback
627 */
628 public static function get_language_specific_meta_key( $base_key, $language = null ) {
629 // If no multilingual plugin is active, return the base key
630 if ( ! self::is_multilingual_active() ) {
631 return $base_key;
632 }
633
634 // Get current admin language if not provided
635 if ( $language === null ) {
636 $language = self::get_current_admin_language();
637 }
638
639 // If no language detected, return base key for backward compatibility
640 if ( ! $language ) {
641 return $base_key;
642 }
643
644 // Always return base key for now - we'll handle fallback in the query functions
645 // This ensures compatibility without requiring migration
646 return $base_key;
647 }
648
649 /**
650 * Get the meta key to write to.
651 *
652 * Unlike `get_meta_key_with_fallback`, this never falls back to the base
653 * key when the language-specific key is empty — that fallback is what
654 * caused secondary-language drag-and-drop saves to clobber the base meta
655 * (and on WPML setups that copy term meta from the original language,
656 * the next read would re-overwrite it from the primary language).
657 *
658 * @param string $base_key The base meta key.
659 * @param string|null $language Language code, auto-detected when null.
660 * @return string Language-specific key when multilingual + language known, else base.
661 */
662 public static function get_meta_key_for_save( $base_key, $language = null ) {
663 if ( ! self::is_multilingual_active() ) {
664 return $base_key;
665 }
666
667 if ( $language === null ) {
668 $language = self::get_current_admin_language();
669 }
670
671 if ( ! $language ) {
672 return $base_key;
673 }
674
675 return $base_key . '_' . $language;
676 }
677
678 /**
679 * Get the appropriate meta key with fallback logic
680 * This function checks if language-specific meta exists, if not falls back to base key
681 *
682 * @param string $base_key The base meta key
683 * @param int $term_id The term ID to check
684 * @param string|null $language Language code
685 * @return string The meta key to use
686 */
687 public static function get_meta_key_with_fallback( $base_key, $term_id = null, $language = null ) {
688 // If no multilingual plugin is active, return the base key
689 if ( ! self::is_multilingual_active() ) {
690 return $base_key;
691 }
692
693 // Get current admin language if not provided
694 if ( $language === null ) {
695 $language = self::get_current_admin_language();
696 }
697
698 // If no language detected, return base key
699 if ( ! $language ) {
700 return $base_key;
701 }
702
703 $lang_meta_key = $base_key . '_' . $language;
704
705 // If we have a specific term ID, check if language-specific meta exists
706 if ( $term_id ) {
707 $lang_value = get_term_meta( $term_id, $lang_meta_key, true );
708 if ( ! empty( $lang_value ) ) {
709 return $lang_meta_key;
710 }
711 // Fall back to base key if language-specific doesn't exist
712 return $base_key;
713 }
714
715 // For queries without specific term ID, we need to check if ANY terms have language-specific meta
716 global $wpdb;
717 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live multilingual meta-key resolution; result varies per active language.
718 $has_lang_meta = $wpdb->get_var( $wpdb->prepare(
719 "SELECT COUNT(*) FROM {$wpdb->termmeta} tm
720 INNER JOIN {$wpdb->term_taxonomy} tt ON tm.term_id = tt.term_id
721 WHERE tm.meta_key = %s AND tt.taxonomy = 'doc_category' AND tm.meta_value != ''",
722 $lang_meta_key
723 ) );
724
725 // If language-specific meta exists for some terms, use it (terms without it will have empty values)
726 // Otherwise, fall back to base key
727 return $has_lang_meta > 0 ? $lang_meta_key : $base_key;
728 }
729
730 /**
731 * Migrate existing category orders to language-specific meta keys
732 * This should be called when a multilingual plugin is activated
733 *
734 * @param string $base_key The base meta key (e.g., 'doc_category_order')
735 * @param string $taxonomy The taxonomy to migrate
736 * @return bool Success status
737 */
738 public static function migrate_category_orders_to_multilingual( $base_key = 'doc_category_order', $taxonomy = 'doc_category' ) {
739 // Only run if multilingual plugin is active
740 if ( ! self::is_multilingual_active() ) {
741 return false;
742 }
743
744 global $wpdb;
745
746 // Get all terms with the base meta key
747 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
748 $terms_with_order = $wpdb->get_results( $wpdb->prepare(
749 "SELECT tm.term_id, tm.meta_value, t.slug
750 FROM {$wpdb->termmeta} tm
751 INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
752 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
753 WHERE tm.meta_key = %s AND tt.taxonomy = %s",
754 $base_key,
755 $taxonomy
756 ) );
757
758 if ( empty( $terms_with_order ) ) {
759 return true; // Nothing to migrate
760 }
761
762 // Get available languages
763 $languages = self::get_available_languages();
764
765 if ( empty( $languages ) ) {
766 return false; // No languages found
767 }
768
769 // Migrate orders for each language
770 foreach ( $languages as $language ) {
771 $language_meta_key = $base_key . '_' . $language;
772
773 foreach ( $terms_with_order as $term_data ) {
774 // Check if language-specific meta already exists
775 $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
776
777 if ( empty( $existing_value ) ) {
778 // Copy the base order to language-specific key
779 update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
780 }
781 }
782 }
783
784 return true;
785 }
786
787 /**
788 * Migrate existing document orders to language-specific meta keys
789 * This should be called when a multilingual plugin is activated
790 *
791 * @param string $base_key The base meta key (e.g., '_docs_order')
792 * @param string $taxonomy The taxonomy to migrate
793 * @return bool Success status
794 */
795 public static function migrate_docs_orders_to_multilingual( $base_key = '_docs_order', $taxonomy = 'doc_category' ) {
796 // Only run if multilingual plugin is active
797 if ( ! self::is_multilingual_active() ) {
798 return false;
799 }
800
801 global $wpdb;
802
803 // Get all terms with the base meta key for document ordering
804 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot multilingual migration; cache would be stale immediately after writes.
805 $terms_with_docs_order = $wpdb->get_results( $wpdb->prepare(
806 "SELECT tm.term_id, tm.meta_value, t.slug
807 FROM {$wpdb->termmeta} tm
808 INNER JOIN {$wpdb->terms} t ON tm.term_id = t.term_id
809 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
810 WHERE tm.meta_key = %s AND tt.taxonomy = %s AND tm.meta_value != ''",
811 $base_key,
812 $taxonomy
813 ) );
814
815 if ( empty( $terms_with_docs_order ) ) {
816 return true; // Nothing to migrate
817 }
818
819 // Get available languages
820 $languages = self::get_available_languages();
821
822 if ( empty( $languages ) ) {
823 return false; // No languages found
824 }
825
826 // Migrate document orders for each language
827 foreach ( $languages as $language ) {
828 $language_meta_key = $base_key . '_' . $language;
829
830 foreach ( $terms_with_docs_order as $term_data ) {
831 // Check if language-specific meta already exists
832 $existing_value = get_term_meta( $term_data->term_id, $language_meta_key, true );
833
834 if ( empty( $existing_value ) ) {
835 // Copy the base document order to language-specific key
836 update_term_meta( $term_data->term_id, $language_meta_key, $term_data->meta_value );
837 }
838 }
839 }
840
841 return true;
842 }
843
844 /**
845 * Migrate both category and document orders to multilingual format
846 * This is a convenience method that runs both migrations
847 *
848 * @return bool Success status
849 */
850 public static function migrate_all_orders_to_multilingual() {
851 $category_result = self::migrate_category_orders_to_multilingual();
852 $docs_result = self::migrate_docs_orders_to_multilingual();
853
854 return $category_result && $docs_result;
855 }
856
857 /**
858 * Get available languages from multilingual plugins
859 *
860 * @return array Array of language codes
861 */
862 public static function get_available_languages() {
863 $languages = [];
864
865 // WPML Support
866 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
867 global $sitepress;
868 if ( $sitepress && $sitepress->is_setup_complete() ) {
869 $active_languages = $sitepress->get_active_languages();
870 if ( is_array( $active_languages ) ) {
871 $languages = array_keys( $active_languages );
872 }
873 }
874 }
875 // Polylang Support
876 elseif ( function_exists( 'pll_languages_list' ) ) {
877 $languages = pll_languages_list();
878 }
879
880 return $languages;
881 }
882
883 /**
884 * Rich list of active site languages for the React admin language bar.
885 *
886 * @return array<int,array{code:string,label:string,native:string,flag:string}>
887 * Empty when no supported multilingual plugin is active.
888 */
889 public static function get_admin_languages() {
890 $languages = [];
891
892 // WPML
893 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
894 global $sitepress;
895 if ( $sitepress && $sitepress->is_setup_complete() ) {
896 $active = $sitepress->get_active_languages();
897 if ( is_array( $active ) ) {
898 foreach ( $active as $code => $lang ) {
899 $languages[] = [
900 'code' => $code,
901 'label' => isset( $lang['english_name'] ) ? $lang['english_name'] : $code,
902 'native' => isset( $lang['native_name'] ) ? $lang['native_name'] : ( isset( $lang['display_name'] ) ? $lang['display_name'] : $code ),
903 'flag' => isset( $lang['country_flag_url'] ) ? $lang['country_flag_url'] : '',
904 ];
905 }
906 }
907 }
908 }
909 // Polylang
910 elseif ( function_exists( 'pll_languages_list' ) ) {
911 $list = pll_languages_list( [ 'fields' => '' ] ); // full PLL_Language objects
912 if ( is_array( $list ) ) {
913 foreach ( $list as $lang ) {
914 if ( ! is_object( $lang ) ) {
915 continue;
916 }
917 $languages[] = [
918 'code' => isset( $lang->slug ) ? $lang->slug : '',
919 'label' => isset( $lang->name ) ? $lang->name : ( isset( $lang->slug ) ? $lang->slug : '' ),
920 'native' => isset( $lang->name ) ? $lang->name : '',
921 'flag' => isset( $lang->flag_url ) ? $lang->flag_url : '',
922 ];
923 }
924 }
925 }
926
927 return $languages;
928 }
929
930 /**
931 * Read a term's language code via the active multilingual plugin.
932 *
933 * @param \WP_Term $term
934 * @return string Language code, or '' when unavailable.
935 */
936 public static function get_term_language( $term ) {
937 if ( ! is_object( $term ) || empty( $term->term_id ) ) {
938 return '';
939 }
940
941 // Polylang — takes the term_id.
942 if ( function_exists( 'pll_get_term_language' ) ) {
943 $lang = pll_get_term_language( $term->term_id, 'slug' );
944 return $lang ? $lang : '';
945 }
946
947 // WPML — element_id is the term_taxonomy_id (NOT the term_id); WPML
948 // normalizes the element_type to `tax_<taxonomy>` internally.
949 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
950 $lang = apply_filters( 'wpml_element_language_code', null, [
951 'element_id' => $term->term_taxonomy_id,
952 'element_type' => isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category',
953 ] );
954 return $lang ? $lang : '';
955 }
956
957 return '';
958 }
959
960 /**
961 * Stamp a term's language via the active multilingual plugin. Standalone
962 * assignment only — it sets/re-stamps the term's own language and does not
963 * link it into an existing translation group.
964 *
965 * @param \WP_Term $term
966 * @param string $lang_code
967 */
968 public static function set_term_language( $term, $lang_code ) {
969 $lang_code = sanitize_text_field( (string) $lang_code );
970 if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
971 return;
972 }
973
974 // Polylang
975 if ( function_exists( 'pll_set_term_language' ) ) {
976 pll_set_term_language( $term->term_id, $lang_code );
977 return;
978 }
979
980 // WPML — element_id is the term_taxonomy_id; element_type is tax_<taxonomy>;
981 // trid=null sets it as a standalone original in the chosen language.
982 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
983 $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
984 do_action( 'wpml_set_element_language_details', [
985 'element_id' => $term->term_taxonomy_id,
986 'element_type' => 'tax_' . $taxonomy,
987 'trid' => null,
988 'language_code' => $lang_code,
989 'source_language_code' => null,
990 ] );
991 }
992 }
993
994 /**
995 * The site's default language code, or '' when no multilingual plugin is active.
996 */
997 public static function get_default_language() {
998 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
999 global $sitepress;
1000 if ( $sitepress ) {
1001 return (string) $sitepress->get_default_language();
1002 }
1003 }
1004 if ( function_exists( 'pll_default_language' ) ) {
1005 return (string) pll_default_language( 'slug' );
1006 }
1007 return '';
1008 }
1009
1010 /**
1011 * All terms in a term's translation group, keyed by language code.
1012 *
1013 * @param \WP_Term $term
1014 * @return array<string,array{term_id:int,name:string}>
1015 */
1016 public static function get_term_translations( $term ) {
1017 if ( ! is_object( $term ) || empty( $term->term_id ) ) {
1018 return [];
1019 }
1020 $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
1021 $out = [];
1022
1023 // Polylang
1024 if ( function_exists( 'pll_get_term_translations' ) ) {
1025 $group = pll_get_term_translations( $term->term_id ); // [lang => term_id]
1026 if ( is_array( $group ) ) {
1027 foreach ( $group as $lang => $tid ) {
1028 $t = get_term( (int) $tid, $taxonomy );
1029 if ( $t && ! is_wp_error( $t ) ) {
1030 $out[ $lang ] = [ 'term_id' => (int) $tid, 'name' => $t->name ];
1031 }
1032 }
1033 }
1034 return $out;
1035 }
1036
1037 // WPML
1038 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
1039 $el_type = 'tax_' . $taxonomy;
1040 $trid = apply_filters( 'wpml_element_trid', null, $term->term_taxonomy_id, $el_type );
1041 if ( ! $trid ) {
1042 return $out;
1043 }
1044 $translations = apply_filters( 'wpml_get_element_translations', null, $trid, $el_type );
1045 if ( is_array( $translations ) ) {
1046 foreach ( $translations as $lang => $tr ) {
1047 $tid = isset( $tr->term_id ) ? (int) $tr->term_id : 0;
1048 if ( ! $tid ) {
1049 continue;
1050 }
1051 $t = get_term( $tid, $taxonomy );
1052 $out[ $lang ] = [
1053 'term_id' => $tid,
1054 'name' => ( $t && ! is_wp_error( $t ) ) ? $t->name : ( isset( $tr->name ) ? $tr->name : '' ),
1055 ];
1056 }
1057 }
1058 }
1059
1060 return $out;
1061 }
1062
1063 /**
1064 * Candidate source terms for the "This is a translation of" dropdown — terms in
1065 * $source_lang (default language) that aren't yet translated into $target_lang.
1066 *
1067 * @return array<int,array{term_id:int,name:string}>
1068 */
1069 public static function get_translation_candidates( $taxonomy, $target_lang, $source_lang ) {
1070 $candidates = [];
1071 $target_lang = sanitize_text_field( (string) $target_lang );
1072 $source_lang = sanitize_text_field( (string) $source_lang );
1073 if ( $taxonomy === '' || $source_lang === '' ) {
1074 return $candidates;
1075 }
1076
1077 // WPML
1078 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1079 global $sitepress;
1080 if ( $sitepress && method_exists( $sitepress, 'get_elements_without_translations' ) ) {
1081 $ttids = $sitepress->get_elements_without_translations( 'tax_' . $taxonomy, $target_lang, $source_lang );
1082 foreach ( (array) $ttids as $ttid ) {
1083 $t = get_term_by( 'term_taxonomy_id', (int) $ttid, $taxonomy );
1084 if ( $t && ! is_wp_error( $t ) ) {
1085 $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
1086 }
1087 }
1088 }
1089 return $candidates;
1090 }
1091
1092 // Polylang — source-lang terms whose group lacks the target language.
1093 if ( function_exists( 'pll_get_term_translations' ) && function_exists( 'pll_get_term_language' ) ) {
1094 $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'lang' => $source_lang ] );
1095 foreach ( (array) $terms as $t ) {
1096 if ( is_wp_error( $t ) ) {
1097 continue;
1098 }
1099 $group = pll_get_term_translations( $t->term_id );
1100 if ( ! isset( $group[ $target_lang ] ) ) {
1101 $candidates[] = [ 'term_id' => (int) $t->term_id, 'name' => $t->name ];
1102 }
1103 }
1104 }
1105
1106 return $candidates;
1107 }
1108
1109 /**
1110 * Set a term's language and (optionally) link it into the translation group of
1111 * $translation_of_term_id. Empty $translation_of_term_id = standalone.
1112 *
1113 * @param \WP_Term $term
1114 * @param string $lang_code
1115 * @param int $translation_of_term_id
1116 */
1117 public static function link_term_translation( $term, $lang_code, $translation_of_term_id = 0 ) {
1118 $lang_code = sanitize_text_field( (string) $lang_code );
1119 if ( $lang_code === '' || ! is_object( $term ) || empty( $term->term_id ) ) {
1120 return;
1121 }
1122 $taxonomy = isset( $term->taxonomy ) ? $term->taxonomy : 'doc_category';
1123 $translation_of_term_id = (int) $translation_of_term_id;
1124
1125 // Polylang
1126 if ( function_exists( 'pll_set_term_language' ) ) {
1127 pll_set_term_language( $term->term_id, $lang_code );
1128 if ( $translation_of_term_id && function_exists( 'pll_save_term_translations' ) ) {
1129 $group = function_exists( 'pll_get_term_translations' )
1130 ? (array) pll_get_term_translations( $translation_of_term_id )
1131 : [];
1132 $group[ $lang_code ] = $term->term_id;
1133 pll_save_term_translations( $group );
1134 }
1135 return;
1136 }
1137
1138 // WPML
1139 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) && ! empty( $term->term_taxonomy_id ) ) {
1140 $el_type = 'tax_' . $taxonomy;
1141 $trid = null;
1142 $src = null;
1143
1144 if ( $translation_of_term_id ) {
1145 $source = get_term( $translation_of_term_id, $taxonomy );
1146 if ( $source && ! is_wp_error( $source ) ) {
1147 $trid = apply_filters( 'wpml_element_trid', null, $source->term_taxonomy_id, $el_type );
1148 $src = self::get_term_language( $source );
1149 }
1150 }
1151
1152 do_action( 'wpml_set_element_language_details', [
1153 'element_id' => $term->term_taxonomy_id,
1154 'element_type' => $el_type,
1155 'trid' => $trid,
1156 'language_code' => $lang_code,
1157 'source_language_code' => $src,
1158 ] );
1159 }
1160 }
1161
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.
1168
1169 public static function get_glossaries() {
1170 global $wpdb;
1171
1172 $lang_join = '';
1173 $lang_where = '';
1174
1175 // Add language filtering if multilingual plugin is active and we should apply filtering
1176 $current_language = self::get_current_language();
1177 if ( $current_language && self::is_multilingual_active() && self::should_apply_language_filtering() ) {
1178 // Restrict language code to a safe character set before SQL interpolation.
1179 $current_language = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $current_language );
1180 // For WPML, use icl_translations table
1181 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
1182 $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'";
1183 $lang_where = " AND (icl_t.language_code = '$current_language' OR icl_t.language_code IS NULL)";
1184 }
1185 // For Polylang, use term_relationships with language taxonomy
1186 elseif ( function_exists( 'pll_current_language' ) ) {
1187 $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";
1188 $lang_where = " AND (t_lang.slug = '$current_language' OR t_lang.slug IS NULL)";
1189 }
1190 }
1191
1192 $query = "
1193 SELECT t.name
1194 FROM {$wpdb->terms} t
1195 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1196 $lang_join
1197 WHERE tt.taxonomy = 'glossaries'
1198 $lang_where
1199 ORDER BY t.name ASC
1200 ";
1201
1202 $glossaries = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1203
1204 return $glossaries;
1205 }
1206
1207 /**
1208 * Determine live search template layout, when live search is not selected from customizer(this will work when live search template is not selected from customizer)
1209 *
1210 * @param string $layout
1211 * @return string $layout
1212 */
1213 public static function determine_search_layout( $layout ) {
1214 if ( $layout ) {
1215 return $layout;
1216 }
1217
1218 $search_layout = betterdocs()->customizer->defaults->get( 'betterdocs_search_layout_select' );
1219 $docs_layout = betterdocs()->customizer->defaults->get( 'betterdocs_docs_layout_select' );
1220 $archive_page_layout = betterdocs()->customizer->defaults->get( 'betterdocs_archive_layout_select' );
1221 $single_layout = betterdocs()->customizer->defaults->get( 'betterdocs_single_layout_select' );
1222
1223 if ( is_post_type_archive( 'docs' ) ) {
1224 if ( $docs_layout != "layout-7" && ! $search_layout ) {
1225 $layout = 'layout-1';
1226 } else if ( $docs_layout == 'layout-7' && ! $search_layout ) {
1227 $layout = 'layout-2';
1228 }
1229 } else if ( is_tax( 'doc_tag' ) && ! $search_layout ) {
1230 $layout = 'layout-1';
1231 } else if ( is_tax( 'doc_category' ) ) {
1232 if ( $archive_page_layout != 'layout-7' && $archive_page_layout != 'layout-8' && ! $search_layout ) {
1233 $layout = 'layout-1';
1234 } else if ( ( $archive_page_layout == 'layout-7' && ! $search_layout ) || ( $archive_page_layout == 'layout-8' && ! $search_layout ) ) {
1235 $layout = 'layout-2';
1236 }
1237 } else if ( is_singular( 'docs' ) ) {
1238 if ( $single_layout != 'layout-8' && $single_layout != 'layout-9' && ! $search_layout ) {
1239 $layout = 'layout-1';
1240 } else if ( ( $single_layout == 'layout-8' && ! $search_layout ) || ( $single_layout == 'layout-9' && ! $search_layout ) ) {
1241 $layout = 'layout-2';
1242 }
1243 }
1244
1245 return $layout;
1246 }
1247
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.
1252
1253 public static function get_the_top_most_parent( $term_id ) {
1254 while ( $term_id != 0 ) {
1255 $parent_id = wp_get_term_taxonomy_parent_id( $term_id, 'doc_category' );
1256
1257 if ( $parent_id == 0 ) {
1258 break;
1259 }
1260
1261 $term_id = $parent_id;
1262 }
1263 return $term_id;
1264 }
1265
1266 public static function get_highest_docs_term() {
1267 $terms = get_terms( [
1268 'taxonomy' => 'doc_category', // Change to your desired taxonomy
1269 'hide_empty' => true, // Only show terms with posts
1270 'orderby' => 'count', // Order by post count
1271 'order' => 'DESC', // Descending order
1272 'number' => 1 // Get only the top term
1273 ] );
1274 return isset( $terms[0] ) ? $terms[0] : [];
1275 }
1276
1277 public static function delete_specific_faq_posts_by_faq_category( $term_id, $taxonomy = 'betterdocs_faq_category' ) {
1278 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- targeted bulk delete by FAQ category; tax filter is required.
1279 $args = [
1280 'post_type' => 'betterdocs_faq',
1281 'posts_per_page' => -1,
1282 // EVERY status, explicitly. WP_Query defaults to 'publish', so "delete this
1283 // group and its FAQs" was deleting only the published ones — the drafts (and
1284 // pending/scheduled/private/trashed FAQs) survived, and the wp_delete_term()
1285 // that follows then stripped their category, leaving them orphaned under
1286 // "Uncategorized". Note 'any' is NOT enough here: it excludes trash.
1287 'post_status' => [ 'publish', 'draft', 'pending', 'future', 'private', 'trash' ],
1288 'tax_query' => [
1289 [
1290 'taxonomy' => $taxonomy,
1291 'field' => 'id',
1292 'terms' => $term_id,
1293 'operator' => 'IN'
1294 ]
1295 ],
1296 'fields' => 'ids'
1297 ];
1298
1299 $query = new \WP_Query( $args );
1300
1301 if ( $query->have_posts() ) {
1302 foreach ( $query->posts as $doc_id ) {
1303 wp_delete_post( $doc_id, true );
1304 }
1305 }
1306 }
1307
1308 /**
1309 * Function To Normalize Repeater Field For Quick Builder
1310 *
1311 * @param array $fields
1312 * @param array $include_field_keys
1313 *
1314 * @return array
1315 */
1316 public static function normalize_repeater_field( $fields, $include_field_keys = [] ) {
1317 if( empty( $include_field_keys ) ) {
1318 return $fields;
1319 }
1320
1321 $normalized_fields = [];
1322
1323 foreach( $fields as $field ) {
1324 foreach( $include_field_keys as $field_key ) {
1325 if( ! isset( $normalized_fields[$field_key] ) ) {
1326 $normalized_fields[$field_key] = isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [];
1327 } else {
1328 array_push( $normalized_fields[$field_key], ...( isset( $field[$field_key] ) && ! empty( $field[$field_key] ) ? $field[$field_key] : [] ) );
1329 $normalized_fields[$field_key] = array_unique( $normalized_fields[$field_key] );
1330 }
1331 }
1332 }
1333
1334 return $normalized_fields;
1335 }
1336
1337 public static function get_local_plugin_data( $basename = '' ) {
1338 if ( empty( $basename ) ) {
1339 return false;
1340 }
1341
1342 if ( !function_exists( 'get_plugins' ) ) {
1343 include_once ABSPATH . 'wp-admin/includes/plugin.php';
1344 }
1345
1346 $plugins = get_plugins();
1347
1348 if ( !isset( $plugins[ $basename ] ) ) {
1349 return false;
1350 }
1351
1352 return $plugins[ $basename ];
1353 }
1354
1355 /**
1356 * Get default file icon based on programming language
1357 *
1358 * @param string $language Programming language identifier
1359 * @return string Emoji icon for the language
1360 */
1361 public static function get_file_icon_by_language( $language ) {
1362 $icons = [
1363 'javascript' => '📄',
1364 'typescript' => '📘',
1365 'jsx' => '⚛️',
1366 'tsx' => '⚛️',
1367 'html' => '🌐',
1368 'css' => '🎨',
1369 'scss' => '🎨',
1370 'sass' => '🎨',
1371 'less' => '🎨',
1372 'php' => '🐘',
1373 'python' => '🐍',
1374 'java' => '☕',
1375 'csharp' => '🔷',
1376 'cpp' => '⚙️',
1377 'c' => '⚙️',
1378 'ruby' => '💎',
1379 'go' => '🐹',
1380 'rust' => '🦀',
1381 'swift' => '🦉',
1382 'kotlin' => '🎯',
1383 'sql' => '🗃️',
1384 'json' => '📋',
1385 'yaml' => '📋',
1386 'xml' => '📄',
1387 'markdown' => '📝',
1388 'curl' => '💻',
1389 'bash' => '💻',
1390 'shell' => '💻',
1391 'powershell' => '💻',
1392 'dockerfile' => '🐳',
1393 ];
1394
1395 return isset( $icons[$language] ) ? $icons[$language] : '📄';
1396 }
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
1479 /**
1480 * Check if AI Chatbot is enabled
1481 *
1482 * @return bool
1483 */
1484 public function is_ai_chatbot_enabled() {
1485 $chatbot_active = is_plugin_active( 'betterdocs-ai-chatbot/betterdocs-ai-chatbot.php' );
1486 $chatbot_license_valid = get_option( 'betterdocs_chatbot_software__license_status' ) === 'valid';
1487 $chatbot_enabled = betterdocs()->settings->get( 'enable_ai_chatbot', false );
1488
1489 // AI Search Suggestions are enabled if all conditions are met
1490 return $chatbot_active && $chatbot_license_valid && $chatbot_enabled;
1491 }
1492
1493 /**
1494 * Check if tags are enabled and post has tags
1495 *
1496 * @return bool
1497 */
1498 public function is_tag_enabled() {
1499 global $post;
1500 $product_terms = wp_get_object_terms( $post->ID, 'doc_tag' );
1501 $enable_tags = betterdocs()->settings->get( 'enable_tags', false );
1502 return ! empty( $product_terms ) && $enable_tags;
1503 }
1504
1505 /**
1506 * Check if AI Search Suggestions are enabled
1507 *
1508 * @return bool
1509 */
1510 public function is_ai_search_suggestions_enabled() {
1511 $ai_search_suggestions_active = is_plugin_active( 'betterdocs-ai-search-suggestions/betterdocs-ai-search-suggestions.php' );
1512 $ai_search_suggestions_license_valid = get_option( 'betterdocs_ai_search_suggestions_software__license_status' ) === 'valid';
1513 $ai_search_suggestions_enabled = betterdocs()->settings->get( 'enable_ai_powered_search', false );
1514
1515 return $ai_search_suggestions_active && $ai_search_suggestions_license_valid && $ai_search_suggestions_enabled;
1516 }
1517
1518 /**
1519 * Get the maximum order value from the 'doc_category_order' term meta
1520 *
1521 * @return int
1522 */
1523 public static function get_max_doc_category_order_from_term_meta() {
1524 global $wpdb;
1525 $sql = $wpdb->prepare( "SELECT MAX(CAST(meta_value AS UNSIGNED)) AS max FROM {$wpdb->termmeta} WHERE meta_key = %s ", 'doc_category_order' );
1526 $result = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above.
1527 return $result;
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 }
1749 }
1750