PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
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 / DocCategories.php

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

376 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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.
7 namespace WPDeveloper\BetterDocs\REST;
8
9 use stdClass;
10 use WPDeveloper\BetterDocs\Core\BaseAPI;
11 use WPDeveloper\BetterDocs\Utils\Helper;
12
13 class DocCategories extends BaseAPI {
14 public function permission_check(): bool {
15 return true;
16 // return current_user_can( 'edit_docs' );
17 }
18
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 }
28
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' ) : '';
33
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 );
43
44 if ( ! empty( $suppress_filters ) ) {
45 $terms_query[ 'suppress_filters' ] = $suppress_filters;
46 }
47
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 }
59
60 $terms = get_terms( $terms_query );
61
62 $terms = $this->convert_terms_to_array_of_std_objects( $terms );
63
64 return $terms;
65 }
66
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 }
79
80 $saved_order = array_map( 'intval', (array) $saved_order );
81 $position = array_flip( $saved_order );
82 $tail_index = count( $saved_order );
83
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;
88
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;
91
92 return $a_pos <=> $b_pos;
93 } );
94
95 return $sorted;
96 }
97
98 private function convert_terms_to_array_of_std_objects( $payload ) {
99 $terms = array();
100
101 foreach ( $payload as $term ) {
102 $object = new stdClass();
103
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;
115
116 array_push( $terms, $object );
117 }
118
119 return $terms;
120 }
121
122 public function get_response( $request ) {
123 global $wpdb;
124
125 $mkb = $request->get_param( 'knowledge_base' );
126 $per_page = $request->get_param( 'per_page' );
127 $page = $request->get_param( 'page' );
128
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 }
375 }
376