| 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 |
|