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

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