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 / Settings / UpdateSettings.php

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

278 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Write settings ability.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities\Settings;
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
18 /**
19 * Change BetterDocs settings — all of them at once, or none of them.
20 *
21 * **Every key is validated before anything is written.** One bad value refuses
22 * the whole call and lists every problem it found, so a batch of twelve
23 * settings never lands half-applied and an agent gets all its mistakes in one
24 * answer rather than one per round trip.
25 *
26 * The write goes to `Core\Settings::save_settings()` directly rather than
27 * through `POST betterdocs/v1/settings`. That route is the same method behind a
28 * REST controller Pro decorates with a reCAPTCHA `rest_pre_dispatch` check —
29 * a human-verification gate an in-process agent call cannot satisfy and should
30 * not have to.
31 *
32 * "Nothing changed" is success: sending a setting the value it already has is
33 * a no-op, not a failure, and the answer says which keys were already right.
34 *
35 * @since 4.9.0
36 */
37 class UpdateSettings extends AbilityBase {
38
39 /**
40 * @since 4.9.0
41 */
42 public function __construct() {
43 $this->id = 'betterdocs/update-settings';
44 $this->label = __( 'Update settings', 'betterdocs' );
45 $this->description = __( 'Change BetterDocs settings. Call bd-get-settings-schema first: each key has a type and, often, a closed set of values. Every key is checked before anything is written, so a call either applies in full or changes nothing. enable_mcp cannot be changed here. Some keys make permalinks stale or only take effect from the next request — the answer says which.', 'betterdocs' );
46 $this->capability = 'edit_docs_settings';
47 }
48
49 /**
50 * @since 4.9.0
51 *
52 * @return array
53 */
54 public function get_annotations() {
55 return [
56 // Destructive because it overwrites configuration that is not
57 // versioned anywhere: there is no undo for a settings write.
58 'readonly' => false,
59 'destructive' => true,
60 'idempotent' => true,
61 'priority' => 2.5,
62 'openWorldHint' => false
63 ];
64 }
65
66 /**
67 * @since 4.9.0
68 *
69 * @return array
70 */
71 public function get_input_schema() {
72 return [
73 'type' => 'object',
74 'additionalProperties' => false,
75 'required' => [ 'settings' ],
76 'properties' => [
77 'settings' => [
78 'type' => 'object',
79 'description' => __( 'The settings to write, as {key: value}. Types follow bd-get-settings-schema: toggles take true/false, numbers take whole numbers, selects take one of their allowed values, checkbox-selects take an array of them.', 'betterdocs' )
80 ]
81 ],
82 'default' => []
83 ];
84 }
85
86 /**
87 * @since 4.9.0
88 *
89 * @return array
90 */
91 public function get_output_schema() {
92 return [
93 'type' => 'object',
94 'properties' => [
95 'updated' => [ 'type' => 'object' ],
96 'unchanged' => [
97 'type' => 'array',
98 'items' => [ 'type' => 'string' ]
99 ],
100 'rewrite_consequence' => [ 'type' => 'boolean' ],
101 'notes' => [
102 'type' => 'array',
103 'items' => [ 'type' => 'string' ]
104 ]
105 ]
106 ];
107 }
108
109 /**
110 * @since 4.9.0
111 *
112 * @param array $input Validated input.
113 * @return array|\WP_Error
114 */
115 public function execute( $input ) {
116 $settings = isset( $input['settings'] ) ? (array) $input['settings'] : [];
117
118 if ( empty( $settings ) ) {
119 return AbilityError::invalid_input(
120 'settings',
121 __( 'Send at least one setting to change, as {key: value}.', 'betterdocs' )
122 );
123 }
124
125 $before = (array) betterdocs()->settings->get_all( false );
126 $coerced = [];
127 $errors = [];
128
129 // Validate everything first. A partial write is worse than a refusal:
130 // the caller cannot tell which half landed.
131 foreach ( $settings as $key => $value ) {
132 $key = (string) $key;
133 $valid = SettingsSchema::validate( $key, $value );
134
135 if ( is_wp_error( $valid ) ) {
136 $errors[] = [
137 'key' => $key,
138 'error' => $valid->get_error_code(),
139 'message' => $valid->get_error_message(),
140 'wp_error' => $valid
141 ];
142
143 continue;
144 }
145
146 $coerced[ $key ] = SettingsSchema::coerce( $key, $value );
147 }
148
149 if ( ! empty( $errors ) ) {
150 return $this->refuse( $errors );
151 }
152
153 $saved = betterdocs()->settings->save_settings( $coerced );
154
155 if ( is_wp_error( $saved ) ) {
156 // `unauthorized_action` is the one refusal that is about the caller
157 // rather than the values.
158 if ( 'unauthorized_action' === $saved->get_error_code() ) {
159 return AbilityError::capability_missing( 'edit_docs_settings', __( 'change BetterDocs settings', 'betterdocs' ) );
160 }
161
162 return AbilityError::upstream( $saved->get_error_message(), [ 'code' => (string) $saved->get_error_code() ] );
163 }
164
165 // `save_settings()` answers false when the stored array did not change.
166 // That is not a failure — the settings are what the caller asked for.
167 $after = (array) betterdocs()->settings->get_all( false );
168 $updated = [];
169 $unchanged = [];
170
171 foreach ( array_keys( $coerced ) as $key ) {
172 $new = array_key_exists( $key, $after ) ? $after[ $key ] : null;
173 $old = array_key_exists( $key, $before ) ? $before[ $key ] : null;
174
175 $updated[ $key ] = SettingsSchema::to_public( $key, $new );
176
177 if ( $new === $old ) {
178 $unchanged[] = $key;
179 }
180 }
181
182 return array_merge(
183 [
184 'updated' => $updated,
185 'unchanged' => $unchanged
186 ],
187 $this->consequences( array_keys( $coerced ) )
188 );
189 }
190
191 /**
192 * The typed refusal for a call that validated badly.
193 *
194 * The **first** key's own error is returned unchanged — code, message and
195 * every typed field — so a `pro_required` stays `pro_required` with
196 * `fixable_by_agent: false`, and only then is the full list attached under
197 * `errors` so an agent can fix everything in one go instead of one refusal
198 * per round trip.
199 *
200 * @since 4.9.0
201 *
202 * @param array[] $errors One entry per bad key.
203 * @return \WP_Error
204 */
205 protected function refuse( array $errors ) {
206 $first = $errors[0];
207
208 /** @var \WP_Error $error */
209 $error = $first['wp_error'];
210 $data = (array) $error->get_error_data();
211
212 if ( ! isset( $data['field'] ) ) {
213 $data['field'] = $first['key'];
214 }
215
216 $data['errors'] = array_map(
217 static function ( array $entry ) {
218 return [
219 'key' => $entry['key'],
220 'error' => $entry['error'],
221 'message' => $entry['message']
222 ];
223 },
224 $errors
225 );
226
227 return new \WP_Error( $error->get_error_code(), $error->get_error_message(), $data );
228 }
229
230 /**
231 * What the caller should know about the keys that were written.
232 *
233 * @since 4.9.0
234 *
235 * @param string[] $keys Written keys.
236 * @return array
237 */
238 protected function consequences( array $keys ) {
239 $notes = [];
240 $rewrite = false;
241
242 foreach ( $keys as $key ) {
243 $entry = SettingsSchema::entry( $key );
244
245 if ( null === $entry ) {
246 continue;
247 }
248
249 if ( $entry['rewrite_consequence'] ) {
250 $rewrite = true;
251 }
252 }
253
254 if ( $rewrite ) {
255 $notes[] = __( 'Permalinks will be flushed on the next request; pretty URLs may take one more request to settle.', 'betterdocs' );
256 }
257
258 if ( in_array( 'multiple_kb', $keys, true ) ) {
259 $notes[] = __( 'Takes effect from the next request: the knowledge_base taxonomy registers on the next load.', 'betterdocs' );
260 }
261
262 foreach ( $keys as $key ) {
263 if ( SettingsSchema::is_secret( $key ) ) {
264 $notes[] = sprintf(
265 /* translators: %s: setting key. */
266 __( '"%s" was written but can never be read back; bd-get-settings returns it masked.', 'betterdocs' ),
267 $key
268 );
269 }
270 }
271
272 return [
273 'rewrite_consequence' => $rewrite,
274 'notes' => $notes
275 ];
276 }
277 }
278