PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 3.8.9
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v3.8.9
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/DocCategories.php +124 -346 4.8.23.8.9 View file →
@@ -1,375 +1,153 @@
1 1 <?php
2 -// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- core docs taxonomy REST endpoints; meta filtering required.
3 -// phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- endpoint exposes user-driven exclusion.
4 -// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table identifiers (WP-provided) and dynamic %s placeholders are intentional.
5 -// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- the uncategorized-docs scan needs raw SQL because WP_Query has no NOT-IN-via-subquery primitive.
6 -// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- list is rebuilt per-request from live post/term state; cache would mask uncategorized status changes.
2 +
7 3 namespace WPDeveloper\BetterDocs\REST;
8 4
9 -use stdClass;
10 5 use WPDeveloper\BetterDocs\Core\BaseAPI;
11 -use WPDeveloper\BetterDocs\Utils\Helper;
12 6
13 7 class DocCategories extends BaseAPI {
14 - public function permission_check(): bool {
15 - return true;
16 - // return current_user_can( 'edit_docs' );
17 - }
8 + public function permission_check(): bool {
9 + return current_user_can( 'edit_docs' );
10 + }
18 11
19 - public function register() {
20 - $this->get( 'doc-categories', array( $this, 'get_response' ), array(
21 - 'password' => array(
22 - 'description' => __( 'The password for password-protected docs.', 'betterdocs' ),
23 - 'type' => 'string'
24 - )
25 - ) );
26 - $this->get( 'doc-categories-kb', array( $this, 'doc_categories_kb_response' ) );
27 - }
12 + public function register() {
13 + $this->get( 'doc-categories', [ $this, 'get_response' ] );
14 + }
28 15
29 - public function doc_categories_kb_response( $request ) {
30 - $mkb = ! empty( $request->get_param( 'knowledge_base' ) ) ? get_term( $request->get_param( 'knowledge_base' ), 'knowledge_base' ) : '';
31 - $mkb = ! empty( $mkb ) ? $mkb->slug : '';
32 - $suppress_filters = ! empty( $request->get_param( 'suppress_filters' ) ) ? $request->get_param( 'suppress_filters' ) : '';
16 + public function get_response( $request ) {
17 + global $wpdb;
33 18
34 - $terms_query = betterdocs()->query->terms_query(
35 - array(
36 - 'parent' => 0,
37 - 'hide_empty' => true,
38 - 'taxonomy' => 'doc_category',
39 - 'orderby' => 'betterdocs_order',
40 - 'order' => 'ASC'
41 - )
42 - );
19 + $mkb = $request->get_param( 'knowledge_base' );
43 20
44 - if ( ! empty( $suppress_filters ) ) {
45 - $terms_query[ 'suppress_filters' ] = $suppress_filters;
46 - }
21 + $terms_query = betterdocs()->query->terms_query(
22 + [
23 + 'hide_empty' => false,
24 + 'taxonomy' => 'doc_category',
25 + 'orderby' => 'meta_value_num',
26 + 'meta_key' => 'doc_category_order',
27 + 'order' => 'ASC'
28 + ]
29 + );
47 30
48 - if ( ! empty( $mkb ) ) {
49 - $terms_query[ 'meta_query' ] = array(
50 - 'relation' => 'AND',
51 - array(
52 - 'key' => 'doc_category_knowledge_base',
53 - 'value' => $mkb,
54 - 'compare' => 'LIKE'
55 - )
56 - );
57 - $terms_query[ 'order' ] = 'ASC';
58 - }
31 + if ( ! empty( $mkb ) ) {
32 + $terms_query['meta_query'] = [
33 + 'relation' => 'AND',
34 + [
35 + 'key' => 'doc_category_knowledge_base',
36 + 'value' => $mkb,
37 + 'compare' => 'LIKE'
38 + ]
39 + ];
40 + $terms_query['order'] = 'ASC';
41 + }
59 42
60 - $terms = get_terms( $terms_query );
43 + $terms = get_terms( $terms_query );
44 + $response = [];
61 45
62 - $terms = $this->convert_terms_to_array_of_std_objects( $terms );
46 + foreach ( $terms as $term ) {
47 + $original_args = [
48 + 'post_type' => 'docs',
49 + 'posts_per_page' => '-1',
50 + 'post_status' => 'any',
51 + 'term_id' => $term->term_id,
52 + 'term_slug' => $term->slug,
53 + 'nested_subcategory' => false,
54 + 'orderby' => 'betterdocs_order'
55 + ];
63 56
64 - return $terms;
65 - }
57 + if ( ! empty( $mkb ) ) {
58 + $original_args['multiple_kb'] = true;
59 + $original_args['kb_slug'] = $mkb;
60 + }
66 61
67 - /**
68 - * Reorder a list of doc rows according to a saved id sequence.
69 - * Ids in $saved_order keep that order; ids not present are appended.
70 - *
71 - * @param array $docs Doc data rows (each has an 'id' key).
72 - * @param array $saved_order Ordered list of post ids from `_docs_order_<lang>`.
73 - * @return array
74 - */
75 - private function sort_by_saved_order( $docs, $saved_order ) {
76 - if ( empty( $docs ) || empty( $saved_order ) ) {
77 - return $docs;
78 - }
62 + $query_args = betterdocs()->query->docs_query_args( $original_args );
79 63
80 - $saved_order = array_map( 'intval', (array) $saved_order );
81 - $position = array_flip( $saved_order );
82 - $tail_index = count( $saved_order );
64 + $posts = betterdocs()->query->get_posts( $query_args, true );
65 + $response[ $term->term_id ] = [];
83 66
84 - $sorted = $docs;
85 - usort( $sorted, function ( $a, $b ) use ( $position, &$tail_index ) {
86 - $a_id = isset( $a['id'] ) ? (int) $a['id'] : 0;
87 - $b_id = isset( $b['id'] ) ? (int) $b['id'] : 0;
67 + if ( ! $posts->have_posts() ) {
68 + wp_reset_query();
69 + }
70 + while ( $posts->have_posts() ) :
71 + $posts->the_post();
72 + $data = $this->get_doc_data( get_the_ID() );
73 + array_push( $response[ $term->term_id ], $data );
74 + endwhile;
88 75
89 - $a_pos = isset( $position[ $a_id ] ) ? $position[ $a_id ] : PHP_INT_MAX;
90 - $b_pos = isset( $position[ $b_id ] ) ? $position[ $b_id ] : PHP_INT_MAX;
76 + wp_reset_postdata();
77 + wp_reset_query();
78 + }
91 79
92 - return $a_pos <=> $b_pos;
93 - } );
80 + /**
81 + * Uncategories Docs
82 + */
83 + $_post__not_in_query = $wpdb->prepare(
84 + "SELECT ID as post_id from $wpdb->posts WHERE post_type = %s AND post_status != 'trash' AND post_status != 'auto-draft' AND ID NOT IN ( SELECT object_id as post_id FROM $wpdb->term_relationships WHERE term_taxonomy_id IN ( SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE taxonomy = %s ) )",
85 + 'docs',
86 + 'doc_category'
87 + );
94 88
95 - return $sorted;
96 - }
89 + $_post__not_in = $wpdb->get_col( $_post__not_in_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
97 90
98 - private function convert_terms_to_array_of_std_objects( $payload ) {
99 - $terms = array();
91 + if ( ! empty( $_post__not_in ) ) {
92 + $uncategorized_docs = [];
93 + $_uncategorized_docs_query = new \WP_Query(
94 + [
95 + 'post_type' => 'docs',
96 + 'post_status' => 'any',
97 + 'post__in' => $_post__not_in
98 + ]
99 + );
100 100
101 - foreach ( $payload as $term ) {
102 - $object = new stdClass();
101 + if ( ! $_uncategorized_docs_query->have_posts() ) {
102 + wp_reset_query();
103 + }
104 + while ( $_uncategorized_docs_query->have_posts() ) :
105 + $_uncategorized_docs_query->the_post();
106 + $data = $this->get_doc_data( get_the_ID() );
107 + array_push( $uncategorized_docs, $data );
108 + endwhile;
103 109
104 - $object->term_id = $term->term_id;
105 - $object->name = $term->name;
106 - $object->slug = $term->slug;
107 - $object->term_group = $term->term_group;
108 - $object->term_taxonomy_id = $term->term_taxonomy_id;
109 - $object->taxonomy = $term->taxonomy;
110 - $object->description = $term->description;
111 - $object->parent = $term->parent;
112 - $object->count = $term->count;
113 - $object->filter = $term->filter;
114 - $object->meta = $term->meta;
110 + wp_reset_postdata();
111 + wp_reset_query();
115 112
116 - array_push( $terms, $object );
117 - }
113 + $response['uncategorized'] = $uncategorized_docs;
114 + }
118 115
119 - return $terms;
120 - }
116 + return $response;
117 + }
121 118
122 - public function get_response( $request ) {
123 - global $wpdb;
119 + /**
120 + * Get Doc Data Based On Doc ID
121 + *
122 + * @return void
123 + */
124 + public function get_doc_data( $id ) {
125 + $post_data = get_post( $id );
126 + $data = [
127 + 'author' => (int) $post_data->post_author,
128 + 'author_info' => [
129 + 'name' => get_the_author_meta( 'display_name', $post_data->post_author ),
130 + 'author_nicename' => get_the_author_meta( 'nicename', $post_data->post_author ),
131 + 'author_url' => get_author_posts_url( $post_data->post_author )
132 + ],
133 + 'unique_id' => uniqid( 'doc' ),
134 + 'id' => $post_data->ID,
135 + 'title' => $post_data->post_title,
136 + 'slug' => get_post_field( 'post_name', $id ),
137 + 'link' => get_permalink( $id ),
138 + 'status' => get_post_status(),
139 + 'date' => $post_data->post_date,
140 + 'date_gmt' => $post_data->post_date_gmt,
141 + 'doc_category' => wp_get_post_terms( $id, 'doc_category', [ 'fields' => 'ids' ] ),
142 + 'doc_tag' => wp_get_post_terms( $id, 'doc_tag', [ 'fields' => 'ids' ] ),
143 + 'password' => $post_data->post_password,
144 + 'comment_status' => $post_data->comment_status
145 + ];
124 146
125 - $mkb = $request->get_param( 'knowledge_base' );
126 - $per_page = $request->get_param( 'per_page' );
127 - $page = $request->get_param( 'page' );
147 + if ( taxonomy_exists( 'knowledge_base' ) ) {
148 + $data['knowledge_base'] = wp_get_post_terms( $id, 'knowledge_base', [ 'fields' => 'ids' ] );
149 + }
128 150
129 - $default_args = array(
130 - 'hide_empty' => false,
131 - 'taxonomy' => 'doc_category',
132 - 'orderby' => 'betterdocs_order',
133 - 'order' => 'ASC'
134 - );
135 -
136 - if ( 0 != $per_page ) {
137 - $default_args[ 'number' ] = $per_page;
138 - }
139 -
140 - if ( 0 != $page ) {
141 - $default_args[ 'offset' ] = ( $page * $per_page ) - $per_page;
142 - }
143 -
144 - $terms_query = betterdocs()->query->terms_query( $default_args );
145 -
146 - if ( ! empty( $mkb ) ) {
147 - $terms_query[ 'meta_query' ] = array(
148 - 'relation' => 'AND',
149 - array(
150 - 'key' => 'doc_category_knowledge_base',
151 - 'value' => $mkb,
152 - 'compare' => 'LIKE'
153 - )
154 - );
155 - $terms_query[ 'order' ] = 'ASC';
156 - }
157 -
158 - $terms = get_terms( $terms_query );
159 - $response = array();
160 -
161 - // Determine allowed post statuses based on user permissions
162 - $post_status = array( 'publish' );
163 - if ( current_user_can( 'read_private_docs' ) ) {
164 - $post_status[] = 'private';
165 - }
166 - // Admin users with edit_docs capability should see all post statuses
167 - if ( current_user_can( 'edit_docs' ) ) {
168 - $post_status = array( 'publish', 'draft', 'pending', 'private', 'future' );
169 - }
170 -
171 - foreach ( $terms as $term ) {
172 - $original_args = array(
173 - 'post_type' => 'docs',
174 - 'posts_per_page' => '-1',
175 - 'post_status' => $post_status,
176 - 'term_id' => $term->term_id,
177 - 'term_slug' => $term->slug,
178 - 'nested_subcategory' => false,
179 - 'orderby' => 'betterdocs_order'
180 - );
181 -
182 - // Exclude password-protected posts unless user has permission
183 - if ( ! current_user_can( 'edit_posts' ) ) {
184 - $original_args[ 'has_password' ] = false;
185 - }
186 -
187 - if ( ! empty( $mkb ) ) {
188 - $original_args[ 'multiple_kb' ] = true;
189 - $original_args[ 'kb_slug' ] = $mkb;
190 - }
191 -
192 - $query_args = betterdocs()->query->docs_query_args( $original_args );
193 -
194 - $posts = betterdocs()->query->get_posts( $query_args, true );
195 - $response[ $term->term_id ] = array();
196 -
197 - if ( ! $posts->have_posts() ) {
198 - wp_reset_postdata();
199 - }
200 - while ( $posts->have_posts() ):
201 - $posts->the_post();
202 - $post_obj = get_post( get_the_ID() );
203 -
204 - // Double-check password protection for individual posts
205 - if ( ! empty( $post_obj->post_password ) ) {
206 - $can_access = $this->can_access_password_content( $post_obj, $request );
207 - if ( ! $can_access ) {
208 - continue; // Skip this post
209 - }
210 - }
211 -
212 - $data = $this->get_doc_data( get_the_ID(), $request );
213 - array_push( $response[ $term->term_id ], $data );
214 - endwhile;
215 -
216 - wp_reset_postdata();
217 - wp_reset_query(); // phpcs:ignore WordPress.WP.DiscouragedFunctions.wp_reset_query_wp_reset_query -- explicit global WP_Query reset after a custom loop; wp_reset_postdata() above only restores post data.
218 -
219 - // WP_Query's `orderby=post__in` is stripped by some plugins/filters
220 - // (notably WPML on REST requests), so apply the saved order in PHP
221 - // here as the source of truth. Posts not in the saved order are
222 - // appended at the end in their existing query order.
223 - $response[ $term->term_id ] = $this->sort_by_saved_order(
224 - $response[ $term->term_id ],
225 - betterdocs()->query->get_docs_order_by_terms( $term->term_id )
226 - );
227 - }
228 -
229 - /**
230 - * Uncategories Docs
231 - */
232 - // Build secure query for uncategorized docs with proper post status filtering.
233 - // $wpdb->posts / $wpdb->term_relationships / $wpdb->term_taxonomy are WP-provided
234 - // table identifiers (safe to interpolate). Dynamic %s placeholder count is built
235 - // from a fixed-shape $post_status array.
236 - $post_status_placeholders = implode( ',', array_fill( 0, count( $post_status ), '%s' ) );
237 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- WP table identifiers; placeholders dynamically generated to match $post_status size.
238 - $_post__not_in_query = $wpdb->prepare(
239 - "SELECT ID as post_id from $wpdb->posts WHERE post_type = %s AND post_status IN ($post_status_placeholders) AND post_status != 'trash' AND post_status != 'auto-draft' AND ID NOT IN ( SELECT object_id as post_id FROM $wpdb->term_relationships WHERE term_taxonomy_id IN ( SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE taxonomy = %s ) )",
240 - array_merge( array( 'docs' ), $post_status, array( 'doc_category' ) )
241 - );
242 -
243 - $_post__not_in = $wpdb->get_col( $_post__not_in_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above.
244 -
245 - if ( ! empty( $_post__not_in ) ) {
246 - $uncategorized_docs = array();
247 - $uncategorized_query_args = array(
248 - 'post_type' => 'docs',
249 - 'post_status' => $post_status,
250 - 'post__in' => $_post__not_in
251 - );
252 -
253 - // Exclude password-protected posts unless user has permission
254 - if ( ! current_user_can( 'edit_posts' ) ) {
255 - $uncategorized_query_args[ 'has_password' ] = false;
256 - }
257 -
258 - $_uncategorized_docs_query = new \WP_Query( $uncategorized_query_args );
259 -
260 - if ( ! $_uncategorized_docs_query->have_posts() ) {
261 - wp_reset_postdata();
262 - }
263 - while ( $_uncategorized_docs_query->have_posts() ):
264 - $_uncategorized_docs_query->the_post();
265 - $post_obj = get_post( get_the_ID() );
266 -
267 - // Double-check password protection for individual posts
268 - if ( ! empty( $post_obj->post_password ) ) {
269 - $can_access = $this->can_access_password_content( $post_obj, $request );
270 - if ( ! $can_access ) {
271 - continue; // Skip this post
272 - }
273 - }
274 -
275 - $data = $this->get_doc_data( get_the_ID(), $request );
276 - array_push( $uncategorized_docs, $data );
277 - endwhile;
278 -
279 - wp_reset_postdata();
280 - wp_reset_postdata();
281 -
282 - $response[ 'uncategorized' ] = $uncategorized_docs;
283 - }
284 -
285 - unset( $terms_query[ 'offset' ] );
286 - unset( $terms_query[ 'number' ] );
287 - $total_terms = wp_count_terms( $terms_query );
288 -
289 - return array(
290 - 'data' => $response,
291 - 'total_terms' => $total_terms
292 - );
293 - }
294 -
295 - /**
296 - * Get Doc Data Based On Doc ID
297 - *
298 - * @param int $id Post ID
299 - * @param WP_REST_Request $request REST request object
300 - * @return array
301 - */
302 - public function get_doc_data( $id, $request = null ) {
303 - $post_data = get_post( $id );
304 - $data = array(
305 - 'author' => (int) $post_data->post_author,
306 - 'author_info' => array(
307 - 'name' => get_the_author_meta( 'display_name', $post_data->post_author ),
308 - 'author_nicename' => get_the_author_meta( 'nicename', $post_data->post_author ),
309 - 'author_url' => get_author_posts_url( $post_data->post_author )
310 - ),
311 - 'unique_id' => uniqid( 'doc' ),
312 - 'id' => $post_data->ID,
313 - 'title' => $post_data->post_title,
314 - 'slug' => get_post_field( 'post_name', $id ),
315 - 'link' => get_permalink( $id ),
316 - 'status' => get_post_status(),
317 - 'date' => $post_data->post_date,
318 - 'date_gmt' => $post_data->post_date_gmt,
319 - 'doc_category' => wp_get_post_terms( $id, 'doc_category', array( 'fields' => 'ids' ) ),
320 - 'doc_tag' => wp_get_post_terms( $id, 'doc_tag', array( 'fields' => 'ids' ) ),
321 - 'comment_status' => $post_data->comment_status
322 - );
323 -
324 - // Only expose password to users with edit permissions
325 - if ( current_user_can( 'edit_docs' ) ) {
326 - $data[ 'password' ] = $post_data->post_password;
327 - }
328 -
329 - // Add password protection indicator
330 - if ( ! empty( $post_data->post_password ) ) {
331 - $data[ 'password_protected' ] = true;
332 - } else {
333 - $data[ 'password_protected' ] = false;
334 - }
335 -
336 - if ( taxonomy_exists( 'knowledge_base' ) ) {
337 - $data[ 'knowledge_base' ] = wp_get_post_terms( $id, 'knowledge_base', array( 'fields' => 'ids' ) );
338 - }
339 -
340 - return $data;
341 - }
342 -
343 - /**
344 - * Checks if the user can access password-protected content.
345 - *
346 - * This method determines whether we need to override the regular password
347 - * check in core with a filter.
348 - *
349 - * @param WP_Post $post Post to check against.
350 - * @param WP_REST_Request $request Request data to check.
351 - * @return bool True if the user can access password-protected content, otherwise false.
352 - */
353 - public function can_access_password_content( $post, $request ) {
354 - if ( empty( $post->post_password ) ) {
355 - // No filter required.
356 - return true;
357 - }
358 -
359 - /*
360 - * Users always get access to password protected content if they have
361 - * the `edit_post` meta capability.
362 - */
363 - if ( current_user_can( 'edit_post', $post->ID ) ) {
364 - return true;
365 - }
366 -
367 - // No password provided in request, no auth.
368 - if ( empty( $request ) || empty( $request[ 'password' ] ) ) {
369 - return false;
370 - }
371 -
372 - // Double-check the request password.
373 - return hash_equals( $post->post_password, $request[ 'password' ] );
374 - }
151 + return $data;
152 + }
375 153 }