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 / Terms / CreateTerm.php

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

414 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Create a doc category or doc tag 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 * Create a doc category or a doc tag — or hand back the one that already
22 * exists.
23 *
24 * **Find-or-create.** A duplicate name is not an error: WordPress answers
25 * `term_exists` with the existing id, and this returns that term with
26 * `created: false`. An agent building a taxonomy from a list of names can run
27 * the same batch twice and get the same twelve categories, which is the only
28 * way "make sure these categories exist" is expressible as a tool call.
29 *
30 * `knowledge_bases` writes the `doc_category_knowledge_base` term meta
31 * registered for REST — the assignment that decides a category's permalink and
32 * which knowledge base archive it belongs to. It is `doc_category` only, needs
33 * `manage_knowledge_base_terms`, and never creates a knowledge base.
34 *
35 * @since 4.9.0
36 */
37 class CreateTerm extends AbilityBase {
38
39 use ResolvesTerms;
40 use ShapesTerms;
41
42 /**
43 * @since 4.9.0
44 */
45 public function __construct() {
46 $this->id = 'betterdocs/create-term';
47 $this->label = __( 'Create term', 'betterdocs' );
48 $this->description = __( 'Create a BetterDocs doc category or doc tag. A name that already exists is returned as it is, with created:false, so the call is safe to repeat. Doc categories may also be filed into knowledge bases, which must already exist.', 'betterdocs' );
49 $this->capability = 'manage_doc_terms';
50 }
51
52 /**
53 * @since 4.9.0
54 *
55 * @return array
56 */
57 public function get_annotations() {
58 return [
59 'readonly' => false,
60 'destructive' => false,
61 'idempotent' => false,
62 'priority' => 2.0,
63 'openWorldHint' => false
64 ];
65 }
66
67 /**
68 * @since 4.9.0
69 *
70 * @return array
71 */
72 public function get_input_schema() {
73 return [
74 'type' => 'object',
75 'additionalProperties' => false,
76 'required' => [ 'taxonomy', 'name' ],
77 'properties' => [
78 'taxonomy' => self::taxonomy_schema(),
79 'name' => [
80 'type' => 'string',
81 'description' => __( 'The term name, as a human would write it. Required.', 'betterdocs' )
82 ],
83 'slug' => [
84 'type' => 'string',
85 'description' => __( 'URL slug. Derived from the name when omitted.', 'betterdocs' )
86 ],
87 'description' => [
88 'type' => 'string',
89 'description' => __( 'Optional description shown on the term archive.', 'betterdocs' )
90 ],
91 'parent' => [
92 'type' => [ 'integer', 'string', 'null' ],
93 'description' => __( 'Parent doc category, by id or name. Send 0 (or null) to move it to the top level. doc_category only.', 'betterdocs' )
94 ],
95 'knowledge_bases' => self::term_ref_schema( __( 'Knowledge bases to file this doc category into, by id, slug or name. doc_category only; never created here — use bd-create-knowledge-base.', 'betterdocs' ) ),
96 'status' => [
97 'type' => 'string',
98 'enum' => [ 'publish', 'draft' ],
99 'description' => __( 'Whether the glossary term is shown on the front end. glossaries only; defaults to publish.', 'betterdocs' )
100 ],
101 'order' => [
102 'type' => 'integer',
103 'minimum' => 0,
104 'description' => __( 'Sort position among glossary terms, lowest first. glossaries only.', 'betterdocs' )
105 ]
106 ],
107 'default' => []
108 ];
109 }
110
111 /**
112 * @since 4.9.0
113 *
114 * @return array
115 */
116 public function get_output_schema() {
117 return [
118 'type' => 'object',
119 'properties' => array_merge(
120 self::term_shape_schema(),
121 [ 'created' => [ 'type' => 'boolean' ] ]
122 )
123 ];
124 }
125
126 /**
127 * @since 4.9.0
128 *
129 * @param array $input Validated input.
130 * @return array|\WP_Error
131 */
132 public function execute( $input ) {
133 $taxonomy = isset( $input['taxonomy'] ) ? (string) $input['taxonomy'] : '';
134
135 // Before anything is read or written: a taxonomy that is switched off
136 // answers with the setting to change, not with `not_found` (ADR-061).
137 $available = $this->taxonomy_available( $taxonomy );
138
139 if ( is_wp_error( $available ) ) {
140 return $available;
141 }
142
143 $params = $this->build_params( $input, $taxonomy );
144
145 if ( is_wp_error( $params ) ) {
146 return $params;
147 }
148
149 // Resolved *before* anything is written. Doing it after the term exists
150 // turns a refusal into a half-finished job: measured on the rig with Pro
151 // deactivated, `bd-create-term {name, knowledge_bases}` answered
152 // `pro_required` and left the new category behind anyway.
153 $kb_slugs = $this->resolve_kb_input( $input, $taxonomy );
154
155 if ( is_wp_error( $kb_slugs ) ) {
156 return $kb_slugs;
157 }
158
159 $created = true;
160 $term = $this->dispatch( 'POST', '/' . $taxonomy, $params, 'wp/v2' );
161
162 if ( is_wp_error( $term ) ) {
163 $existing = $this->existing_term_id( $term );
164
165 if ( 0 === $existing ) {
166 return $this->map_term_error(
167 $term,
168 $taxonomy,
169 sprintf(
170 /* translators: %s: term type, e.g. "doc category". */
171 __( 'create a %s', 'betterdocs' ),
172 $this->term_object_name( $taxonomy )
173 )
174 );
175 }
176
177 // Find-or-create: the name is taken, so hand back what is there.
178 $created = false;
179 $term = $this->dispatch( 'GET', '/' . $taxonomy . '/' . $existing, [ 'context' => 'view' ], 'wp/v2' );
180
181 if ( is_wp_error( $term ) ) {
182 return $this->map_term_error( $term, $taxonomy, __( 'read the term that already exists', 'betterdocs' ) );
183 }
184 }
185
186 $term = (array) $term;
187
188 if ( empty( $term['id'] ) ) {
189 return AbilityError::upstream( __( 'The term was not created and WordPress reported no reason.', 'betterdocs' ) );
190 }
191
192 if ( null !== $kb_slugs ) {
193 $assigned = $this->assign_knowledge_bases( (int) $term['id'], $kb_slugs );
194
195 if ( is_wp_error( $assigned ) ) {
196 return $assigned;
197 }
198
199 $term = $assigned;
200 }
201
202 return array_merge( $this->term_shape( $term, $taxonomy ), [ 'created' => $created ] );
203 }
204
205 /**
206 * Turn validated input into `wp/v2/<taxonomy>` parameters.
207 *
208 * Shared with {@see UpdateTerm}.
209 *
210 * @since 4.9.0
211 *
212 * @param array $input Validated input.
213 * @param string $taxonomy Taxonomy name.
214 * @return array|\WP_Error
215 */
216 protected function build_params( array $input, $taxonomy ) {
217 $params = [];
218
219 foreach ( [ 'name', 'slug', 'description' ] as $field ) {
220 if ( isset( $input[ $field ] ) ) {
221 $params[ $field ] = (string) $input[ $field ];
222 }
223 }
224
225 // A glossary term's description is the `glossary_term_description` meta,
226 // not the term's own column: BetterDocs drains that column into the meta
227 // and blanks it, and both the admin screen and the A–Z front end read the
228 // meta first (ADR-061). Writing the column instead would be silently
229 // discarded the next time someone edited the term.
230 if ( 'glossaries' === $taxonomy && array_key_exists( 'description', $params ) ) {
231 $params['meta'][ self::GLOSSARY_DESCRIPTION_META ] = $params['description'];
232
233 unset( $params['description'] );
234 }
235
236 $meta = $this->glossary_meta( $input, $taxonomy );
237
238 if ( is_wp_error( $meta ) ) {
239 return $meta;
240 }
241
242 if ( [] !== $meta ) {
243 $params['meta'] = isset( $params['meta'] ) ? array_merge( $params['meta'], $meta ) : $meta;
244 }
245
246 if ( isset( $input['knowledge_bases'] ) && 'doc_category' !== $taxonomy ) {
247 return AbilityError::invalid_input(
248 'knowledge_bases',
249 __( 'Only doc categories belong to knowledge bases.', 'betterdocs' )
250 );
251 }
252
253 if ( ! array_key_exists( 'parent', $input ) ) {
254 return $params;
255 }
256
257 if ( 'doc_category' !== $taxonomy ) {
258 return AbilityError::invalid_input(
259 'parent',
260 __( 'These tools nest doc categories only; a doc tag takes no parent.', 'betterdocs' )
261 );
262 }
263
264 // `0`, `"0"` and `null` mean "the top level" — the same convention
265 // `bd-list-terms`' parent filter uses (ADR-059, finding B). WordPress
266 // un-nests a category sent `parent: 0`, so the value is passed straight
267 // through: id 0 is not a term and would otherwise resolve to `not_found`,
268 // which is why moving a category to the top level used to be refused.
269 $parent_ref = $input['parent'];
270
271 if ( null === $parent_ref || 0 === $parent_ref || '0' === $parent_ref ) {
272 $params['parent'] = 0;
273
274 return $params;
275 }
276
277 // `false`: naming a parent that does not exist is a mistake worth
278 // reporting, not an instruction to invent a category.
279 $parent = $this->resolve_terms( [ $parent_ref ], 'doc_category', false );
280
281 if ( is_wp_error( $parent ) ) {
282 return $parent;
283 }
284
285 $params['parent'] = isset( $parent[0] ) ? (int) $parent[0] : 0;
286
287 return $params;
288 }
289
290 /**
291 * The id WordPress reports on a duplicate name, or 0 when the error is
292 * something else.
293 *
294 * @since 4.9.0
295 *
296 * @param \WP_Error $error What the controller returned.
297 * @return int
298 */
299 protected function existing_term_id( \WP_Error $error ) {
300 if ( 'term_exists' !== $error->get_error_code() ) {
301 return 0;
302 }
303
304 $data = $error->get_error_data();
305
306 if ( is_array( $data ) && ! empty( $data['term_id'] ) ) {
307 return (int) $data['term_id'];
308 }
309
310 // `wp_insert_term()` puts the id in the data directly; the REST
311 // controller adds the `term_id` key on top. Accept either.
312 return is_scalar( $data ) ? (int) $data : 0;
313 }
314
315 /**
316 * Resolve the `knowledge_bases` input to slugs, or null when there is none.
317 *
318 * Every reason this can refuse — Pro absent, Multiple Knowledge Base off, a
319 * knowledge base that does not exist, the wrong taxonomy — is known before
320 * the first write, which is the point of doing it here.
321 *
322 * @since 4.9.0
323 *
324 * @param array $input Validated input.
325 * @param string $taxonomy Taxonomy name.
326 * @return string[]|null|\WP_Error
327 */
328 protected function resolve_kb_input( array $input, $taxonomy ) {
329 if ( ! isset( $input['knowledge_bases'] ) ) {
330 return null;
331 }
332
333 if ( 'doc_category' !== $taxonomy ) {
334 return AbilityError::invalid_input(
335 'knowledge_bases',
336 __( 'Only doc categories belong to knowledge bases.', 'betterdocs' )
337 );
338 }
339
340 return $this->kb_slugs_for( (array) $input['knowledge_bases'] );
341 }
342
343 /**
344 * The glossary-only term metas, validated against the taxonomy in play.
345 *
346 * `status` is the string `'1'` / `'0'` the feature stores — that is what
347 * `Core\Glossaries::update_glossary_status()` writes and what the admin
348 * screen and the A–Z front end read — so the tools take the readable
349 * `publish` / `draft` and map it. `order` is stored as a string too, and
350 * lowest sorts first.
351 *
352 * Both are refused on the other taxonomies rather than ignored: silently
353 * dropping a field an agent sent is how an agent learns the wrong model of
354 * the tool.
355 *
356 * @since 4.9.0
357 *
358 * @param array $input Validated input.
359 * @param string $taxonomy Taxonomy name.
360 * @return array|\WP_Error Meta map, possibly empty.
361 */
362 protected function glossary_meta( array $input, $taxonomy ) {
363 $meta = [];
364
365 foreach ( [ 'status', 'order' ] as $field ) {
366 if ( ! isset( $input[ $field ] ) ) {
367 continue;
368 }
369
370 if ( 'glossaries' !== $taxonomy ) {
371 return AbilityError::invalid_input(
372 $field,
373 __( 'Only glossary terms take a status or an order.', 'betterdocs' )
374 );
375 }
376
377 $meta[ $field ] = 'status' === $field
378 ? ( 'publish' === (string) $input['status'] ? '1' : '0' )
379 : (string) (int) $input['order'];
380 }
381
382 return $meta;
383 }
384
385 /**
386 * Write the knowledge-base assignment and return the term as it now is.
387 *
388 * Replace semantics: the list given is the list the category ends up with,
389 * and `[]` clears it.
390 *
391 * @since 4.9.0
392 *
393 * @param int $term_id Doc category id.
394 * @param string[] $slugs Knowledge-base slugs, already resolved.
395 * @return array|\WP_Error The updated term item.
396 */
397 protected function assign_knowledge_bases( $term_id, array $slugs ) {
398 $updated = $this->dispatch(
399 'POST',
400 '/doc_category/' . (int) $term_id,
401 [
402 'meta' => [ self::KB_META_KEY => $slugs ]
403 ],
404 'wp/v2'
405 );
406
407 if ( is_wp_error( $updated ) ) {
408 return $this->map_term_error( $updated, 'doc_category', __( 'assign knowledge bases to a doc category', 'betterdocs' ) );
409 }
410
411 return (array) $updated;
412 }
413 }
414