PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
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 / Docs / GetDoc.php

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

297 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Read one doc ability.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities\Docs;
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\ShapesDocs;
19 use WPDeveloper\BetterDocs\Utils\BlockBuilder;
20
21 /**
22 * Read one doc, by id or by slug.
23 *
24 * Returns **both** forms of the body: `content.raw` is the stored block markup,
25 * which is what `bd-update-doc` expects back, and `content.rendered` is what a
26 * visitor sees. An agent asked to "fix the second paragraph" needs the raw
27 * form; an agent asked "does this page mention X" needs the rendered one.
28 *
29 * `faq_groups` reports which FAQ groups the doc's blocks filter to, decoded
30 * from the block attributes by {@see BlockBuilder::find_faq_blocks()} — the
31 * question `bd-attach-faq` will need answered before it changes anything.
32 * It is read from the post itself rather than from the response, so it is the
33 * same answer whichever context the read ran in.
34 *
35 * **The context degrades rather than refusing.** `WP_REST_Posts_Controller`
36 * answers `context=edit` with 403 `rest_forbidden_context` for a doc the caller
37 * may read but not edit (measured on WordPress 7.1: `mcp-author` on another
38 * user's published doc). Refusing outright would tell an agent it cannot read a
39 * doc it demonstrably can, so the request is made in `view` context instead and
40 * `content.raw` comes back empty — which is exactly what "you may read this,
41 * not edit it" means.
42 *
43 * @since 4.9.0
44 */
45 class GetDoc extends AbilityBase {
46
47 use ResolvesTerms;
48 use ShapesDocs;
49
50 /**
51 * @since 4.9.0
52 */
53 public function __construct() {
54 $this->id = 'betterdocs/get-doc';
55 $this->label = __( 'Get doc', 'betterdocs' );
56 $this->description = __( 'Read one BetterDocs doc by id or slug: its raw block content, its rendered HTML, its categories, tags and knowledge bases, its view and reaction counts, and which FAQ groups its FAQ blocks show.', 'betterdocs' );
57 $this->capability = 'edit_docs';
58 }
59
60 /**
61 * @since 4.9.0
62 *
63 * @return array
64 */
65 public function get_annotations() {
66 return [
67 'readonly' => true,
68 'destructive' => false,
69 'idempotent' => true,
70 'priority' => 1.5,
71 'openWorldHint' => false
72 ];
73 }
74
75 /**
76 * @since 4.9.0
77 *
78 * @return array
79 */
80 public function get_input_schema() {
81 return [
82 'type' => 'object',
83 'additionalProperties' => false,
84 'properties' => [
85 'id' => [
86 'type' => 'integer',
87 'description' => __( 'The doc id. Give this or slug.', 'betterdocs' )
88 ],
89 'slug' => [
90 'type' => 'string',
91 'description' => __( 'The doc slug, as it appears in the URL. Give this or id.', 'betterdocs' )
92 ],
93 'context' => [
94 'type' => 'string',
95 'enum' => [ 'view', 'edit' ],
96 'default' => 'edit',
97 'description' => __( 'edit (default) includes the raw block content, and quietly falls back to view for a doc you may read but not edit; view returns only the rendered HTML.', 'betterdocs' )
98 ]
99 ],
100 'default' => []
101 ];
102 }
103
104 /**
105 * @since 4.9.0
106 *
107 * @return array
108 */
109 public function get_output_schema() {
110 return [
111 'type' => 'object',
112 'properties' => array_merge(
113 self::doc_summary_schema(),
114 [
115 'content' => [
116 'type' => 'object',
117 'properties' => [
118 'raw' => [ 'type' => 'string' ],
119 'rendered' => [ 'type' => 'string' ]
120 ]
121 ],
122 'total_views' => [ 'type' => 'integer' ],
123 'reactions' => [
124 'type' => 'object',
125 'properties' => [
126 'happy' => [ 'type' => 'integer' ],
127 'normal' => [ 'type' => 'integer' ],
128 'sad' => [ 'type' => 'integer' ]
129 ]
130 ],
131 'faq_groups' => [
132 'type' => 'array',
133 'items' => [ 'type' => 'integer' ]
134 ]
135 ]
136 )
137 ];
138 }
139
140 /**
141 * @since 4.9.0
142 *
143 * @param array $input Validated input.
144 * @return array|\WP_Error
145 */
146 public function execute( $input ) {
147 $context = isset( $input['context'] ) ? (string) $input['context'] : 'edit';
148 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
149 $slug = isset( $input['slug'] ) ? trim( (string) $input['slug'] ) : '';
150
151 if ( $id <= 0 && '' === $slug ) {
152 return AbilityError::invalid_input( 'id', __( 'Give either id or slug.', 'betterdocs' ) );
153 }
154
155 if ( $id <= 0 ) {
156 $id = $this->id_for_slug( $slug );
157
158 if ( is_wp_error( $id ) ) {
159 return $id;
160 }
161 }
162
163 $post = $this->require_doc( $id );
164
165 if ( is_wp_error( $post ) ) {
166 return $post;
167 }
168
169 if ( ! current_user_can( 'read_post', $post->ID ) ) {
170 return AbilityError::capability_missing(
171 'read_private_docs',
172 sprintf(
173 /* translators: %d: doc id. */
174 __( 'read doc #%d', 'betterdocs' ),
175 (int) $post->ID
176 )
177 );
178 }
179
180 // Edit context is refused outright for a doc this user may read but not
181 // edit, so ask for it only when it can be given.
182 if ( 'edit' === $context && ! current_user_can( 'edit_post', $post->ID ) ) {
183 $context = 'view';
184 }
185
186 $doc = $this->dispatch( 'GET', '/docs/' . (int) $post->ID, [ 'context' => $context ], 'wp/v2' );
187
188 if ( is_wp_error( $doc ) ) {
189 return $this->map_rest_error(
190 $doc,
191 sprintf(
192 /* translators: %d: doc id. */
193 __( 'read doc #%d', 'betterdocs' ),
194 (int) $post->ID
195 ),
196 $post
197 );
198 }
199
200 $doc = (array) $doc;
201
202 return array_merge(
203 $this->doc_summary( $doc ),
204 [
205 'content' => [
206 // Empty in view context: WordPress does not hand the stored
207 // markup to someone who cannot edit the post, and neither
208 // should this.
209 'raw' => isset( $doc['content']['raw'] ) ? (string) $doc['content']['raw'] : '',
210 'rendered' => isset( $doc['content']['rendered'] ) ? (string) $doc['content']['rendered'] : ''
211 ],
212 'total_views' => isset( $doc['total_views'] ) ? (int) $doc['total_views'] : 0,
213 'reactions' => $this->reactions( isset( $doc['reactions'] ) ? (array) $doc['reactions'] : [] ),
214 // From the post, not the response: the block attributes are the
215 // same whichever context was granted, and reporting no FAQ
216 // groups because the context was narrowed would be a wrong
217 // answer rather than a withheld one.
218 'faq_groups' => $this->faq_group_ids( (string) $post->post_content )
219 ]
220 );
221 }
222
223 /**
224 * The doc id behind a slug.
225 *
226 * `status => any` because an agent asked to finish a draft knows its slug,
227 * not its id; the ability's own `edit_docs` gate and the `read_post` check
228 * below are what decide whether it may see it.
229 *
230 * @since 4.9.0
231 *
232 * @param string $slug Doc slug.
233 * @return int|\WP_Error
234 */
235 protected function id_for_slug( $slug ) {
236 $found = $this->dispatch(
237 'GET',
238 '/docs',
239 [
240 'slug' => $slug,
241 'status' => 'any',
242 'per_page' => 1,
243 'context' => 'edit'
244 ],
245 'wp/v2'
246 );
247
248 if ( is_wp_error( $found ) ) {
249 return $this->map_rest_error( $found, __( 'look a doc up by slug', 'betterdocs' ) );
250 }
251
252 if ( ! is_array( $found ) || empty( $found[0]['id'] ) ) {
253 return AbilityError::not_found( 'doc', $slug );
254 }
255
256 return (int) $found[0]['id'];
257 }
258
259 /**
260 * Reaction counts as integers, with every key present.
261 *
262 * @since 4.9.0
263 *
264 * @param array $reactions What the REST field returned.
265 * @return array
266 */
267 protected function reactions( array $reactions ) {
268 return [
269 'happy' => isset( $reactions['happy'] ) ? (int) $reactions['happy'] : 0,
270 'normal' => isset( $reactions['normal'] ) ? (int) $reactions['normal'] : 0,
271 'sad' => isset( $reactions['sad'] ) ? (int) $reactions['sad'] : 0
272 ];
273 }
274
275 /**
276 * FAQ group ids the doc's FAQ blocks include.
277 *
278 * @since 4.9.0
279 *
280 * @param string $raw Raw block content.
281 * @return int[]
282 */
283 protected function faq_group_ids( $raw ) {
284 $ids = [];
285
286 foreach ( BlockBuilder::find_faq_blocks( $raw ) as $block ) {
287 foreach ( $block['include'] as $group ) {
288 if ( isset( $group['id'] ) ) {
289 $ids[] = (int) $group['id'];
290 }
291 }
292 }
293
294 return array_values( array_unique( $ids ) );
295 }
296 }
297