PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 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 All 201 releases
betterdocs / includes / Abilities / Terms / ListTerms.php

ListTerms.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.3, at includes/Abilities/Terms/ListTerms.php

446 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * List doc categories or doc tags ability.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities\Terms;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 use WPDeveloper\BetterDocs\Abilities\AbilityBase;
16 use WPDeveloper\BetterDocs\Abilities\AbilityError;
17 use WPDeveloper\BetterDocs\Abilities\Traits\ResolvesTerms;
18 use WPDeveloper\BetterDocs\Abilities\Traits\ShapesTerms;
19
20 /**
21 * Read the taxonomy: what categories and tags exist, how many docs are in each,
22 * and which knowledge bases the categories belong to.
23 *
24 * The one tool an agent should call before creating terms, because it is what
25 * turns "add this to the installation guide category" into an id instead of a
26 * near-miss duplicate.
27 *
28 * `hide_empty` defaults to **false**: a category with no docs in it yet is
29 * exactly the one an agent is about to file something into, and hiding it would
30 * make the tool answer "it does not exist" about a category that does. With it
31 * on, `total` can come back lower than the number of items — measured on the
32 * rig: `get_terms( hide_empty => true )` keeps `u20-parent` because its child
33 * has a doc, while `get_terms( fields => count )`, which is where `X-WP-Total`
34 * comes from, runs a flat `count > 0` and answers 3 for those same 4 terms.
35 * WordPress' behaviour, in core, on any hierarchical taxonomy; the field
36 * description says so rather than pretending the numbers agree.
37 *
38 * `knowledge_base` is a `doc_category` filter that BetterDocs Pro implements
39 * (`MultipleKB::modify_doc_category_rest_query()`), so on a site without Pro it
40 * is refused with the typed Pro state rather than silently ignored — a filter
41 * that is dropped returns *every* category, which reads as "they are all in this
42 * knowledge base".
43 *
44 * @since 4.9.0
45 */
46 class ListTerms extends AbilityBase {
47
48 use ResolvesTerms;
49 use ShapesTerms;
50
51 /**
52 * @since 4.9.0
53 */
54 public function __construct() {
55 $this->id = 'betterdocs/list-terms';
56 $this->label = __( 'List terms', 'betterdocs' );
57 $this->description = __( 'List BetterDocs doc categories or doc tags, with their doc counts, parents and knowledge bases. Filter by search text, parent or knowledge base. Call this before creating terms so you reuse what exists instead of making near-duplicates.', 'betterdocs' );
58 $this->capability = 'edit_docs';
59 }
60
61 /**
62 * @since 4.9.0
63 *
64 * @return array
65 */
66 public function get_annotations() {
67 return [
68 'readonly' => true,
69 'destructive' => false,
70 'idempotent' => true,
71 'priority' => 1.5,
72 'openWorldHint' => false
73 ];
74 }
75
76 /**
77 * @since 4.9.0
78 *
79 * @return array
80 */
81 public function get_input_schema() {
82 return [
83 'type' => 'object',
84 'additionalProperties' => false,
85 'required' => [ 'taxonomy' ],
86 'properties' => [
87 'taxonomy' => self::taxonomy_schema(),
88 'search' => [
89 'type' => 'string',
90 'description' => __( 'Free-text search over term names and slugs.', 'betterdocs' )
91 ],
92 'parent' => [
93 'type' => [ 'integer', 'string' ],
94 'description' => __( 'Only the children of this doc category, by id or name. Use 0 for top-level terms only.', 'betterdocs' )
95 ],
96 'knowledge_base' => [
97 'type' => [ 'integer', 'string' ],
98 'description' => __( 'Only doc categories filed into this knowledge base, by id, slug or name. Needs BetterDocs Pro with Multiple Knowledge Base on.', 'betterdocs' )
99 ],
100 'hide_empty' => [
101 'type' => 'boolean',
102 'default' => false,
103 'description' => __( 'true drops terms with no docs in them. Defaults to false. Both taxonomies are hierarchical, so WordPress keeps a parent whose child has docs even when the parent itself has none — and counts it out of total, which is why total can be lower than the number of items on this filter.', 'betterdocs' )
104 ],
105 'orderby' => [
106 'type' => 'string',
107 'enum' => [ 'name', 'count', 'slug', 'id', 'description', 'doc_category_order' ],
108 'default' => 'name',
109 'description' => __( 'Sort field. doc_category_order is the manual order set in the BetterDocs admin, and applies to doc_category only.', 'betterdocs' )
110 ],
111 'order' => [
112 'type' => 'string',
113 'enum' => [ 'asc', 'desc' ],
114 'default' => 'asc'
115 ],
116 'page' => [
117 'type' => 'integer',
118 'minimum' => 1,
119 'default' => 1
120 ],
121 'per_page' => [
122 'type' => 'integer',
123 'minimum' => 1,
124 'maximum' => 100,
125 'default' => 50,
126 'description' => __( 'Terms per page, 1–100.', 'betterdocs' )
127 ]
128 ],
129 'default' => []
130 ];
131 }
132
133 /**
134 * @since 4.9.0
135 *
136 * @return array
137 */
138 public function get_output_schema() {
139 return [
140 'type' => 'object',
141 'properties' => [
142 'items' => [
143 'type' => 'array',
144 'items' => [
145 'type' => 'object',
146 'properties' => self::term_shape_schema()
147 ]
148 ],
149 'total' => [ 'type' => 'integer' ],
150 'total_pages' => [ 'type' => 'integer' ],
151 'page' => [ 'type' => 'integer' ],
152 'per_page' => [ 'type' => 'integer' ]
153 ]
154 ];
155 }
156
157 /**
158 * @since 4.9.0
159 *
160 * @param array $input Validated input.
161 * @return array|\WP_Error
162 */
163 public function execute( $input ) {
164 $taxonomy = isset( $input['taxonomy'] ) ? (string) $input['taxonomy'] : '';
165 $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1;
166 $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : 50;
167
168 // Before anything is read or written: a taxonomy that is switched off
169 // answers with the setting to change, not with `not_found` (ADR-061).
170 $available = $this->taxonomy_available( $taxonomy );
171
172 if ( is_wp_error( $available ) ) {
173 return $available;
174 }
175
176 $params = [
177 // View, not edit. `WP_REST_Terms_Controller::get_items_permissions_check()`
178 // refuses `context=edit` with `rest_forbidden_context` unless the
179 // caller holds the taxonomy's `edit_terms` capability — which an
180 // author does not, so this read-only tool would have been
181 // administrator- and editor-only. Measured on the rig: term `meta`,
182 // `doc_category_order` and `total_docs_count` are all present in
183 // view context anyway, so nothing is lost by asking for less.
184 'context' => 'view',
185 'hide_empty' => ! empty( $input['hide_empty'] ),
186 'orderby' => isset( $input['orderby'] ) ? (string) $input['orderby'] : 'name',
187 'order' => isset( $input['order'] ) ? (string) $input['order'] : 'asc',
188 'page' => $page,
189 'per_page' => $per_page
190 ];
191
192 if ( isset( $input['search'] ) && '' !== (string) $input['search'] ) {
193 $params['search'] = (string) $input['search'];
194 }
195
196 $parent = $this->parent_param( $input, $taxonomy );
197
198 if ( is_wp_error( $parent ) ) {
199 return $parent;
200 }
201
202 if ( null !== $parent ) {
203 $params['parent'] = $parent;
204 }
205
206 if ( isset( $input['knowledge_base'] ) && '' !== $input['knowledge_base'] ) {
207 if ( 'doc_category' !== $taxonomy ) {
208 return AbilityError::invalid_input(
209 'knowledge_base',
210 __( 'Only doc categories belong to knowledge bases.', 'betterdocs' )
211 );
212 }
213
214 $slugs = $this->kb_slugs_for( [ $input['knowledge_base'] ] );
215
216 if ( is_wp_error( $slugs ) ) {
217 return $slugs;
218 }
219
220 // Pro filters on the slug, not the id, and does so with a `LIKE`
221 // over the membership meta — which over-reports (finding D). The KB
222 // path re-checks each match against its own membership before it
223 // answers.
224 return $this->list_by_knowledge_base(
225 $taxonomy,
226 $params,
227 isset( $slugs[0] ) ? $slugs[0] : '',
228 $page,
229 $per_page
230 );
231 }
232
233 $response = $this->dispatch_terms( $taxonomy, $params );
234
235 if ( $response->is_error() ) {
236 return $this->map_term_error( $response->as_error(), $taxonomy, __( 'list terms', 'betterdocs' ) );
237 }
238
239 $headers = $response->get_headers();
240 $items = [];
241
242 foreach ( (array) $response->get_data() as $term ) {
243 $items[] = $this->term_shape( (array) $term, $taxonomy );
244 }
245
246 return [
247 'items' => $items,
248 'total' => isset( $headers['X-WP-Total'] ) ? (int) $headers['X-WP-Total'] : count( $items ),
249 'total_pages' => isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 1,
250 'page' => $page,
251 'per_page' => $per_page
252 ];
253 }
254
255 /**
256 * List the doc categories that truly belong to a knowledge base.
257 *
258 * Multiple Knowledge Base filters `knowledge_base` with a `LIKE` over the
259 * serialised `doc_category_knowledge_base` meta, so the query also returns a
260 * category filed under a knowledge base whose slug merely *contains* the
261 * requested one (`qa-kb` matches `qa-kb-2`). Pro's own KB tools re-check the
262 * membership meta after the query; this list did not, so a `qa-kb-2`
263 * category was reported under `qa-kb` (ADR-059, finding D).
264 *
265 * The whole `LIKE` match set is walked, each candidate confirmed against its
266 * own membership slugs (the exact `in_array` Pro uses), and the confirmed
267 * set is paged in PHP so `total` and `total_pages` count what the tool
268 * returns rather than the wider `LIKE` match. The `betterdocs/v1/doc-categories-kb`
269 * reader is not used for this: it carries the same `LIKE`, and its own
270 * `parent: 0` / `hide_empty: true` constraints would drop legitimate members.
271 *
272 * @since 4.9.0
273 *
274 * @param string $taxonomy Always `doc_category` here.
275 * @param array $base_params The query parameters built for the listing.
276 * @param string $slug Resolved knowledge-base slug.
277 * @param int $page Requested page.
278 * @param int $per_page Requested per_page.
279 * @return array|\WP_Error
280 */
281 protected function list_by_knowledge_base( $taxonomy, array $base_params, $slug, $page, $per_page ) {
282 if ( '' === (string) $slug ) {
283 // Matches the terms controller's own count for an empty collection.
284 return [
285 'items' => [],
286 'total' => 0,
287 'total_pages' => 0,
288 'page' => (int) $page,
289 'per_page' => (int) $per_page
290 ];
291 }
292
293 $gather = array_merge(
294 $base_params,
295 [
296 'knowledge_base' => (string) $slug,
297 'per_page' => 100
298 ]
299 );
300 $members = [];
301 $total_pages = 1;
302 $p = 1;
303
304 // 20 pages of 100 is two thousand categories in one knowledge base; past
305 // that something is wrong with the site, not with the paging (the cap
306 // Pro's own `assigned_categories()` uses).
307 while ( $p <= $total_pages && $p <= 20 ) {
308 $gather['page'] = $p;
309 $response = $this->dispatch_terms( $taxonomy, $gather );
310
311 if ( $response->is_error() ) {
312 return $this->map_term_error( $response->as_error(), $taxonomy, __( 'list terms', 'betterdocs' ) );
313 }
314
315 foreach ( (array) $response->get_data() as $term ) {
316 $term = (array) $term;
317
318 if ( in_array( (string) $slug, $this->kb_slugs( $term ), true ) ) {
319 $members[] = $term;
320 }
321 }
322
323 $headers = $response->get_headers();
324 $total_pages = isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 1;
325 ++$p;
326 }
327
328 $total = count( $members );
329 $offset = ( (int) $page - 1 ) * (int) $per_page;
330 $items = [];
331
332 foreach ( array_slice( $members, max( 0, $offset ), (int) $per_page ) as $term ) {
333 $items[] = $this->term_shape( $term, $taxonomy );
334 }
335
336 return [
337 'items' => $items,
338 'total' => $total,
339 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 1,
340 'page' => (int) $page,
341 'per_page' => (int) $per_page
342 ];
343 }
344
345 /**
346 * Run the collection request with this tool's own parameters intact.
347 *
348 * BetterDocs Pro's `InstantAnswer::order_ia_doc_taxonomies()` hooks
349 * `rest_doc_category_query` and, whenever `$_GET` is empty, overwrites
350 * `number`, `hide_empty`, `orderby`, `order` and `meta_key` with the
351 * Instant Answer widget's own settings — it is guarding against a widget
352 * request, but `$_GET` is empty for *every* internal `rest_do_request()`,
353 * which is exactly how an ability calls a route. Measured on the rig: a
354 * request for `per_page 100, hide_empty false, orderby name` reached
355 * `get_terms()` as `number 10, hide_empty 1, orderby meta_value_num`, so
356 * this tool's documented parameters did nothing and a site with more than
357 * ten doc categories could never page past the first ten.
358 *
359 * The fix is local and does not touch Pro: a filter at a later priority
360 * puts back what this request asked for, and only for this request —
361 * identity-compared, so nothing else in the page is affected. `orderby` is
362 * deliberately left alone when the caller asked for `doc_category_order`,
363 * because Free's own `PostType::modify_doc_category_rest_query()` rewrites
364 * that one into a meta-value sort at priority 10 and is right to.
365 *
366 * @since 4.9.0
367 *
368 * @param string $taxonomy Taxonomy name.
369 * @param array $params Query parameters.
370 * @return \WP_REST_Response
371 */
372 protected function dispatch_terms( $taxonomy, array $params ) {
373 $request = new \WP_REST_Request( 'GET', '/wp/v2/' . $taxonomy );
374
375 $request->set_header( 'Content-Type', 'application/json' );
376 $request->set_query_params( $params );
377
378 if ( 'doc_category' !== $taxonomy ) {
379 return rest_do_request( $request );
380 }
381
382 $restore = function ( $args, $filtered_request ) use ( $request, $params ) {
383 if ( $filtered_request !== $request ) {
384 return $args;
385 }
386
387 $args['number'] = (int) $params['per_page'];
388 $args['offset'] = ( (int) $params['page'] - 1 ) * (int) $params['per_page'];
389 $args['hide_empty'] = (bool) $params['hide_empty'];
390 $args['order'] = strtoupper( (string) $params['order'] );
391
392 if ( 'doc_category_order' !== $params['orderby'] ) {
393 $args['orderby'] = (string) $params['orderby'];
394 unset( $args['meta_key'] );
395 }
396
397 return $args;
398 };
399
400 add_filter( 'rest_doc_category_query', $restore, 20, 2 );
401
402 $response = rest_do_request( $request );
403
404 remove_filter( 'rest_doc_category_query', $restore, 20 );
405
406 return $response;
407 }
408
409 /**
410 * The `parent` query parameter, or null when there is none.
411 *
412 * `0` is a meaningful value here — "top level only" — so it cannot be
413 * folded into the "omitted" case.
414 *
415 * @since 4.9.0
416 *
417 * @param array $input Validated input.
418 * @param string $taxonomy Taxonomy name.
419 * @return int|null|\WP_Error
420 */
421 protected function parent_param( array $input, $taxonomy ) {
422 if ( ! isset( $input['parent'] ) || '' === $input['parent'] ) {
423 return null;
424 }
425
426 if ( 0 === $input['parent'] || '0' === $input['parent'] ) {
427 return 0;
428 }
429
430 if ( 'doc_category' !== $taxonomy ) {
431 return AbilityError::invalid_input(
432 'parent',
433 __( 'These tools nest doc categories only; a doc tag takes no parent.', 'betterdocs' )
434 );
435 }
436
437 $ids = $this->resolve_terms( [ $input['parent'] ], 'doc_category', false );
438
439 if ( is_wp_error( $ids ) ) {
440 return $ids;
441 }
442
443 return isset( $ids[0] ) ? (int) $ids[0] : 0;
444 }
445 }
446