PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
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
betterdocs / includes / Abilities / Traits / ShapesFAQs.php

ShapesFAQs.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.2, at includes/Abilities/Traits/ShapesFAQs.php

551 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The FAQ group and FAQ shapes the eight FAQ abilities answer with, and the
4 * translation from the FAQ Builder's own return values to typed errors.
5 *
6 * @package BetterDocs
7 * @since 4.9.0
8 */
9
10 namespace WPDeveloper\BetterDocs\Abilities\Traits;
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit; // Exit if accessed directly.
14 }
15
16 use WPDeveloper\BetterDocs\Abilities\AbilityError;
17 use WPDeveloper\BetterDocs\Utils\BlockBuilder;
18
19 /**
20 * Eight tools, two objects — and one place that knows how odd the routes
21 * underneath them are.
22 *
23 * The FAQ Builder's REST routes (`betterdocs/faq/*`, note: **no `/v1`**) predate
24 * every convention in this plugin. `create_category` answers
25 * `{success, term_id}`, `create_post` answers a **bare integer**,
26 * `update_category` and `delete_category` answer a bare `true`, and none of them
27 * declares an `args` schema. This trait is where those become objects, so an
28 * agent sees the same `{id, name, slug, …}` from every FAQ tool.
29 *
30 * Three behaviours of those routes drive the design and are worth stating,
31 * because each one silently destroys data if a tool sends only what changed:
32 *
33 * 1. `update_category` hands its parameters straight to `wp_update_term()`, so
34 * an absent `description` or `slug` **blanks** the stored one, and an absent
35 * `group_icon_url` overwrites the group's icon with an empty string.
36 * 2. `update_post` does `sanitize_text_field( $post_title )` and
37 * `wp_kses_post( $post_content )` on whatever it was given, so an absent
38 * title or answer is written as `''`.
39 * 3. Its `status` branch builds `[ 'ID', 'post_type', 'status' ]` for
40 * `wp_update_post()`, which has no `status` field — the FAQ's post status
41 * never changes, and the same call skips the title and content branch
42 * entirely.
43 *
44 * So every update tool reads the object first, merges, and sends the whole
45 * record; and a status change goes through `wp/v2/betterdocs_faq` (post status)
46 * or `betterdocs/faq/category_status` (the group's `status` term meta), which
47 * are the paths that actually work.
48 *
49 * @since 4.9.0
50 */
51 trait ShapesFAQs {
52
53 // `self::GROUP_TAXONOMY`, `self::FAQ_POST_TYPE`, `self::FAQ_NS` and
54 // `self::FAQ_STATUSES` are declared on `Abilities\AbilityBase`, which every
55 // user of this trait extends: PHP 7.4 traits cannot carry constants.
56
57 /**
58 * `status` as the FAQ group tools declare it.
59 *
60 * The group's status is the `status` term meta the FAQ Builder toggles —
61 * `1` shows the group on the front end, `0` hides it
62 * (`Query::faq_terms_query_args()` filters on exactly that). It is spelled
63 * `publish`/`draft` here so it reads like every other status in this tool
64 * set.
65 *
66 * @since 4.9.0
67 *
68 * @return array
69 */
70 protected static function group_status_schema() {
71 return [
72 'type' => 'string',
73 'enum' => [ 'publish', 'draft' ],
74 'description' => __( 'publish shows the group on the front end, draft hides it. This is the FAQ Builder\'s enabled/disabled switch, stored as the group\'s status term meta.', 'betterdocs' )
75 ];
76 }
77
78 /**
79 * The FAQ group summary all four group tools answer with.
80 *
81 * @since 4.9.0
82 *
83 * @param array $term A `wp/v2/betterdocs_faq_category` item.
84 * @return array
85 */
86 protected function group_shape( array $term ) {
87 return [
88 'id' => isset( $term['id'] ) ? (int) $term['id'] : 0,
89 'name' => isset( $term['name'] ) ? (string) $term['name'] : '',
90 'slug' => isset( $term['slug'] ) ? (string) $term['slug'] : '',
91 'description' => isset( $term['description'] ) ? (string) $term['description'] : '',
92 'status' => $this->group_status_of( $term ),
93 'order' => (int) $this->term_meta_first( $term, 'order', 0 ),
94 // WordPress' term count tracks **published** posts only, which is
95 // why the draft count is a separate number rather than folded in.
96 'faq_count' => isset( $term['count'] ) ? (int) $term['count'] : 0,
97 'draft_count' => isset( $term['betterdocs_draft_count'] ) ? (int) $term['betterdocs_draft_count'] : 0
98 ];
99 }
100
101 /**
102 * JSON Schema for {@see self::group_shape()}.
103 *
104 * @since 4.9.0
105 *
106 * @return array
107 */
108 protected static function group_shape_schema() {
109 return [
110 'id' => [ 'type' => 'integer' ],
111 'name' => [ 'type' => 'string' ],
112 'slug' => [ 'type' => 'string' ],
113 'description' => [ 'type' => 'string' ],
114 'status' => [ 'type' => 'string' ],
115 'order' => [ 'type' => 'integer' ],
116 'faq_count' => [ 'type' => 'integer' ],
117 'draft_count' => [ 'type' => 'integer' ]
118 ];
119 }
120
121 /**
122 * The FAQ summary all four FAQ tools answer with.
123 *
124 * @since 4.9.0
125 *
126 * @param array $post A `wp/v2/betterdocs_faq` item, ideally in edit context.
127 * @return array
128 */
129 protected function faq_shape( array $post ) {
130 $groups = isset( $post[ self::GROUP_TAXONOMY ] ) ? (array) $post[ self::GROUP_TAXONOMY ] : [];
131 $group_id = isset( $groups[0] ) ? (int) $groups[0] : 0;
132
133 return [
134 'id' => isset( $post['id'] ) ? (int) $post['id'] : 0,
135 'question' => $this->rendered_or_raw_field( isset( $post['title'] ) ? $post['title'] : '' ),
136 'answer' => $this->rendered_or_raw_field( isset( $post['content'] ) ? $post['content'] : '' ),
137 'group' => $group_id > 0 ? $this->group_summary( $group_id ) : null,
138 'status' => isset( $post['status'] ) ? (string) $post['status'] : '',
139 'order' => $this->faq_order_position( isset( $post['id'] ) ? (int) $post['id'] : 0, $group_id )
140 ];
141 }
142
143 /**
144 * JSON Schema for {@see self::faq_shape()}.
145 *
146 * @since 4.9.0
147 *
148 * @return array
149 */
150 protected static function faq_shape_schema() {
151 return [
152 'id' => [ 'type' => 'integer' ],
153 'question' => [ 'type' => 'string' ],
154 'answer' => [ 'type' => 'string' ],
155 'group' => [
156 'type' => [ 'object', 'null' ],
157 'properties' => [
158 'id' => [ 'type' => 'integer' ],
159 'name' => [ 'type' => 'string' ],
160 'slug' => [ 'type' => 'string' ]
161 ]
162 ],
163 'status' => [ 'type' => 'string' ],
164 'order' => [ 'type' => [ 'integer', 'null' ] ]
165 ];
166 }
167
168 /**
169 * `{id, name, slug}` for an FAQ group id, or null when it has gone.
170 *
171 * @since 4.9.0
172 *
173 * @param int $id Term id.
174 * @return array|null
175 */
176 protected function group_summary( $id ) {
177 $term = get_term( (int) $id, self::GROUP_TAXONOMY );
178
179 if ( ! $term || is_wp_error( $term ) ) {
180 return null;
181 }
182
183 return [
184 'id' => (int) $term->term_id,
185 'name' => (string) $term->name,
186 'slug' => (string) $term->slug
187 ];
188 }
189
190 /**
191 * An FAQ's position in its group's manual order, 1-based.
192 *
193 * The order lives in the group's `_betterdocs_faq_order` term meta as a
194 * comma-separated id list, newest first — the FAQ Builder's drag-and-drop
195 * writes it and `Query` reads it. Null when the FAQ has no group, or when
196 * the group's list does not mention it (an FAQ assigned outside the
197 * Builder).
198 *
199 * @since 4.9.0
200 *
201 * @param int $post_id FAQ post id.
202 * @param int $group_id FAQ group term id.
203 * @return int|null
204 */
205 protected function faq_order_position( $post_id, $group_id ) {
206 $post_id = (int) $post_id;
207 $group_id = (int) $group_id;
208
209 if ( $post_id <= 0 || $group_id <= 0 ) {
210 return null;
211 }
212
213 $stored = (string) get_term_meta( $group_id, '_betterdocs_faq_order', true );
214 $ids = array_values( array_filter( array_map( 'trim', explode( ',', $stored ) ), 'strlen' ) );
215 $index = array_search( (string) $post_id, $ids, true );
216
217 return false === $index ? null : (int) $index + 1;
218 }
219
220 /**
221 * A term meta value out of a REST term item.
222 *
223 * `register_term_meta()` is called without `single`, so every one of these
224 * keys arrives as an array of values rather than a scalar.
225 *
226 * @since 4.9.0
227 *
228 * @param array $term A `wp/v2/betterdocs_faq_category` item.
229 * @param string $key Meta key.
230 * @param mixed $fallback Value when the key is absent or empty.
231 * @return mixed
232 */
233 protected function term_meta_first( array $term, $key, $fallback = '' ) {
234 $value = isset( $term['meta'][ $key ] ) ? $term['meta'][ $key ] : null;
235
236 if ( is_array( $value ) ) {
237 $value = count( $value ) > 0 ? reset( $value ) : null;
238 }
239
240 return ( null === $value || '' === $value ) ? $fallback : $value;
241 }
242
243 /**
244 * `publish` or `draft` for a group item.
245 *
246 * A group with no `status` meta at all — an import that predates the FAQ
247 * Builder — is `draft`, because that is how the front end treats it.
248 *
249 * @since 4.9.0
250 *
251 * @param array $term A `wp/v2/betterdocs_faq_category` item.
252 * @return string
253 */
254 protected function group_status_of( array $term ) {
255 return '1' === (string) $this->term_meta_first( $term, 'status', '0' ) ? 'publish' : 'draft';
256 }
257
258 /**
259 * WordPress returns `{raw, rendered}` in edit context and `{rendered}` in
260 * view context; either way an agent wants one string.
261 *
262 * Named apart from `ShapesDocs::rendered_or_raw()` so the two traits can be
263 * used in the same class without colliding.
264 *
265 * @since 4.9.0
266 *
267 * @param mixed $field A REST title/content field.
268 * @return string
269 */
270 protected function rendered_or_raw_field( $field ) {
271 if ( is_string( $field ) ) {
272 return $field;
273 }
274
275 if ( ! is_array( $field ) ) {
276 return '';
277 }
278
279 if ( isset( $field['raw'] ) && '' !== $field['raw'] ) {
280 return (string) $field['raw'];
281 }
282
283 return isset( $field['rendered'] ) ? (string) $field['rendered'] : '';
284 }
285
286 /**
287 * Load one FAQ group as a REST item.
288 *
289 * @since 4.9.0
290 *
291 * @param int $id Term id.
292 * @return array|\WP_Error
293 */
294 protected function group_item( $id ) {
295 $id = (int) $id;
296
297 $term = $id > 0 ? get_term( $id, self::GROUP_TAXONOMY ) : null;
298
299 if ( ! $term || is_wp_error( $term ) ) {
300 return AbilityError::not_found( 'FAQ group', $id );
301 }
302
303 $item = $this->dispatch( 'GET', '/' . self::GROUP_TAXONOMY . '/' . $id, [ 'context' => 'view' ], 'wp/v2' );
304
305 if ( is_wp_error( $item ) ) {
306 return $this->map_faq_error( $item, __( 'read an FAQ group', 'betterdocs' ) );
307 }
308
309 return (array) $item;
310 }
311
312 /**
313 * Load one FAQ as a REST item, refusing anything that is not an FAQ.
314 *
315 * @since 4.9.0
316 *
317 * @param int $id Post id.
318 * @return array|\WP_Error
319 */
320 protected function faq_item( $id ) {
321 $id = (int) $id;
322 $post = $id > 0 ? get_post( $id ) : null;
323
324 if ( ! $post || self::FAQ_POST_TYPE !== $post->post_type ) {
325 return AbilityError::not_found( 'FAQ', $id );
326 }
327
328 $item = $this->dispatch( 'GET', '/' . self::FAQ_POST_TYPE . '/' . $id, [ 'context' => 'edit' ], 'wp/v2' );
329
330 if ( is_wp_error( $item ) ) {
331 return $this->map_faq_error( $item, __( 'read an FAQ', 'betterdocs' ) );
332 }
333
334 return (array) $item;
335 }
336
337 /**
338 * Resolve a group reference — an id, a slug or a name — to a term id.
339 *
340 * @since 4.9.0
341 *
342 * @param mixed $ref Group id, slug or name.
343 * @param bool $create Whether an unmatched **name** may be created.
344 * @return int|\WP_Error
345 */
346 protected function resolve_group( $ref, $create ) {
347 if ( is_int( $ref ) || ( is_string( $ref ) && ctype_digit( $ref ) ) ) {
348 $term = get_term( (int) $ref, self::GROUP_TAXONOMY );
349
350 if ( ! $term || is_wp_error( $term ) ) {
351 return AbilityError::not_found( 'FAQ group', (int) $ref );
352 }
353
354 return (int) $term->term_id;
355 }
356
357 if ( ! is_string( $ref ) || '' === trim( $ref ) ) {
358 return AbilityError::invalid_input(
359 'group',
360 __( 'An FAQ group reference must be a group id, a slug or a name.', 'betterdocs' )
361 );
362 }
363
364 $ref = trim( $ref );
365
366 foreach ( [ 'slug', 'name' ] as $field ) {
367 $term = get_term_by( $field, 'slug' === $field ? sanitize_title( $ref ) : $ref, self::GROUP_TAXONOMY );
368
369 if ( $term && ! is_wp_error( $term ) ) {
370 return (int) $term->term_id;
371 }
372 }
373
374 if ( ! $create ) {
375 return AbilityError::not_found( 'FAQ group', $ref );
376 }
377
378 return $this->create_group( [ 'title' => $ref ] );
379 }
380
381 /**
382 * Create an FAQ group through the FAQ Builder's own route.
383 *
384 * Not `wp_insert_term()`: that runs no capability check at all, and not
385 * `POST wp/v2/betterdocs_faq_category` either, which is gated on
386 * `edit_doc_terms` and would refuse a user these tools are meant to serve
387 * (the FAQ Builder's own gate is `edit_others_posts || edit_others_docs` —
388 * ADR-005). The route also carries the side effects the Builder depends on:
389 * `created_betterdocs_faq_category` stamps the new group's `order` and
390 * `status` meta.
391 *
392 * @since 4.9.0
393 *
394 * @param array $params `title`, and optionally `description` / `slug`.
395 * @return int|\WP_Error New term id.
396 */
397 protected function create_group( array $params ) {
398 $result = $this->dispatch( 'POST', '/faq/create_category', $params, self::FAQ_NS );
399
400 if ( is_wp_error( $result ) ) {
401 return $this->map_faq_error(
402 $result,
403 sprintf(
404 /* translators: %s: FAQ group name. */
405 __( 'create the FAQ group "%s"', 'betterdocs' ),
406 isset( $params['title'] ) ? $params['title'] : ''
407 )
408 );
409 }
410
411 $result = (array) $result;
412
413 if ( empty( $result['success'] ) || empty( $result['term_id'] ) ) {
414 return AbilityError::upstream( __( 'The FAQ group was not created and BetterDocs reported no reason.', 'betterdocs' ) );
415 }
416
417 return (int) $result['term_id'];
418 }
419
420 /**
421 * Resolve the `group_id` / `group_name` pair every FAQ tool accepts.
422 *
423 * @since 4.9.0
424 *
425 * @param array $input Validated input.
426 * @param bool $create Whether an unmatched `group_name` may be created.
427 * @return int|null|\WP_Error Term id, or null when neither field was sent.
428 */
429 protected function group_ref_input( array $input, $create ) {
430 $has_id = isset( $input['group_id'] ) && '' !== $input['group_id'];
431 $has_name = isset( $input['group_name'] ) && '' !== trim( (string) $input['group_name'] );
432
433 if ( $has_id && $has_name ) {
434 return AbilityError::invalid_input(
435 'group_name',
436 __( 'Send group_id or group_name, not both.', 'betterdocs' )
437 );
438 }
439
440 if ( $has_id ) {
441 return $this->resolve_group( (int) $input['group_id'], false );
442 }
443
444 if ( $has_name ) {
445 return $this->resolve_group( (string) $input['group_name'], $create );
446 }
447
448 return null;
449 }
450
451 /**
452 * Turn an answer an agent wrote into the HTML an FAQ stores.
453 *
454 * FAQ answers are plain HTML, not blocks: the FAQ Builder edits them in
455 * TinyMCE and the front end prints `the_content()` of a post that never sees
456 * the block editor. Markdown is converted with Parsedown in safe mode and
457 * passed through `wp_kses_post()`; HTML is passed through `wp_kses_post()`
458 * too, so both formats store the same class of markup — which is also what
459 * `FAQBuilder::update_betterdocs_faq()` enforces on its own path.
460 *
461 * @since 4.9.0
462 *
463 * @param string $answer Raw answer.
464 * @param string $format `markdown` or `html`.
465 * @return string
466 */
467 protected function answer_html( $answer, $format ) {
468 $answer = (string) $answer;
469
470 if ( 'html' === $format ) {
471 return wp_kses_post( $answer );
472 }
473
474 return BlockBuilder::markdown_to_html( $answer );
475 }
476
477 /**
478 * Translate a refusal from an FAQ route into the typed vocabulary.
479 *
480 * The FAQ Builder routes answer with whatever `wp_insert_term()`,
481 * `wp_update_post()` or the permission callback produced, so this covers
482 * both families: the legacy codes and the `wp/v2` ones the status and read
483 * paths can return.
484 *
485 * @since 4.9.0
486 *
487 * @param \WP_Error $error What the route returned.
488 * @param string $what What the caller was trying to do, as a phrase.
489 * @return \WP_Error
490 */
491 protected function map_faq_error( \WP_Error $error, $what ) {
492 $code = $error->get_error_code();
493 $message = $error->get_error_message();
494 $data = (array) $error->get_error_data();
495
496 switch ( $code ) {
497 case 'rest_forbidden':
498 case 'rest_forbidden_context':
499 case 'rest_cannot_create':
500 case 'rest_cannot_edit':
501 case 'rest_cannot_edit_others':
502 case 'rest_cannot_delete':
503 case 'rest_cannot_read':
504 case 'rest_cannot_update':
505 // Every FAQ route is gated on the one additive capability
506 // BetterDocs adds there, so there is only one capability to name.
507 return AbilityError::capability_missing( 'edit_others_docs', $what );
508
509 case 'rest_cannot_publish':
510 return AbilityError::capability_missing( 'publish_docs', $what );
511
512 case 'betterdocs_invalid_faq':
513 case 'rest_post_invalid_id':
514 return AbilityError::not_found( 'FAQ', isset( $data['id'] ) ? $data['id'] : 0 );
515
516 case 'invalid_term_id':
517 case 'rest_term_invalid':
518 case 'rest_term_invalid_id':
519 return AbilityError::not_found( 'FAQ group', isset( $data['id'] ) ? $data['id'] : 0 );
520
521 case 'term_exists':
522 $existing = is_array( $data ) && isset( $data['term_id'] ) ? (int) $data['term_id'] : (int) ( is_scalar( $data ) ? $data : 0 );
523
524 return AbilityError::conflict(
525 __( 'An FAQ group with that name already exists.', 'betterdocs' ),
526 [
527 'object' => 'FAQ group',
528 'id' => $existing
529 ]
530 );
531
532 case 'empty_term_name':
533 return AbilityError::invalid_input( 'title', __( 'An FAQ group needs a name.', 'betterdocs' ) );
534
535 case 'rest_already_trashed':
536 return AbilityError::conflict( $message, [ 'object' => 'FAQ' ] );
537
538 case 'rest_invalid_param':
539 $field = isset( $data['params'] ) && is_array( $data['params'] ) ? (string) key( $data['params'] ) : '';
540
541 return AbilityError::invalid_input(
542 '' !== $field ? $field : 'input',
543 '' !== $field && isset( $data['params'][ $field ] ) ? (string) $data['params'][ $field ] : $message
544 );
545
546 default:
547 return AbilityError::upstream( $message, [ 'code' => (string) $code ] );
548 }
549 }
550 }
551