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 / Traits / ResolvesTerms.php

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

329 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Turning term references an agent wrote into term ids.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities\Traits;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 use WPDeveloper\BetterDocs\Abilities\AbilityError;
16 use WPDeveloper\BetterDocs\Abilities\ProState;
17
18 /**
19 * An agent knows terms by the names a human would use, not by id.
20 *
21 * Every content tool therefore accepts `["Getting started", 12, "how-to"]` and
22 * this trait turns that into `[3, 12, 7]`. Two rules make it safe:
23 *
24 * 1. **Only a write creates.** A filter (`bd-list-docs {tag: "typo"}`) that
25 * created the term it was looking for would answer "no results" *and* litter
26 * the taxonomy, so `$create` is false on every read path and the refusal
27 * says so.
28 * 2. **Creating goes through the REST route, never `wp_insert_term()`.**
29 * `wp_insert_term()` performs no capability check at all — measured on
30 * WordPress 7.1 — so an author holding only `edit_docs` could mint doc
31 * categories through a doc tool. `POST wp/v2/doc_category` runs
32 * `edit_doc_terms` first and gives a mappable error when it refuses.
33 *
34 * Knowledge bases are never created here: that is the Pro tool's job, and on a
35 * site where the taxonomy is not even registered the honest answer is *why*
36 * rather than "not found".
37 *
38 * @since 4.9.0
39 */
40 trait ResolvesTerms {
41
42 /**
43 * Taxonomies a content tool may create a term in.
44 *
45 * @since 4.9.0
46 *
47 * @var string[]
48 */
49 protected static $creatable_taxonomies = [ 'doc_category', 'doc_tag', 'glossaries' ];
50
51 /**
52 * Resolve a list of term references to term ids.
53 *
54 * A reference is an int id (must exist), or a string matched against the
55 * slug first and then the name.
56 *
57 * @since 4.9.0
58 *
59 * @param array $refs Term references.
60 * @param string $taxonomy Taxonomy name.
61 * @param bool $create Whether an unmatched name may be created.
62 * @return int[]|\WP_Error Term ids, or the first refusal.
63 */
64 protected function resolve_terms( array $refs, $taxonomy, $create ) {
65 $taxonomy = (string) $taxonomy;
66
67 $available = $this->taxonomy_available( $taxonomy );
68
69 if ( is_wp_error( $available ) ) {
70 return $available;
71 }
72
73 $ids = [];
74
75 foreach ( $refs as $ref ) {
76 $id = $this->resolve_term( $ref, $taxonomy, $create );
77
78 if ( is_wp_error( $id ) ) {
79 return $id;
80 }
81
82 $ids[] = $id;
83 }
84
85 return array_values( array_unique( $ids ) );
86 }
87
88 /**
89 * Resolve one reference.
90 *
91 * @since 4.9.0
92 *
93 * @param mixed $ref Term id, slug or name.
94 * @param string $taxonomy Taxonomy name.
95 * @param bool $create Whether an unmatched name may be created.
96 * @return int|\WP_Error
97 */
98 protected function resolve_term( $ref, $taxonomy, $create ) {
99 if ( is_int( $ref ) || ( is_string( $ref ) && ctype_digit( $ref ) ) ) {
100 $term = get_term( (int) $ref, $taxonomy );
101
102 if ( ! $term || is_wp_error( $term ) ) {
103 return AbilityError::not_found( $this->term_object_name( $taxonomy ), (int) $ref );
104 }
105
106 return (int) $term->term_id;
107 }
108
109 if ( ! is_string( $ref ) || '' === trim( $ref ) ) {
110 return AbilityError::invalid_input(
111 $taxonomy,
112 __( 'A term reference must be a term id, a slug or a name.', 'betterdocs' )
113 );
114 }
115
116 $ref = trim( $ref );
117
118 foreach ( [ 'slug', 'name' ] as $field ) {
119 $term = get_term_by( $field, 'slug' === $field ? sanitize_title( $ref ) : $ref, $taxonomy );
120
121 if ( $term && ! is_wp_error( $term ) ) {
122 return (int) $term->term_id;
123 }
124 }
125
126 if ( ! $create ) {
127 return AbilityError::not_found( $this->term_object_name( $taxonomy ), $ref );
128 }
129
130 if ( ! in_array( $taxonomy, self::$creatable_taxonomies, true ) ) {
131 return AbilityError::not_found( $this->term_object_name( $taxonomy ), $ref );
132 }
133
134 return $this->create_term( $ref, $taxonomy );
135 }
136
137 /**
138 * Create a term through the REST route, so the capability check runs.
139 *
140 * @since 4.9.0
141 *
142 * @param string $name Term name.
143 * @param string $taxonomy Taxonomy name.
144 * @return int|\WP_Error
145 */
146 protected function create_term( $name, $taxonomy ) {
147 $created = $this->dispatch( 'POST', '/' . $taxonomy, [ 'name' => $name ], 'wp/v2' );
148
149 if ( is_wp_error( $created ) ) {
150 $data = (array) $created->get_error_data();
151
152 // A race, or a name that differs only by case: WordPress hands back
153 // the existing id, which is exactly what find-or-create wanted.
154 if ( 'term_exists' === $created->get_error_code() && ! empty( $data['term_id'] ) ) {
155 return (int) $data['term_id'];
156 }
157
158 if ( 'rest_cannot_create' === $created->get_error_code() ) {
159 $taxonomy_object = get_taxonomy( $taxonomy );
160 $capability = $taxonomy_object && isset( $taxonomy_object->cap->edit_terms )
161 ? (string) $taxonomy_object->cap->edit_terms
162 : 'edit_doc_terms';
163
164 return AbilityError::capability_missing(
165 $capability,
166 sprintf(
167 /* translators: 1: term name, 2: taxonomy name. */
168 __( 'create the %1$s "%2$s"', 'betterdocs' ),
169 $this->term_object_name( $taxonomy ),
170 $name
171 )
172 );
173 }
174
175 return AbilityError::upstream( $created->get_error_message(), [ 'taxonomy' => $taxonomy ] );
176 }
177
178 return isset( $created['id'] ) ? (int) $created['id'] : 0;
179 }
180
181 /**
182 * Whether the taxonomy can be used at all, and why not when it cannot.
183 *
184 * Only `knowledge_base` can legitimately be absent: BetterDocs Pro registers
185 * it, and only while Multiple Knowledge Base is on. An agent that asked for
186 * a knowledge base needs to know which of those two it is, because one is
187 * fixable with a tool call and the other is not.
188 *
189 * @since 4.9.0
190 *
191 * @param string $taxonomy Taxonomy name.
192 * @return true|\WP_Error
193 */
194 protected function taxonomy_available( $taxonomy ) {
195 if ( taxonomy_exists( $taxonomy ) ) {
196 return true;
197 }
198
199 // Glossaries are a **Free** feature behind a Free setting: with
200 // `enable_glossaries` off the taxonomy is not registered at all, so the
201 // honest answer names the setting and the write that turns it on. The
202 // state is deliberately not a `pro_` one, so `requires_pro` stays false
203 // (ADR-061).
204 if ( 'glossaries' === $taxonomy ) {
205 return AbilityError::setting_disabled(
206 self::GLOSSARY_SETTING,
207 [ self::GLOSSARY_SETTING => true ],
208 __( 'Glossaries', 'betterdocs' ),
209 'setting_off'
210 );
211 }
212
213 if ( 'knowledge_base' !== $taxonomy ) {
214 return AbilityError::upstream(
215 sprintf(
216 /* translators: %s: taxonomy name. */
217 __( 'The "%s" taxonomy is not registered on this site.', 'betterdocs' ),
218 $taxonomy
219 ),
220 [ 'taxonomy' => $taxonomy ]
221 );
222 }
223
224 $state = ProState::get( true );
225
226 if ( 'pro_active_setting_off' === $state['state'] ) {
227 return AbilityError::setting_disabled(
228 'multiple_kb',
229 [ 'multiple_kb' => true ],
230 __( 'Knowledge bases', 'betterdocs' )
231 );
232 }
233
234 if ( ProState::is_blocking( $state ) ) {
235 return AbilityError::pro_required( $state, __( 'Knowledge bases', 'betterdocs' ) );
236 }
237
238 return AbilityError::upstream(
239 __( 'The knowledge_base taxonomy is not registered, although BetterDocs Pro reports itself able to register it.', 'betterdocs' ),
240 [
241 'taxonomy' => 'knowledge_base',
242 'state' => $state['state']
243 ]
244 );
245 }
246
247 /**
248 * `{id, name, slug}` for a term id, or null when it has gone.
249 *
250 * @since 4.9.0
251 *
252 * @param int $id Term id.
253 * @param string $taxonomy Optional taxonomy to scope the lookup.
254 * @return array|null
255 */
256 protected function term_summary( $id, $taxonomy = '' ) {
257 $term = '' !== $taxonomy ? get_term( (int) $id, $taxonomy ) : get_term( (int) $id );
258
259 if ( ! $term || is_wp_error( $term ) ) {
260 return null;
261 }
262
263 return [
264 'id' => (int) $term->term_id,
265 'name' => (string) $term->name,
266 'slug' => (string) $term->slug
267 ];
268 }
269
270 /**
271 * `{id, name, slug}` for a list of ids, dropping any that have gone.
272 *
273 * @since 4.9.0
274 *
275 * @param array $ids Term ids.
276 * @param string $taxonomy Taxonomy to scope the lookup.
277 * @return array[]
278 */
279 protected function term_summaries( array $ids, $taxonomy = '' ) {
280 $out = [];
281
282 foreach ( $ids as $id ) {
283 $summary = $this->term_summary( $id, $taxonomy );
284
285 if ( null !== $summary ) {
286 $out[] = $summary;
287 }
288 }
289
290 return $out;
291 }
292
293 /**
294 * What to call a term of this taxonomy in an error message.
295 *
296 * @since 4.9.0
297 *
298 * @param string $taxonomy Taxonomy name.
299 * @return string
300 */
301 protected function term_object_name( $taxonomy ) {
302 $names = [
303 'doc_category' => __( 'doc category', 'betterdocs' ),
304 'doc_tag' => __( 'doc tag', 'betterdocs' ),
305 'knowledge_base' => __( 'knowledge base', 'betterdocs' ),
306 'glossaries' => __( 'glossary term', 'betterdocs' ),
307 'betterdocs_faq_category' => __( 'FAQ group', 'betterdocs' )
308 ];
309
310 return isset( $names[ $taxonomy ] ) ? $names[ $taxonomy ] : __( 'term', 'betterdocs' );
311 }
312
313 /**
314 * JSON Schema for a list of term references.
315 *
316 * @since 4.9.0
317 *
318 * @param string $description Field description.
319 * @return array
320 */
321 protected static function term_ref_schema( $description ) {
322 return [
323 'type' => 'array',
324 'description' => $description,
325 'items' => [ 'type' => [ 'integer', 'string' ] ]
326 ];
327 }
328 }
329