PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
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 / Abilities / Faq / AttachFAQ.php

AttachFAQ.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/Abilities/Faq/AttachFAQ.php

514 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Attach FAQ groups to a doc ability.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities\Faq;
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\ShapesDocs;
18 use WPDeveloper\BetterDocs\Abilities\Traits\ShapesFAQs;
19 use WPDeveloper\BetterDocs\Utils\BlockBuilder;
20
21 /**
22 * Put FAQ groups on a doc — by writing a block into its content, because that
23 * is the only place the relationship exists.
24 *
25 * There is no doc → FAQ-group relation field in BetterDocs: a doc shows FAQs
26 * because its content carries a `betterdocs/faq` block whose `includeFaqGroup`
27 * attribute names the groups. So "attach" means "edit the content", and the two
28 * things that follow from that are what this tool is really for:
29 *
30 * - **It is idempotent.** Running it twice with the same groups leaves one
31 * block, not two: `append` looks for a block that already filters on exactly
32 * this id set before adding anything.
33 * - **It repairs as it goes.** A block written by any other REST client is
34 * likely to hold the bare-id form `"[5]"`, which renders correctly since
35 * 4.9.0 but opens in Gutenberg with an empty group picker. Every FAQ block in
36 * the doc is rewritten to the `{value, label}` object form on the way past,
37 * and the answer says how many needed it.
38 *
39 * The write goes through `POST wp/v2/docs/<id>`, never `wp_update_post()`:
40 * that function unslashes its input, which eats the backslashes in the block
41 * attribute's JSON and leaves a block whose filter matches nothing — measured,
42 * not theoretical.
43 *
44 * @since 4.9.0
45 */
46 class AttachFAQ extends AbilityBase {
47
48 use ShapesDocs;
49 use ShapesFAQs;
50
51 /**
52 * @since 4.9.0
53 */
54 public function __construct() {
55 $this->id = 'betterdocs/attach-faq';
56 $this->label = __( 'Attach FAQ groups to a doc', 'betterdocs' );
57 $this->description = __( 'Show FAQ groups on a doc. Attaching writes a betterdocs/faq block into the doc content (there is no relation field). Re-running with the same groups is a no-op. Use replace_faq_blocks to narrow to a different set. Bare-id FAQ blocks already in the doc are repaired to the form the block editor can read.', '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' => false,
69 'destructive' => false,
70 'idempotent' => true,
71 'priority' => 2.0,
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' => [ 'doc_id' ],
86 'properties' => [
87 'doc_id' => [
88 'type' => 'integer',
89 'description' => __( 'The doc to attach the FAQ groups to. Required.', 'betterdocs' )
90 ],
91 'group_ids' => [
92 'type' => 'array',
93 'items' => [ 'type' => 'integer' ],
94 'description' => __( 'FAQ groups to show, by id.', 'betterdocs' )
95 ],
96 'group_names' => [
97 'type' => 'array',
98 'items' => [ 'type' => 'string' ],
99 'description' => __( 'FAQ groups to show, by name or slug. They must already exist — this tool never creates one; use bd-create-faq-group.', 'betterdocs' )
100 ],
101 'mode' => [
102 'type' => 'string',
103 'enum' => [ 'append', 'replace_faq_blocks' ],
104 'default' => 'append',
105 'description' => __( 'append adds a block unless one already filters on exactly these groups. replace_faq_blocks removes every FAQ block in the doc first, so the doc ends up showing these groups and nothing else; with no groups at all it just removes them.', 'betterdocs' )
106 ],
107 'layout' => [
108 'type' => 'string',
109 'enum' => [ 'layout-1', 'layout-2', 'layout-3', 'layout-4' ],
110 'description' => __( 'FAQ block layout: layout-1 Modern, layout-2 Classic, layout-3 Abstract, layout-4 Tab. Defaults to the block\'s own layout-1.', 'betterdocs' )
111 ],
112 'exclude_group_ids' => [
113 'type' => 'array',
114 'items' => [ 'type' => 'integer' ],
115 'description' => __( 'FAQ groups to leave out of this block, by id — the block\'s excludeFaqGroup attribute.', 'betterdocs' )
116 ]
117 ],
118 'default' => []
119 ];
120 }
121
122 /**
123 * @since 4.9.0
124 *
125 * @return array
126 */
127 public function get_output_schema() {
128 return [
129 'type' => 'object',
130 'properties' => [
131 'doc_id' => [ 'type' => 'integer' ],
132 'url' => [ 'type' => 'string' ],
133 'mode' => [ 'type' => 'string' ],
134 'faq_groups' => [
135 'type' => 'array',
136 'items' => [
137 'type' => 'object',
138 'properties' => [
139 'id' => [ 'type' => 'integer' ],
140 'name' => [ 'type' => 'string' ]
141 ]
142 ]
143 ],
144 'changed' => [ 'type' => 'boolean' ],
145 'repaired_blocks' => [ 'type' => 'integer' ],
146 'faq_blocks_now' => [
147 'type' => 'array',
148 'items' => [
149 'type' => 'array',
150 'items' => [ 'type' => 'integer' ]
151 ]
152 ]
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 $doc_id = isset( $input['doc_id'] ) ? (int) $input['doc_id'] : 0;
165 $mode = isset( $input['mode'] ) ? (string) $input['mode'] : 'append';
166
167 $doc = $this->require_doc( $doc_id );
168
169 if ( is_wp_error( $doc ) ) {
170 return $doc;
171 }
172
173 if ( ! current_user_can( 'edit_post', $doc_id ) ) {
174 return AbilityError::capability_missing(
175 $this->editing_capability( $doc ),
176 sprintf(
177 /* translators: %d: doc id. */
178 __( 'attach FAQ groups to doc #%d', 'betterdocs' ),
179 $doc_id
180 )
181 );
182 }
183
184 $groups = $this->resolve_groups( $input, $mode );
185
186 if ( is_wp_error( $groups ) ) {
187 return $groups;
188 }
189
190 $excluded = $this->resolve_excluded( $input );
191
192 if ( is_wp_error( $excluded ) ) {
193 return $excluded;
194 }
195
196 $item = $this->dispatch( 'GET', '/docs/' . $doc_id, [ 'context' => 'edit' ], 'wp/v2' );
197
198 if ( is_wp_error( $item ) ) {
199 return $this->map_rest_error( $item, __( 'read the doc\'s content', 'betterdocs' ), $doc );
200 }
201
202 $item = (array) $item;
203 $raw = isset( $item['content']['raw'] ) ? (string) $item['content']['raw'] : '';
204
205 // Repair first, so an existing bare-id block is compared and counted in
206 // the form the editor can read — and so the "nothing changed" answer
207 // below is about this call's groups, not about a legacy encoding.
208 $repaired = BlockBuilder::repair_faq_blocks( $raw, [ $this, 'group_label' ] );
209 $repairs = $this->count_repairs( $raw, $repaired );
210
211 $content = $this->apply( $repaired, $groups, $excluded, $mode, $input );
212 $changed = $content !== $raw;
213
214 if ( $changed ) {
215 $written = $this->dispatch( 'POST', '/docs/' . $doc_id, [ 'content' => $content ], 'wp/v2' );
216
217 if ( is_wp_error( $written ) ) {
218 return $this->map_rest_error( $written, __( 'write the doc\'s content', 'betterdocs' ), $doc );
219 }
220
221 // Read back rather than trusting what was sent: the controller has
222 // its own filters, and `faq_blocks_now` is meant to describe the doc
223 // as it now is.
224 $after = $this->dispatch( 'GET', '/docs/' . $doc_id, [ 'context' => 'edit' ], 'wp/v2' );
225
226 if ( ! is_wp_error( $after ) ) {
227 $after = (array) $after;
228 $content = isset( $after['content']['raw'] ) ? (string) $after['content']['raw'] : $content;
229 $item = $after;
230 }
231 }
232
233 return [
234 'doc_id' => $doc_id,
235 'url' => isset( $item['link'] ) ? (string) $item['link'] : '',
236 'mode' => $mode,
237 'faq_groups' => array_map(
238 static function ( array $group ) {
239 return [
240 'id' => (int) $group['id'],
241 'name' => (string) $group['label']
242 ];
243 },
244 $groups
245 ),
246 'changed' => $changed,
247 'repaired_blocks' => $repairs,
248 'faq_blocks_now' => $this->blocks_now( $content )
249 ];
250 }
251
252 /**
253 * The term name for a group id — the label the Gutenberg picker shows.
254 *
255 * Public because {@see BlockBuilder::repair_faq_blocks()} takes it as a
256 * callable.
257 *
258 * @since 4.9.0
259 *
260 * @param int $id Term id.
261 * @return string
262 */
263 public function group_label( $id ) {
264 $summary = $this->group_summary( (int) $id );
265
266 return null === $summary ? '' : $summary['name'];
267 }
268
269 /**
270 * Turn `group_ids` and `group_names` into `[ [ 'id', 'label' ], … ]`.
271 *
272 * Names are find-only: attaching a group that does not exist is a mistake
273 * worth reporting, and a silently created empty group would render as an
274 * empty FAQ section on a published doc.
275 *
276 * @since 4.9.0
277 *
278 * @param array $input Validated input.
279 * @param string $mode `append` or `replace_faq_blocks`.
280 * @return array|\WP_Error
281 */
282 protected function resolve_groups( array $input, $mode ) {
283 $refs = array_merge(
284 isset( $input['group_ids'] ) ? (array) $input['group_ids'] : [],
285 isset( $input['group_names'] ) ? (array) $input['group_names'] : []
286 );
287
288 if ( empty( $refs ) ) {
289 if ( 'replace_faq_blocks' === $mode ) {
290 // "Show these groups and nothing else", with an empty list, is
291 // a legitimate instruction: remove every FAQ block.
292 return [];
293 }
294
295 return AbilityError::invalid_input(
296 'group_ids',
297 __( 'Name at least one FAQ group, by id in group_ids or by name in group_names.', 'betterdocs' )
298 );
299 }
300
301 $groups = [];
302
303 foreach ( $refs as $ref ) {
304 $id = $this->resolve_group( $ref, false );
305
306 if ( is_wp_error( $id ) ) {
307 return $id;
308 }
309
310 $groups[ (int) $id ] = [
311 'id' => (int) $id,
312 'label' => $this->group_label( $id )
313 ];
314 }
315
316 return array_values( $groups );
317 }
318
319 /**
320 * The `exclude_group_ids` input, resolved the same way.
321 *
322 * @since 4.9.0
323 *
324 * @param array $input Validated input.
325 * @return array|\WP_Error
326 */
327 protected function resolve_excluded( array $input ) {
328 if ( empty( $input['exclude_group_ids'] ) ) {
329 return [];
330 }
331
332 $excluded = [];
333
334 foreach ( (array) $input['exclude_group_ids'] as $ref ) {
335 $id = $this->resolve_group( $ref, false );
336
337 if ( is_wp_error( $id ) ) {
338 return $id;
339 }
340
341 $excluded[ (int) $id ] = [
342 'id' => (int) $id,
343 'label' => $this->group_label( $id )
344 ];
345 }
346
347 return array_values( $excluded );
348 }
349
350 /**
351 * The content this call wants the doc to have.
352 *
353 * @since 4.9.0
354 *
355 * @param string $content Repaired content.
356 * @param array $groups Groups to attach.
357 * @param array $excluded Groups to exclude.
358 * @param string $mode `append` or `replace_faq_blocks`.
359 * @param array $input Validated input.
360 * @return string
361 */
362 protected function apply( $content, array $groups, array $excluded, $mode, array $input ) {
363 if ( 'replace_faq_blocks' === $mode ) {
364 return BlockBuilder::replace_faq_blocks( $content, $this->markup( $groups, $excluded, $input ) );
365 }
366
367 if ( $this->already_attached( $content, $groups ) ) {
368 return $content;
369 }
370
371 return BlockBuilder::append_block( $content, $this->markup( $groups, $excluded, $input ) );
372 }
373
374 /**
375 * The block markup for this call, or `''` when there is nothing to write.
376 *
377 * @since 4.9.0
378 *
379 * @param array $groups Groups to attach.
380 * @param array $excluded Groups to exclude.
381 * @param array $input Validated input.
382 * @return string
383 */
384 protected function markup( array $groups, array $excluded, array $input ) {
385 if ( empty( $groups ) ) {
386 return '';
387 }
388
389 $attrs = [];
390
391 if ( isset( $input['layout'] ) && '' !== $input['layout'] ) {
392 $attrs['faqLayout'] = (string) $input['layout'];
393 }
394
395 if ( ! empty( $excluded ) ) {
396 $attrs['excludeFaqGroup'] = BlockBuilder::encode_groups( $excluded );
397 }
398
399 return BlockBuilder::faq_block( $groups, $attrs );
400 }
401
402 /**
403 * Whether the doc already carries a block filtering on exactly these
404 * groups.
405 *
406 * Order-insensitive, and about the **id set** only: a block on the same
407 * groups with a different layout is still that attachment, and adding a
408 * second copy of it is what an agent re-running its own instruction must
409 * not cause.
410 *
411 * @since 4.9.0
412 *
413 * @param string $content Post content.
414 * @param array $groups Groups to attach.
415 * @return bool
416 */
417 protected function already_attached( $content, array $groups ) {
418 $wanted = $this->ids_of( $groups );
419
420 foreach ( BlockBuilder::find_faq_blocks( $content ) as $found ) {
421 if ( $this->ids_of( $found['include'] ) === $wanted ) {
422 return true;
423 }
424 }
425
426 return false;
427 }
428
429 /**
430 * A sorted, unique id list for comparison.
431 *
432 * @since 4.9.0
433 *
434 * @param array $groups `[ [ 'id' => int, … ], … ]`.
435 * @return int[]
436 */
437 protected function ids_of( array $groups ) {
438 $ids = [];
439
440 foreach ( $groups as $group ) {
441 if ( isset( $group['id'] ) ) {
442 $ids[] = (int) $group['id'];
443 }
444 }
445
446 $ids = array_values( array_unique( $ids ) );
447
448 sort( $ids );
449
450 return $ids;
451 }
452
453 /**
454 * How many FAQ blocks the repair pass rewrote.
455 *
456 * `BlockBuilder::repair_faq_blocks()` answers with content, not a count —
457 * correctly, since it must return the input byte-for-byte when nothing
458 * needed doing. The blocks are in the same order either way, so comparing
459 * the two group attributes block by block is the count.
460 *
461 * @since 4.9.0
462 *
463 * @param string $before Content as it was stored.
464 * @param string $after Content after the repair pass.
465 * @return int
466 */
467 protected function count_repairs( $before, $after ) {
468 if ( $before === $after ) {
469 return 0;
470 }
471
472 $was = BlockBuilder::find_faq_blocks( $before );
473 $now = BlockBuilder::find_faq_blocks( $after );
474 $out = 0;
475
476 foreach ( $was as $index => $block ) {
477 if ( ! isset( $now[ $index ] ) ) {
478 continue;
479 }
480
481 foreach ( [ 'includeFaqGroup', 'excludeFaqGroup' ] as $attribute ) {
482 $old = isset( $block['block']['attrs'][ $attribute ] ) ? $block['block']['attrs'][ $attribute ] : null;
483 $new = isset( $now[ $index ]['block']['attrs'][ $attribute ] ) ? $now[ $index ]['block']['attrs'][ $attribute ] : null;
484
485 if ( $old !== $new ) {
486 ++$out;
487 break;
488 }
489 }
490 }
491
492 return $out;
493 }
494
495 /**
496 * The group ids each FAQ block in the content filters on, in document
497 * order.
498 *
499 * @since 4.9.0
500 *
501 * @param string $content Post content.
502 * @return array[]
503 */
504 protected function blocks_now( $content ) {
505 $out = [];
506
507 foreach ( BlockBuilder::find_faq_blocks( $content ) as $found ) {
508 $out[] = $this->ids_of( $found['include'] );
509 }
510
511 return $out;
512 }
513 }
514