PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.5.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.5.3
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 3.5.2 All 199 releases
betterdocs / includes / REST / Docs.php

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

687 lines 22.2 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\REST;
4
5 use Error;
6 use WP_Query;
7 use WP_REST_Response;
8 use WPDeveloper\BetterDocs\Core\BaseAPI;
9
10 class Docs extends BaseAPI {
11 public function permission_check(): bool {
12 return true;
13 }
14
15 public function register() {
16 $this->get( 'search', [$this, 'search_posts'], [
17 'password' => [
18 'description' => __( 'The password for password-protected docs.' ),
19 'type' => 'string',
20 ],
21 ] );
22 $this->get( 'search-insert', [$this, 'search_insert'] );
23 $this->get( 'get-terms', [$this, 'get_terms_name_and_slug'] );
24 $this->get( 'months-with-posts', [$this, 'get_months_with_posts'] );
25 $this->get( 'order_docs', [$this, 'render_betterdocs_order_docs'], [
26 'password' => [
27 'description' => __( 'The password for password-protected docs.' ),
28 'type' => 'string',
29 ],
30 ] );
31 $this->register_field( 'docs', 'year_month', [
32 'get_callback' => [$this, 'year_month']
33 ] );
34
35 $this->register_field(
36 'docs',
37 'password',
38 [
39 'get_callback' => [ $this, 'get_post_password' ]
40 ]
41 );
42
43 add_filter( 'rest_docs_query', [ $this, 'filter_docs_query' ], 10, 2 );
44 $this->get( 'docs-faq-count', [ $this, 'get_docs_faq_counts' ] );
45 }
46
47 public function render_betterdocs_order_docs($request) {
48 $doc_category = $request->get_param('doc_category');
49 $order = $request->get_param('order');
50 $orderby = $request->get_param('orderby');
51 $per_page = $request->get_param('per_page');
52
53 if( empty( $doc_category) ) {
54 return [];
55 }
56
57 $args = [
58 'term_id' => $doc_category,
59 'orderby' => $orderby,
60 'order' => $order,
61 'posts_per_page' => $per_page
62 ];
63
64 $args = betterdocs()->query->docs_query_args($args);
65
66 // Exclude password-protected posts unless user has permission or provided password
67 if ( ! current_user_can( 'edit_posts' ) ) {
68 $args['has_password'] = false;
69 }
70
71 $posts = betterdocs()->query->get_posts( $args, true );
72
73 if ( ! $posts->have_posts() ) {
74 wp_reset_query();
75 }
76
77 $post_datas = [];
78
79 while ( $posts->have_posts() ):
80 $posts->the_post();
81 $post_obj = get_post( get_the_ID() );
82
83 // Double-check password protection for individual posts
84 if ( ! empty( $post_obj->post_password ) ) {
85 $can_access = $this->can_access_password_content( $post_obj, $request );
86 if ( ! $can_access ) {
87 continue; // Skip this post
88 }
89 }
90
91 $post_data = $this->get_doc_data( get_the_ID(), $request );
92 array_push( $post_datas, $post_data );
93 endwhile;
94
95 wp_reset_postdata();
96 wp_reset_query();
97
98 return $post_datas;
99 }
100
101 public function get_docs_faq_counts() {
102 // Initialize the return array
103 $counts = [
104 'created_docs' => 0,
105 'published_docs' => 0,
106 'created_faq' => 0,
107 'published_faq' => 0
108 ];
109
110 // Get all docs (any status)
111 $all_docs_query = new WP_Query([
112 'post_type' => 'docs',
113 'post_status' => 'any',
114 'posts_per_page' => -1,
115 'fields' => 'ids',
116 'no_found_rows' => true,
117 ]);
118 $counts['created_docs'] = $all_docs_query->post_count;
119
120 // Get published docs only
121 $published_docs_query = new WP_Query([
122 'post_type' => 'docs',
123 'post_status' => 'publish',
124 'posts_per_page' => -1,
125 'fields' => 'ids',
126 'no_found_rows' => true,
127 ]);
128 $counts['published_docs'] = $published_docs_query->post_count;
129
130 // Get all FAQs (any status)
131 $all_faq_query = new WP_Query([
132 'post_type' => 'betterdocs_faq',
133 'post_status' => 'any',
134 'posts_per_page' => -1,
135 'fields' => 'ids',
136 'no_found_rows' => true,
137 ]);
138 $counts['created_faq'] = $all_faq_query->post_count;
139
140 // Get published FAQs only
141 $published_faq_query = new WP_Query([
142 'post_type' => 'betterdocs_faq',
143 'post_status' => 'publish',
144 'posts_per_page' => -1,
145 'fields' => 'ids',
146 'no_found_rows' => true,
147 ]);
148 $counts['published_faq'] = $published_faq_query->post_count;
149
150 return $counts;
151 }
152
153 /**
154 * Get Doc Data Based On Doc ID
155 *
156 * @param int $id Post ID
157 * @param WP_REST_Request $request REST request object
158 * @return array
159 */
160 public function get_doc_data( $id, $request = null ) {
161 $post_data = get_post( $id );
162
163 // Check if user can access password-protected content
164 $can_access_password_content = $this->can_access_password_content( $post_data, $request );
165
166 $data = [
167 'author' => (int) $post_data->post_author,
168 'author_info' => [
169 'name' => get_the_author_meta( 'display_name', $post_data->post_author ),
170 'author_nicename' => get_the_author_meta( 'nicename', $post_data->post_author ),
171 'author_url' => get_author_posts_url( $post_data->post_author )
172 ],
173 'unique_id' => uniqid( 'doc' ),
174 'id' => $post_data->ID,
175 'title' => [
176 'rendered' => $post_data->post_title
177 ],
178 'slug' => get_post_field( 'post_name', $id ),
179 'link' => get_permalink( $id ),
180 'status' => get_post_status(),
181 'date' => $post_data->post_date,
182 'date_gmt' => $post_data->post_date_gmt,
183 'doc_category' => wp_get_post_terms( $id, 'doc_category', ["fields" => "ids"] ),
184 'doc_tag' => wp_get_post_terms( $id, 'doc_tag', ["fields" => "ids"] ),
185 'comment_status' => $post_data->comment_status
186 ];
187
188 // Only include password field if user has edit permissions
189 if ( current_user_can( 'edit_post', $id ) ) {
190 $data['password'] = $post_data->post_password;
191 }
192
193 // Add password protection indicator
194 if ( ! empty( $post_data->post_password ) ) {
195 $data['password_protected'] = true;
196
197 // If user cannot access password-protected content, hide sensitive data
198 if ( ! $can_access_password_content ) {
199 // Keep basic info but indicate it's protected
200 $data['title']['rendered'] = $post_data->post_title; // WordPress doesn't prefix in REST API
201 $data['excerpt'] = ''; // Hide excerpt for password-protected posts
202 }
203 } else {
204 $data['password_protected'] = false;
205 }
206
207 if ( taxonomy_exists( 'knowledge_base' ) ) {
208 $data['knowledge_base'] = wp_get_post_terms( $id, 'knowledge_base', ["fields" => "ids"] );
209 }
210
211 return $data;
212 }
213
214 /**
215 * Checks if the user can access password-protected content.
216 *
217 * This method determines whether we need to override the regular password
218 * check in core with a filter.
219 *
220 * @param WP_Post $post Post to check against.
221 * @param WP_REST_Request $request Request data to check.
222 * @return bool True if the user can access password-protected content, otherwise false.
223 */
224 public function can_access_password_content( $post, $request ) {
225 if ( empty( $post->post_password ) ) {
226 // No filter required.
227 return true;
228 }
229
230 /*
231 * Users always get access to password protected content if they have
232 * the `edit_post` meta capability.
233 */
234 if ( current_user_can( 'edit_post', $post->ID ) ) {
235 return true;
236 }
237
238 // No password provided in request, no auth.
239 if ( empty( $request ) || empty( $request['password'] ) ) {
240 return false;
241 }
242
243 // Double-check the request password.
244 return hash_equals( $post->post_password, $request['password'] );
245 }
246
247 /**
248 * Retrieves the months and years that have posts of the type 'docs' and formats them.
249 *
250 * This function queries the WordPress database for all unique months and years
251 * in which 'docs' post type posts have been published. The results are then
252 * formatted into an array of associative arrays, where each entry contains an
253 * 'id' and a 'name'.
254 *
255 * The 'id' is a string formatted as 'month-year' (e.g., 'may-2024') to provide
256 * a unique identifier that is easy to work with in JavaScript and HTML. The 'name'
257 * is a more human-readable string formatted as 'Month Year' (e.g., 'May 2024') to
258 * display to users.
259 *
260 * @return WP_REST_Response A response containing the formatted months and years.
261 */
262 public function get_months_with_posts() {
263 global $wpdb;
264
265 // Query to get distinct year and month from posts of type 'docs'
266 $results = $wpdb->get_results(
267 "SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month
268 FROM $wpdb->posts
269 WHERE post_type = 'docs'
270 ORDER BY post_date DESC"
271 );
272
273 $formatted_months = [];
274
275 foreach ( $results as $result ) {
276 $year = $result->year;
277 $month = $result->month;
278
279 // Create a DateTime object to format the month
280 $date = \DateTime::createFromFormat( '!m', $month );
281 $month_name = $date->format( 'F' ); // Full month name
282 $month_number = $date->format( 'm' ); // Month number with leading zero
283
284 // Format the months and years into wp rest api structure like wp-json/wp/v2/doc_category
285 $formatted_months[] = [
286 'id' => "$year-$month_number", // e.g., '2024-05'
287 'name' => "$month_name $year" // e.g., 'May 2024'
288 ];
289 }
290
291 return rest_ensure_response( $formatted_months );
292 }
293
294 /**
295 * Callback function to retrieve 'year_month' field value.
296 *
297 * @param object $post The REST API response object.
298 * @return string The formatted date (e.g., '2024-05').
299 */
300 public function year_month( $post ) {
301 $date_string = isset( $post->post_date ) ? $post->post_date : '';
302
303 $date = new \DateTime( $date_string );
304
305 // Format the date to 'Y-m' (e.g., '2024-05')
306 $formatted_date = $date->format( 'Y-m' );
307
308 return $formatted_date;
309 }
310
311 /**
312 * Filter the docs query by year_month and status parameters.
313 *
314 * @param array $args The query arguments.
315 * @param WP_REST_Request $request The current REST API request.
316 * @return array Modified query arguments.
317 */
318 public function filter_docs_query( $args, $request ) {
319 // Filter by year_month
320 if ( isset( $request['year_month'] ) ) {
321 $formatted_date = $request['year_month'];
322
323 // Parse the formatted_date to year and month
324 $year = substr( $formatted_date, 0, 4 );
325 $month = substr( $formatted_date, 5, 2 );
326
327 // Add date query arguments
328 $args['date_query'] = [
329 [
330 'year' => $year,
331 'month' => $month
332 ]
333 ];
334 }
335
336 // Filter by post status
337 // When status is 'any' and user has edit_docs capability, show all post statuses
338 if ( isset( $request['status'] ) && $request['status'] === 'any' && current_user_can( 'edit_docs' ) ) {
339 $args['post_status'] = [ 'publish', 'draft', 'pending', 'private', 'future' ];
340 }
341
342 return $args;
343 }
344
345
346 public function get_post_password( $object, $field_name, $request ) {
347 // Suppress unused parameter warnings
348 unset( $field_name, $request );
349
350 if ( current_user_can( 'edit_docs' ) ) {
351 return isset( $object['password'] ) ? $object['password'] : '';
352 } else {
353 return '';
354 }
355 }
356
357 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'];
367
368 if( current_user_can( 'read_private_docs' ) ) {
369 array_push($post_status, 'private');
370 }
371
372 // Common query args
373 $common_args = [
374 'post_status' => $post_status,
375 'suppress_filters' => true,
376 'orderby' => 'relevance',
377 ];
378
379 // Exclude password-protected posts unless user has permission
380 if ( ! current_user_can( 'edit_posts' ) ) {
381 $common_args['has_password'] = false;
382 }
383
384 // Handle WPML multilingual search
385 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
386 // If search term contains non-ASCII characters (e.g., Chinese, Japanese, Bangla),
387 // search across all languages to find translated posts
388 if ( $search_query && preg_match('/[^\x00-\x7F]/', $search_query) ) {
389 // Non-ASCII search: bypass ALL filters including WPML language filtering
390 // This allows searching across all languages
391 $common_args['suppress_filters'] = true;
392 } else {
393 // ASCII-only search (English), use WPML filters to restrict to current language
394 $common_args['suppress_filters'] = false;
395 $common_args['lang'] = ICL_LANGUAGE_CODE;
396 }
397 }
398
399 if ( $search_query ) {
400 $common_args['s'] = $search_query;
401 // Respect per_page from the client; cap at 50 so a slow LIKE query can't load thousands of rows.
402 $common_args['posts_per_page'] = $number > 0 ? min( $number, 50 ) : 20;
403
404 // SearchExtender's posts_search filter must run so docs with matching
405 // tag/category term names are included in results.
406 $common_args['suppress_filters'] = false;
407 } else {
408 $common_args['posts_per_page'] = $number;
409 }
410
411 // Docs-specific query
412 $docs_args = array_merge(
413 $common_args,
414 [
415 'post_type' => 'docs'
416 ]
417 );
418
419 if ( ! $search_query ) {
420 // Use date ordering when KB filter is present to avoid analytics query conflicts
421 if ( ! empty( $kb_slug ) ) {
422 $docs_args['orderby'] = 'date';
423 $docs_args['order'] = 'DESC';
424 } else {
425 $docs_args['meta_key'] = '_betterdocs_meta_views';
426 $docs_args['orderby'] = 'meta_value_num';
427 $docs_args['order'] = 'DESC';
428 }
429 }
430
431 if ( ! empty( $docs_ids ) ) {
432 unset( $docs_args['meta_key'] );
433 $docs_args['posts_per_page'] = -1;
434 $docs_args['post__in'] = $docs_ids;
435 }
436
437 if ( ! empty( $doc_term_ids ) ) {
438 unset( $docs_args['meta_key'] );
439 $docs_args['posts_per_page'] = -1;
440 $docs_args['tax_query'] = [
441 [
442 'taxonomy' => 'doc_category',
443 'field' => 'term_id',
444 'terms' => $doc_term_ids,
445 'operator' => 'IN',
446 ]
447 ];
448 }
449
450 // Taxonomy filter for docs
451 if ( $doc_category ) {
452 $docs_args['tax_query'] = [
453 [
454 'taxonomy' => 'doc_category',
455 'field' => 'slug',
456 'terms' => $doc_category,
457 'operator' => 'AND',
458 'include_children' => true,
459 ],
460 ];
461 }
462
463 // Knowledge base filter for docs
464 // Pass kb_slug in args to let MultipleKB filter handle it (avoid duplicate filters)
465 if ( ! empty( $kb_slug ) && taxonomy_exists( 'knowledge_base' ) ) {
466 $docs_args['kb_slug'] = $kb_slug;
467 }
468
469
470 // FAQ-specific query
471 $faq_args = array_merge(
472 $common_args,
473 [
474 'post_type' => 'betterdocs_faq',
475 'orderby' => 'date',
476 'order' => 'DESC',
477 ]
478 );
479
480 if ( ! empty( $faq_term_ids ) ) {
481 $faq_args['posts_per_page'] = -1;
482 $faq_args['tax_query'] = [
483 [
484 'taxonomy' => 'betterdocs_faq_category',
485 'field' => 'term_id',
486 'terms' => $faq_term_ids,
487 'operator' => 'IN',
488 ]
489 ];
490 }
491
492 $docs_query = betterdocs()->query->get_posts( $docs_args );
493
494 $faq_query = new WP_Query( $faq_args );
495
496 // Process docs posts
497 if ( $docs_query->have_posts() ) {
498 while ( $docs_query->have_posts() ) {
499 $docs_query->the_post();
500
501 $post_obj = get_post( get_the_ID() );
502
503 // Check if user can access password-protected content
504 $can_access = $this->can_access_password_content( $post_obj, $request );
505
506 // Skip password-protected posts if user cannot access them
507 if ( ! empty( $post_obj->post_password ) && ! $can_access ) {
508 continue;
509 }
510
511 $taxonomies = array();
512 $terms = get_the_terms( get_the_ID(), 'doc_category' );
513 if ( $terms && ! is_wp_error( $terms ) ) {
514 $taxonomies = wp_list_pluck( $terms, 'name' );
515 }
516
517 // Get the correct permalink with language parameter if needed
518 $post_id = get_the_ID();
519 $permalink = get_the_permalink( $post_id );
520
521 // If WPML is active and post language differs from site language, add lang parameter
522 if ( is_plugin_active( 'sitepress-multilingual-cms/sitepress.php' ) ) {
523 $post_language = apply_filters( 'wpml_element_language_code', null, array( 'element_id' => $post_id, 'element_type' => 'post_docs' ) );
524
525 if ( $post_language ) {
526 global $sitepress;
527 $current_lang = $sitepress ? $sitepress->get_current_language() : '';
528
529 // If post language is different from current site language, add language parameter
530 if ( $post_language !== $current_lang ) {
531 $permalink = add_query_arg( 'lang', $post_language, $permalink );
532 }
533 }
534 }
535 // Handle TranslatePress permalinks
536 elseif ( class_exists( '\TRP_Translate_Press' ) ) {
537 global $TRP_LANGUAGE;
538 $trp = \TRP_Translate_Press::get_trp_instance();
539 if ( isset( $trp ) && method_exists( $trp, 'get_component' ) ) {
540 $trp_settings = $trp->get_component( 'settings' );
541 $trp_url_converter = $trp->get_component( 'url_converter' );
542
543 if ( $trp_settings && $trp_url_converter && isset( $TRP_LANGUAGE ) ) {
544 $settings = $trp_settings->get_settings();
545 $default_lang = isset( $settings['default-language'] ) ? $settings['default-language'] : 'en_US';
546
547 // If we're not on the default language, ensure the URL has the language prefix
548 if ( $TRP_LANGUAGE && $TRP_LANGUAGE !== $default_lang ) {
549 $permalink = $trp_url_converter->get_url_for_language( $TRP_LANGUAGE, $permalink );
550 // Remove the #TRPLINKPROCESSED marker that TranslatePress adds
551 $permalink = str_replace( '#TRPLINKPROCESSED', '', $permalink );
552 }
553 }
554 }
555 }
556
557
558 // Get the title - apply TranslatePress translation if active
559 $title = get_the_title();
560 if ( class_exists( '\TRP_Translate_Press' ) ) {
561 global $TRP_LANGUAGE, $wpdb;
562 if ( isset( $TRP_LANGUAGE ) ) {
563 $trp = \TRP_Translate_Press::get_trp_instance();
564 if ( isset( $trp ) && method_exists( $trp, 'get_component' ) ) {
565 $trp_settings = $trp->get_component( 'settings' );
566 if ( $trp_settings ) {
567 $settings = $trp_settings->get_settings();
568 $default_lang = isset( $settings['default-language'] ) ? strtolower( $settings['default-language'] ) : 'en_us';
569 $current_lang = strtolower( $TRP_LANGUAGE );
570
571 // Only query translation if not on default language
572 if ( $default_lang !== $current_lang ) {
573 $trp_table = $wpdb->prefix . 'trp_dictionary_' . $default_lang . '_' . $current_lang;
574
575 // Query the translation dictionary for this title
576 $translated = $wpdb->get_var( $wpdb->prepare(
577 "SELECT translated FROM {$trp_table} WHERE original = %s AND status != 2 LIMIT 1",
578 $title
579 ) );
580
581 if ( $translated && ! empty( $translated ) ) {
582 $title = $translated;
583 }
584 }
585 }
586 }
587 }
588 }
589
590 $posts[] = array(
591 'title' => $title,
592 'post_type' => get_post_type(),
593 'permalink' => $permalink,
594 'taxonomies' => implode( ', ', $taxonomies ),
595 );
596 }
597 wp_reset_postdata();
598 }
599
600 // Process FAQ posts with content
601 if ( $faq_query->have_posts() ) {
602 while ( $faq_query->have_posts() ) {
603 $faq_query->the_post();
604
605 $terms = get_the_terms( get_the_ID(), 'betterdocs_faq_category' );
606 $taxonomies = array();
607 if ( $terms && ! is_wp_error( $terms ) ) {
608 $taxonomies = wp_list_pluck( $terms, 'name' );
609 }
610
611 $posts[] = array(
612 'title' => get_the_title(),
613 'content' => get_the_content(), // Include post content for FAQ posts
614 'post_type' => get_post_type(),
615 'permalink' => get_the_permalink(),
616 'taxonomies' => implode( ', ', $taxonomies ),
617 );
618 }
619 wp_reset_postdata();
620 }
621
622 return $posts;
623 }
624
625
626
627 public function search_insert( $request ) {
628 $search_input = sanitize_text_field( $request->get_param( 's' ) );
629 $no_result = sanitize_text_field( $request->get_param( 'no_result' ) );
630
631 return betterdocs()->query->insert_search_keyword( $search_input, $no_result );
632 }
633
634
635 public function get_terms_name_and_slug( $request ) {
636 $default_params = [
637 'taxonomy' => $request->get_param( 'taxonomy' ),
638 'hide_empty' => false,
639 'fields' => 'all',
640 ];
641
642 if ( betterdocs()->settings->get( 'child_category_exclude' ) ) { //disable child terms if this is enabled
643 $default_params['parent'] = 0;
644 }
645
646 // Add KB filtering if knowledge_base parameter is provided
647 $kb_slug = $request->get_param( 'knowledge_base' );
648 if ( ! empty( $kb_slug ) && $request->get_param( 'taxonomy' ) === 'doc_category' ) {
649 // Categories can belong to multiple KBs (stored as serialized array in doc_category_knowledge_base)
650 // We need to filter categories that have the KB slug in their serialized array
651 $default_params['meta_query'] = [
652 [
653 'key' => 'doc_category_knowledge_base',
654 'value' => serialize(strval($kb_slug)),
655 'compare' => 'LIKE'
656 ]
657 ];
658 }
659
660 // Retrieve all terms for the specified taxonomy, including empty ones
661 $terms = get_terms($default_params);
662
663 // Initialize an empty array to hold the term data
664 $term_data = [];
665
666 // Loop through each term and extract the name and slug
667 $term_data = array_map(
668 function ( $term ) {
669 return [
670 'name' => $term->name,
671 'slug' => $term->slug,
672 'parent' => $term->parent,
673 ];
674 },
675 $terms
676 );
677
678 // Return the array of term data
679 return $term_data;
680 }
681
682 public function get_faq_categories( $request ) {
683 // Suppress unused parameter warning
684 unset( $request );
685 }
686 }
687