PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/REST/Docs.php +131 -32 4.4.04.9.2 View file →
@@ -1,8 +1,13 @@
1 1 <?php
2 +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- core docs REST endpoints; meta/tax filtering required.
3 +namespace WPDeveloper\BetterDocs\REST;
2 4
3 -namespace WPDeveloper\BetterDocs\REST;
5 +if ( ! defined( 'ABSPATH' ) ) {
6 + exit;
7 +}
4 8
9 +
5 10 use Error;
6 11 use WP_Query;
7 12 use WP_REST_Response;
8 13 use WPDeveloper\BetterDocs\Core\BaseAPI;
@@ -14,9 +19,9 @@
14 19
15 20 public function register() {
16 21 $this->get( 'search', [$this, 'search_posts'], [
17 22 'password' => [
18 - 'description' => __( 'The password for password-protected docs.' ),
23 + 'description' => __( 'The password for password-protected docs.', 'betterdocs' ),
19 24 'type' => 'string',
20 25 ],
21 26 ] );
22 27 $this->get( 'search-insert', [$this, 'search_insert'] );
@@ -23,9 +28,9 @@
23 28 $this->get( 'get-terms', [$this, 'get_terms_name_and_slug'] );
24 29 $this->get( 'months-with-posts', [$this, 'get_months_with_posts'] );
25 30 $this->get( 'order_docs', [$this, 'render_betterdocs_order_docs'], [
26 31 'password' => [
27 - 'description' => __( 'The password for password-protected docs.' ),
32 + 'description' => __( 'The password for password-protected docs.', 'betterdocs' ),
28 33 'type' => 'string',
29 34 ],
30 35 ] );
31 36 $this->register_field( 'docs', 'year_month', [
@@ -70,9 +75,9 @@
70 75
71 76 $posts = betterdocs()->query->get_posts( $args, true );
72 77
73 78 if ( ! $posts->have_posts() ) {
74 - wp_reset_query();
79 + wp_reset_postdata();
75 80 }
76 81
77 82 $post_datas = [];
78 83
@@ -92,9 +97,9 @@
92 97 array_push( $post_datas, $post_data );
93 98 endwhile;
94 99
95 100 wp_reset_postdata();
96 - wp_reset_query();
101 + wp_reset_postdata();
97 102
98 103 return $post_datas;
99 104 }
100 105
@@ -100,18 +105,27 @@
100 105
101 106 public function get_docs_faq_counts() {
102 107 // Initialize the return array
103 108 $counts = [
104 - 'created_docs' => 0,
105 - 'published_docs' => 0,
106 - 'created_faq' => 0,
107 - 'published_faq' => 0
109 + 'created_docs' => 0,
110 + 'published_docs' => 0,
111 + 'created_faq' => 0,
112 + 'published_faq' => 0,
113 + 'created_product_faq' => 0,
114 + 'published_product_faq' => 0
108 115 ];
109 116
110 - // Get all docs (any status)
117 + // QA-004: this endpoint is public (Docs::permission_check() returns true),
118 + // so the draft-inclusive "created" totals must not leak unpublished-content
119 + // volume to anonymous / low-privilege callers. Only users who can edit
120 + // others' posts see the any-status counts; everyone else gets published
121 + // counts (created_* == published_*).
122 + $created_status = current_user_can( 'edit_others_posts' ) ? 'any' : 'publish';
123 +
124 + // Get all docs (created = any status for privileged users, else published)
111 125 $all_docs_query = new WP_Query([
112 126 'post_type' => 'docs',
113 - 'post_status' => 'any',
127 + 'post_status' => $created_status,
114 128 'posts_per_page' => -1,
115 129 'fields' => 'ids',
116 130 'no_found_rows' => true,
117 131 ]);
@@ -126,19 +140,49 @@
126 140 'no_found_rows' => true,
127 141 ]);
128 142 $counts['published_docs'] = $published_docs_query->post_count;
129 143
130 - // Get all FAQs (any status)
144 + // Product FAQ groups share the betterdocs_faq post type but live in the
145 + // betterdocs_product_faq_category taxonomy. Split the counts so the
146 + // General FAQ Builder stats exclude Product FAQs (no leakage) and the
147 + // WooCommerce tab can show its own Product FAQ totals.
148 + $product_terms = get_terms([
149 + 'taxonomy' => 'betterdocs_product_faq_category',
150 + 'hide_empty' => false,
151 + 'fields' => 'ids',
152 + ]);
153 + $product_terms = ( ! is_wp_error( $product_terms ) && ! empty( $product_terms ) ) ? $product_terms : [];
154 +
155 + $exclude_product_tax_query = ! empty( $product_terms ) ? [
156 + [
157 + 'taxonomy' => 'betterdocs_product_faq_category',
158 + 'field' => 'term_id',
159 + 'terms' => $product_terms,
160 + 'operator' => 'NOT IN',
161 + ],
162 + ] : [];
163 +
164 + $include_product_tax_query = ! empty( $product_terms ) ? [
165 + [
166 + 'taxonomy' => 'betterdocs_product_faq_category',
167 + 'field' => 'term_id',
168 + 'terms' => $product_terms,
169 + 'operator' => 'IN',
170 + ],
171 + ] : [];
172 +
173 + // General FAQs (created = any status for privileged users), excluding Product FAQs.
131 174 $all_faq_query = new WP_Query([
132 175 'post_type' => 'betterdocs_faq',
133 - 'post_status' => 'any',
176 + 'post_status' => $created_status,
134 177 'posts_per_page' => -1,
135 178 'fields' => 'ids',
136 179 'no_found_rows' => true,
180 + 'tax_query' => $exclude_product_tax_query,
137 181 ]);
138 182 $counts['created_faq'] = $all_faq_query->post_count;
139 183
140 - // Get published FAQs only
184 + // Published General FAQs, excluding Product FAQs.
141 185 $published_faq_query = new WP_Query([
142 186 'post_type' => 'betterdocs_faq',
143 187 'post_status' => 'publish',
144 188 'posts_per_page' => -1,
@@ -143,11 +187,36 @@
143 187 'post_status' => 'publish',
144 188 'posts_per_page' => -1,
145 189 'fields' => 'ids',
146 190 'no_found_rows' => true,
191 + 'tax_query' => $exclude_product_tax_query,
147 192 ]);
148 193 $counts['published_faq'] = $published_faq_query->post_count;
149 194
195 + if ( ! empty( $product_terms ) ) {
196 + // Product FAQs (created = any status for privileged users).
197 + $all_product_faq_query = new WP_Query([
198 + 'post_type' => 'betterdocs_faq',
199 + 'post_status' => $created_status,
200 + 'posts_per_page' => -1,
201 + 'fields' => 'ids',
202 + 'no_found_rows' => true,
203 + 'tax_query' => $include_product_tax_query,
204 + ]);
205 + $counts['created_product_faq'] = $all_product_faq_query->post_count;
206 +
207 + // Published Product FAQs.
208 + $published_product_faq_query = new WP_Query([
209 + 'post_type' => 'betterdocs_faq',
210 + 'post_status' => 'publish',
211 + 'posts_per_page' => -1,
212 + 'fields' => 'ids',
213 + 'no_found_rows' => true,
214 + 'tax_query' => $include_product_tax_query,
215 + ]);
216 + $counts['published_product_faq'] = $published_product_faq_query->post_count;
217 + }
218 +
150 219 return $counts;
151 220 }
152 221
153 222 /**
@@ -262,8 +331,9 @@
262 331 public function get_months_with_posts() {
263 332 global $wpdb;
264 333
265 334 // Query to get distinct year and month from posts of type 'docs'
335 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- aggregation across the posts table; no user input.
266 336 $results = $wpdb->get_results(
267 337 "SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month
268 338 FROM $wpdb->posts
269 339 WHERE post_type = 'docs'
@@ -354,17 +424,17 @@
354 424 }
355 425 }
356 426
357 427 public function search_posts( $request ) {
358 - $search_query = sanitize_text_field( $request->get_param( 's' ) );
359 - $doc_category = sanitize_text_field( $request->get_param( 'doc_category' ) );
360 - $kb_slug = sanitize_text_field( $request->get_param( 'knowledge_base' ) );
361 - $number = (int) $request->get_param( 'per_page' ) ? (int) $request->get_param( 'per_page' ) : 5;
362 - $docs_ids = ! empty( $request->get_param( 'doc_ids' ) ) ? explode( ',', $request->get_param( 'doc_ids' ) ) : [];
363 - $doc_term_ids = ! empty( $request->get_param( 'doc_categories_ids' ) ) ? explode( ',', $request->get_param( 'doc_categories_ids' ) ) : [];
364 - $faq_term_ids = ! empty( $request->get_param( 'faq_categories_ids' ) ) ? explode( ',', $request->get_param( 'faq_categories_ids' ) ) : [];
365 - $posts = array();
366 - $post_status = ['publish'];
428 + $search_query = sanitize_text_field( $request->get_param( 's' ) );
429 + $doc_category = sanitize_text_field( $request->get_param( 'doc_category' ) );
430 + $kb_slug = sanitize_text_field( $request->get_param( 'knowledge_base' ) );
431 + $number = (int) $request->get_param( 'per_page' ) ? (int) $request->get_param( 'per_page' ) : 5;
432 + $docs_ids = ! empty( $request->get_param( 'doc_ids' ) ) ? explode( ',', $request->get_param( 'doc_ids' ) ) : [];
433 + $doc_term_ids = ! empty( $request->get_param( 'doc_categories_ids' ) ) ? explode( ',', $request->get_param( 'doc_categories_ids' ) ) : [];
434 + $faq_term_ids = ! empty( $request->get_param( 'faq_categories_ids' ) ) ? explode( ',', $request->get_param( 'faq_categories_ids' ) ) : [];
435 + $posts = array();
436 + $post_status = ['publish'];
367 437
368 438 if( current_user_can( 'read_private_docs' ) ) {
369 439 array_push($post_status, 'private');
370 440 }
@@ -371,9 +441,9 @@
371 441
372 442 // Common query args
373 443 $common_args = [
374 444 'post_status' => $post_status,
375 - 'suppress_filters' => true,
445 + 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Hooks.PreGetPosts.PreGetPosts,WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- search bypasses content filters; WPML override below.
376 446 'orderby' => 'relevance',
377 447 ];
378 448
379 449 // Exclude password-protected posts unless user has permission
@@ -387,9 +457,9 @@
387 457 // search across all languages to find translated posts
388 458 if ( $search_query && preg_match('/[^\x00-\x7F]/', $search_query) ) {
389 459 // Non-ASCII search: bypass ALL filters including WPML language filtering
390 460 // This allows searching across all languages
391 - $common_args['suppress_filters'] = true;
461 + $common_args['suppress_filters'] = true; // phpcs:ignore WordPressVIPMinimum.Hooks.PreGetPosts.PreGetPosts,WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- non-ASCII search must reach all WPML translations.
392 462 } else {
393 463 // ASCII-only search (English), use WPML filters to restrict to current language
394 464 $common_args['suppress_filters'] = false;
395 465 $common_args['lang'] = ICL_LANGUAGE_CODE;
@@ -397,9 +467,14 @@
397 467 }
398 468
399 469 if ( $search_query ) {
400 470 $common_args['s'] = $search_query;
401 - $common_args['posts_per_page'] = -1;
471 + // Respect per_page from the client; cap at 50 so a slow LIKE query can't load thousands of rows.
472 + $common_args['posts_per_page'] = $number > 0 ? min( $number, 50 ) : 20;
473 +
474 + // SearchExtender's posts_search filter must run so docs with matching
475 + // tag/category term names are included in results.
476 + $common_args['suppress_filters'] = false;
402 477 } else {
403 478 $common_args['posts_per_page'] = $number;
404 479 }
405 480
@@ -483,12 +558,12 @@
483 558 ]
484 559 ];
485 560 }
486 561
487 - // Run individual queries
488 562 $docs_query = betterdocs()->query->get_posts( $docs_args );
489 - $faq_query = new WP_Query( $faq_args );
490 563
564 + $faq_query = new WP_Query( $faq_args );
565 +
491 566 // Process docs posts
492 567 if ( $docs_query->have_posts() ) {
493 568 while ( $docs_query->have_posts() ) {
494 569 $docs_query->the_post();
@@ -514,8 +589,9 @@
514 589 $permalink = get_the_permalink( $post_id );
515 590
516 591 // If WPML is active and post language differs from site language, add lang parameter
517 592 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
593 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML public integration filter.
518 594 $post_language = apply_filters( 'wpml_element_language_code', null, array( 'element_id' => $post_id, 'element_type' => 'post_docs' ) );
519 595
520 596 if ( $post_language ) {
521 597 global $sitepress;
@@ -564,15 +640,20 @@
564 640 $current_lang = strtolower( $TRP_LANGUAGE );
565 641
566 642 // Only query translation if not on default language
567 643 if ( $default_lang !== $current_lang ) {
568 - $trp_table = $wpdb->prefix . 'trp_dictionary_' . $default_lang . '_' . $current_lang;
569 -
570 - // Query the translation dictionary for this title
644 + $default_lang = preg_replace( '/[^a-z0-9_]/', '', $default_lang );
645 + $current_lang = preg_replace( '/[^a-z0-9_]/', '', $current_lang );
646 + $trp_table = $wpdb->prefix . 'trp_dictionary_' . $default_lang . '_' . $current_lang;
647 +
648 + // Query the translation dictionary for this title.
649 + // $trp_table is composed from $wpdb->prefix + sanitized lang slugs (preg_replace allowlist above), safe to interpolate.
650 + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- TranslatePress dynamic dictionary table; cache would defeat live translation lookup.
571 651 $translated = $wpdb->get_var( $wpdb->prepare(
572 652 "SELECT translated FROM {$trp_table} WHERE original = %s AND status != 2 LIMIT 1",
573 653 $title
574 654 ) );
655 + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
575 656
576 657 if ( $translated && ! empty( $translated ) ) {
577 658 $title = $translated;
578 659 }
@@ -622,9 +703,27 @@
622 703 public function search_insert( $request ) {
623 704 $search_input = sanitize_text_field( $request->get_param( 's' ) );
624 705 $no_result = sanitize_text_field( $request->get_param( 'no_result' ) );
625 706
626 - return betterdocs()->query->insert_search_keyword( $search_input, $no_result );
707 + $result = betterdocs()->query->insert_search_keyword( $search_input, $no_result );
708 +
709 + if ( ! empty( $search_input ) ) {
710 + /**
711 + * Fires after a front-end docs search is logged.
712 + *
713 + * Pro hooks this to record a 'search' event into the raw events
714 + * table, which the Advanced Analytics aggregator rolls up into
715 + * betterdocs_analytics_search (search volume + zero-result rate —
716 + * the primary content-gap signal).
717 + *
718 + * @param string $search_input The search term.
719 + * @param bool $no_result True when the search returned no results.
720 + * @param WP_REST_Request $request The ingest request.
721 + */
722 + do_action( 'betterdocs_analytics_search_recorded', $search_input, ! empty( $no_result ), $request );
723 + }
724 +
725 + return $result;
627 726 }
628 727
629 728
630 729 public function get_terms_name_and_slug( $request ) {