PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 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 All 200 releases
betterdocs / includes / Abilities / Settings / SettingsSchema.php

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

880 lines 24.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The settings schema, resolved from the live settings tree.
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\AbilityError;
16 use WPDeveloper\BetterDocs\Abilities\ProState;
17 use WPDeveloper\BetterDocs\Core\Settings;
18
19 /**
20 * BetterDocs has no settings schema file — it has a **UI tree**.
21 *
22 * `Core\Settings::settings_args()` is 1,900 lines of tabs, sections and fields
23 * written for the admin's form builder, filtered by a dozen hooks (Pro adds
24 * whole tabs through them), and `get_default()` is a separate flat map of
25 * defaults that has drifted from the tree's own `default` keys. Neither alone
26 * says what an agent may write.
27 *
28 * This class joins them at runtime, after `init`, and emits one entry per
29 * **data-bearing** field: what type it is in JSON, what it defaults to, which
30 * values it accepts, whether it needs Pro, whether writing it has consequences.
31 * That resolved map is the contract behind all three settings tools, and it is
32 * the thing `bd-get-settings-schema` hands an agent before it writes anything.
33 *
34 * Two rules keep it honest:
35 *
36 * - **`get_default()` wins on values, the tree wins on shape.** Where a field's
37 * own `default` disagrees with `get_default()`, the flat map is what
38 * `Settings::get()` actually returns, so it is the default an agent is told.
39 * - **Nothing is emitted that cannot be written back.** Whole tabs are dropped
40 * (ADR-010), so are the field types that are buttons, uploaders and repeaters,
41 * and so is anything the UI itself disables.
42 *
43 * @since 4.9.0
44 */
45 final class SettingsSchema {
46
47 /**
48 * Tabs the MCP surface does not expose (ADR-010): licensing, the
49 * import/export and migration tooling (file uploads and destructive
50 * one-shots), and Git Sync (credentials plus a repository the agent has no
51 * business rewiring).
52 *
53 * @since 4.9.0
54 */
55 const EXCLUDED_TABS = [ 'tab-license', 'tab-import-export', 'tab-migration', 'tab-git-sync' ];
56
57 /**
58 * Keys refused before the schema is even consulted, with the reason.
59 *
60 * `enable_mcp` is the MCP master switch. It is not in the settings tree at
61 * all (it lives only in `get_default()`, deliberately off the Settings
62 * screen), so an agent writing it would otherwise be told "not a BetterDocs
63 * setting" — true, and useless. A tool must not be able to switch off the
64 * server it is talking through.
65 *
66 * @since 4.9.0
67 */
68 const NOT_WRITABLE_KEYS = [
69 'enable_mcp' => 'enable_mcp is the MCP master switch and cannot be changed from here. An administrator can toggle it under BetterDocs → MCP.'
70 ];
71
72 /**
73 * Credential-shaped setting keys the MCP surface must never read back, on
74 * top of `Settings::sensitive_api_key_fields()` — which lists only the AI
75 * provider keys. `recaptcha_secret_key` is Pro's server-side reCAPTCHA
76 * secret: data-bearing, credential-shaped, and absent from that list, so
77 * without this it was read back in clear by `bd-get-settings`.
78 * `secret_keys()` unions this with the AI list and a filter so Pro and
79 * add-ons can extend it (ADR-059).
80 *
81 * @since 4.9.0
82 */
83 const MCP_SECRET_KEYS = [
84 'recaptcha_secret_key'
85 ];
86
87 /**
88 * Field types that carry no stored value: layout, buttons, one-shot
89 * actions, uploaders, code viewers and the repeaters deferred to v2.
90 *
91 * `copy-to-clipboard` is here too, against `03-ARCHITECTURE.md`'s type
92 * table: measured on the rig, all 16 of them are shortcode strings shown for
93 * copying, every one absent from `get_default()`, and writing one would put
94 * a key in the option that nothing ever reads.
95 *
96 * @since 4.9.0
97 */
98 const SKIPPED_TYPES = [
99 'section',
100 'tab',
101 'title',
102 'button',
103 'action',
104 'html',
105 'codeviewer',
106 'cross_domain_code',
107 'wwa_instructions',
108 'ai_edit_actions',
109 'settingsuploader',
110 'importerupload',
111 'github-repo-settings',
112 'better-repeater',
113 'copy-to-clipboard'
114 ];
115
116 /**
117 * Field type → JSON type.
118 *
119 * @since 4.9.0
120 */
121 const TYPE_MAP = [
122 'toggle' => 'boolean',
123 'text' => 'string',
124 'textarea' => 'string',
125 'permalink_structure' => 'string',
126 'media' => 'string',
127 'colorpicker' => 'string',
128 'number' => 'integer',
129 'min_token_number' => 'integer',
130 'select' => 'string',
131 'radio-card' => 'string',
132 'platform_model_select' => 'string',
133 'checkbox-select' => 'array'
134 ];
135
136 /**
137 * Keys whose change makes WordPress rewrite rules stale.
138 *
139 * The first six are what `Core\Rewrite::flush_rewrite_rules()` compares
140 * (measured at `Rewrite.php` L134–144) before setting the
141 * `betterdocs_flush_rewrite_rules` transient. The last two change which
142 * taxonomies register at all, from the next request, and pretty knowledge
143 * base URLs need a flush after them.
144 *
145 * @since 4.9.0
146 */
147 const REWRITE_KEYS = [
148 'permalink_structure',
149 'docs_slug',
150 'builtin_doc_page',
151 'docs_page',
152 'tag_slug',
153 'category_slug',
154 'multiple_kb',
155 'disable_root_slug_mkb'
156 ];
157
158 /**
159 * Keys `Core\Settings::fallback_slugs()` silently restores when they are
160 * saved empty (its own `$cannot_be_empty`, which is private — kept in step
161 * with it by hand).
162 *
163 * Refusing an empty value up front is the difference between "that did not
164 * work" and a write that reports success and quietly puts the old value
165 * back.
166 *
167 * @since 4.9.0
168 */
169 const CANNOT_BE_EMPTY = [
170 'breadcrumb_doc_title',
171 'docs_slug',
172 'category_slug',
173 'tag_slug',
174 'permalink_structure',
175 'docs_page'
176 ];
177
178 /**
179 * The subset of {@see self::CANNOT_BE_EMPTY} an empty value is refused for.
180 *
181 * `docs_page` is left out on purpose: its fallback is conditional — with
182 * `builtin_doc_page` on, "no page" is the normal state, and only with the
183 * built-in page off does `fallback_slugs()` step in (by switching
184 * `builtin_doc_page` back on rather than restoring the page). The other five
185 * are plain slug strings whose empty value is always discarded.
186 *
187 * @since 4.9.0
188 */
189 const REFUSE_EMPTY = [
190 'breadcrumb_doc_title',
191 'docs_slug',
192 'category_slug',
193 'tag_slug',
194 'permalink_structure'
195 ];
196
197 /**
198 * What a secret reads back as. A constant, never
199 * `Helper::mask_api_key()`'s partial mask: that keeps the prefix and the
200 * last four characters for a human who needs to recognise their own key,
201 * and neither of those belongs in a model's context.
202 *
203 * @since 4.9.0
204 */
205 const MASK = '********';
206
207 /**
208 * Resolved schema, per request.
209 *
210 * @since 4.9.0
211 *
212 * @var array|null
213 */
214 private static $schema = null;
215
216 /**
217 * Resolved tab list, per request.
218 *
219 * @since 4.9.0
220 *
221 * @var array|null
222 */
223 private static $tabs = null;
224
225 /**
226 * The whole schema, keyed by setting name.
227 *
228 * @since 4.9.0
229 *
230 * @param bool $fresh Rebuild instead of using the memo.
231 * @return array<string, array>
232 */
233 public static function resolve( $fresh = false ) {
234 if ( $fresh || null === self::$schema ) {
235 self::build();
236 }
237
238 return self::$schema;
239 }
240
241 /**
242 * `[ { id, label, included }, … ]` for every tab in the tree.
243 *
244 * @since 4.9.0
245 *
246 * @param bool $fresh Rebuild instead of using the memo.
247 * @return array[]
248 */
249 public static function tabs( $fresh = false ) {
250 if ( $fresh || null === self::$tabs ) {
251 self::build();
252 }
253
254 return self::$tabs;
255 }
256
257 /**
258 * One entry, or null.
259 *
260 * @since 4.9.0
261 *
262 * @param string $key Setting key.
263 * @return array|null
264 */
265 public static function entry( $key ) {
266 $schema = self::resolve();
267
268 return isset( $schema[ $key ] ) ? $schema[ $key ] : null;
269 }
270
271 /**
272 * Drop the memo. For tests, and for anything that changes the tree mid-request.
273 *
274 * @since 4.9.0
275 *
276 * @return void
277 */
278 public static function reset() {
279 self::$schema = null;
280 self::$tabs = null;
281 }
282
283 /**
284 * Whether a key is one of the API keys that must never be read back.
285 *
286 * @since 4.9.0
287 *
288 * @param string $key Setting key.
289 * @return bool
290 */
291 public static function is_secret( $key ) {
292 return in_array( (string) $key, self::secret_keys(), true );
293 }
294
295 /**
296 * May this key be written with this value?
297 *
298 * Every refusal is typed and says what would work instead, because the
299 * caller is a model that will otherwise guess.
300 *
301 * @since 4.9.0
302 *
303 * @param string $key Setting key.
304 * @param mixed $value Proposed value.
305 * @return true|\WP_Error
306 */
307 public static function validate( $key, $value ) {
308 $key = (string) $key;
309
310 if ( isset( self::NOT_WRITABLE_KEYS[ $key ] ) ) {
311 return AbilityError::invalid_input( $key, self::NOT_WRITABLE_KEYS[ $key ] );
312 }
313
314 $entry = self::entry( $key );
315
316 if ( null === $entry ) {
317 return AbilityError::invalid_input(
318 $key,
319 sprintf(
320 /* translators: %s: setting key. */
321 __( '"%s" is not a BetterDocs setting; call bd-get-settings-schema for the keys this site accepts.', 'betterdocs' ),
322 $key
323 )
324 );
325 }
326
327 if ( ! $entry['writable'] ) {
328 return AbilityError::invalid_input(
329 $key,
330 sprintf(
331 /* translators: %s: setting key. */
332 __( '"%s" is listed but not writable on this site — BetterDocs disables the field, usually because the feature it belongs to is not installed.', 'betterdocs' ),
333 $key
334 )
335 );
336 }
337
338 if ( $entry['pro'] && ! betterdocs()->is_pro_active() ) {
339 return AbilityError::pro_required( ProState::get( false ), $entry['label'] );
340 }
341
342 $typed = self::check_type( $entry, $value );
343
344 if ( is_wp_error( $typed ) ) {
345 return $typed;
346 }
347
348 $coerced = self::coerce( $key, $value );
349
350 if ( in_array( $key, self::REFUSE_EMPTY, true ) && self::is_empty( $coerced ) ) {
351 return AbilityError::invalid_input(
352 $key,
353 sprintf(
354 /* translators: 1: setting key, 2: default value. */
355 __( '"%1$s" cannot be empty: BetterDocs puts the default ("%2$s") back on save, so an empty value is silently discarded.', 'betterdocs' ),
356 $key,
357 is_scalar( $entry['default'] ) ? (string) $entry['default'] : ''
358 )
359 );
360 }
361
362 if ( is_array( $entry['enum'] ) && ! empty( $entry['enum'] ) ) {
363 $allowed = array_map( 'strval', $entry['enum'] );
364 $given = 'array' === $entry['type'] ? (array) $coerced : [ $coerced ];
365
366 foreach ( $given as $one ) {
367 if ( ! in_array( (string) $one, $allowed, true ) ) {
368 return AbilityError::invalid_input(
369 $key,
370 sprintf(
371 /* translators: 1: value, 2: setting key. */
372 __( '"%1$s" is not an allowed value for "%2$s".', 'betterdocs' ),
373 is_scalar( $one ) ? (string) $one : gettype( $one ),
374 $key
375 ),
376 $entry['enum']
377 );
378 }
379 }
380 }
381
382 if ( 'integer' === $entry['type'] ) {
383 if ( null !== $entry['min'] && $coerced < $entry['min'] ) {
384 return AbilityError::invalid_input(
385 $key,
386 sprintf(
387 /* translators: 1: setting key, 2: minimum. */
388 __( '"%1$s" must be at least %2$s.', 'betterdocs' ),
389 $key,
390 $entry['min']
391 )
392 );
393 }
394
395 if ( null !== $entry['max'] && $coerced > $entry['max'] ) {
396 return AbilityError::invalid_input(
397 $key,
398 sprintf(
399 /* translators: 1: setting key, 2: maximum. */
400 __( '"%1$s" must be at most %2$s.', 'betterdocs' ),
401 $key,
402 $entry['max']
403 )
404 );
405 }
406 }
407
408 return true;
409 }
410
411 /**
412 * The value as it should be stored.
413 *
414 * Booleans become real booleans: `Core\Settings::save_settings()` normalises
415 * `'1'`/`'on'`/`'true'` to `true` anyway, and `get_all()` casts back to
416 * whatever type the default has, so this is the one representation that
417 * survives both directions unchanged.
418 *
419 * @since 4.9.0
420 *
421 * @param string $key Setting key.
422 * @param mixed $value Proposed value.
423 * @return mixed
424 */
425 public static function coerce( $key, $value ) {
426 $entry = self::entry( $key );
427 $type = null === $entry ? 'string' : $entry['type'];
428
429 switch ( $type ) {
430 case 'boolean':
431 return self::truthy( $value );
432
433 case 'integer':
434 return (int) $value;
435
436 case 'array':
437 $out = [];
438
439 foreach ( (array) $value as $item ) {
440 if ( is_scalar( $item ) ) {
441 $out[] = (string) $item;
442 }
443 }
444
445 return array_values( array_unique( $out ) );
446
447 default:
448 return is_scalar( $value ) ? (string) $value : '';
449 }
450 }
451
452 /**
453 * The stored value as an agent should see it.
454 *
455 * @since 4.9.0
456 *
457 * @param string $key Setting key.
458 * @param mixed $stored Value from `Settings::get_all()`.
459 * @return mixed
460 */
461 public static function to_public( $key, $stored ) {
462 if ( self::is_secret( $key ) ) {
463 return ( null === $stored || '' === $stored ) ? '' : self::MASK;
464 }
465
466 $entry = self::entry( $key );
467 $type = null === $entry ? 'string' : $entry['type'];
468
469 switch ( $type ) {
470 case 'boolean':
471 return self::truthy( $stored );
472
473 case 'integer':
474 return (int) $stored;
475
476 case 'array':
477 $out = [];
478
479 foreach ( (array) $stored as $item ) {
480 if ( is_scalar( $item ) ) {
481 $out[] = (string) $item;
482 }
483 }
484
485 return array_values( $out );
486
487 default:
488 return is_scalar( $stored ) ? (string) $stored : '';
489 }
490 }
491
492 /**
493 * WordPress' own idea of truth, plus the two spellings BetterDocs stores.
494 *
495 * @since 4.9.0
496 *
497 * @param mixed $value Any value.
498 * @return bool
499 */
500 public static function truthy( $value ) {
501 if ( is_bool( $value ) ) {
502 return $value;
503 }
504
505 if ( is_string( $value ) ) {
506 return ! in_array( strtolower( trim( $value ) ), [ '', '0', 'off', 'false', 'no' ], true );
507 }
508
509 return (bool) $value;
510 }
511
512 /**
513 * Build the schema and the tab list from the live tree.
514 *
515 * @since 4.9.0
516 *
517 * @return void
518 */
519 private static function build() {
520 $settings = betterdocs()->settings;
521 $args = $settings->settings_args();
522 $defaults = array_merge( (array) $settings->get_default(), (array) $settings->get_pro_defaults() );
523 $tabs = isset( $args['tabs'] ) && is_array( $args['tabs'] ) ? $args['tabs'] : [];
524
525 self::$schema = [];
526 self::$tabs = [];
527
528 foreach ( $tabs as $tab_key => $tab ) {
529 $id = isset( $tab['id'] ) ? (string) $tab['id'] : (string) $tab_key;
530 $included = ! in_array( $id, self::EXCLUDED_TABS, true );
531
532 self::$tabs[] = [
533 'id' => $id,
534 'label' => isset( $tab['label'] ) ? (string) $tab['label'] : $id,
535 'included' => $included
536 ];
537
538 if ( ! $included || empty( $tab['fields'] ) ) {
539 continue;
540 }
541
542 self::walk( (array) $tab['fields'], $id, '', $defaults );
543 }
544 }
545
546 /**
547 * Recurse the tree, emitting one entry per data-bearing field.
548 *
549 * Sections and tabs nest arbitrarily deep — the Layout tab is tabs inside
550 * sections inside tabs inside a section — and each level may hold fields of
551 * its own, so the walk descends first and decides afterwards.
552 *
553 * @since 4.9.0
554 *
555 * @param array $fields Field list.
556 * @param string $tab Tab id.
557 * @param string $section Nearest section label.
558 * @param array $defaults Flat default map.
559 * @return void
560 */
561 private static function walk( array $fields, $tab, $section, array $defaults ) {
562 foreach ( $fields as $field ) {
563 if ( ! is_array( $field ) ) {
564 continue;
565 }
566
567 $type = isset( $field['type'] ) ? (string) $field['type'] : '';
568
569 if ( ! empty( $field['fields'] ) && is_array( $field['fields'] ) ) {
570 $nested = ( 'section' === $type && isset( $field['label'] ) ) ? (string) $field['label'] : $section;
571
572 self::walk( $field['fields'], $tab, $nested, $defaults );
573 }
574
575 if ( ! isset( self::TYPE_MAP[ $type ] ) || in_array( $type, self::SKIPPED_TYPES, true ) ) {
576 continue;
577 }
578
579 $key = isset( $field['name'] ) ? (string) $field['name'] : '';
580
581 if ( '' === $key || isset( self::NOT_WRITABLE_KEYS[ $key ] ) ) {
582 continue;
583 }
584
585 self::$schema[ $key ] = self::entry_for( $key, $type, $field, $tab, $section, $defaults );
586 }
587 }
588
589 /**
590 * One schema entry.
591 *
592 * @since 4.9.0
593 *
594 * @param string $key Setting key.
595 * @param string $type Field type from the tree.
596 * @param array $field The field.
597 * @param string $tab Tab id.
598 * @param string $section Section label.
599 * @param array $defaults Flat default map.
600 * @return array
601 */
602 private static function entry_for( $key, $type, array $field, $tab, $section, array $defaults ) {
603 $json_type = self::TYPE_MAP[ $type ];
604 $secret = self::is_secret( $key );
605
606 // `get_default()` is authoritative: it is what `Settings::get()` returns
607 // when nothing is stored, whatever the tree's own `default` says.
608 $default = array_key_exists( $key, $defaults )
609 ? $defaults[ $key ]
610 : ( array_key_exists( 'default', $field ) ? $field['default'] : null );
611
612 $entry = [
613 'key' => $key,
614 'type' => $json_type,
615 'default' => self::cast( $json_type, $default ),
616 'label' => isset( $field['label'] ) ? (string) $field['label'] : $key,
617 'help' => self::help_of( $field ),
618 'tab' => $tab,
619 'section' => (string) $section,
620 'pro' => ! empty( $field['is_pro'] ),
621 'enum' => self::enum_of( $field ),
622 'min' => isset( $field['min'] ) ? (int) $field['min'] : null,
623 'max' => isset( $field['max'] ) ? (int) $field['max'] : null,
624 'writable' => empty( $field['disabled'] ),
625 'readable' => ! $secret,
626 'rewrite_consequence' => in_array( $key, self::REWRITE_KEYS, true ),
627 'notes' => []
628 ];
629
630 $entry['notes'] = self::notes_for( $entry, $secret );
631
632 return $entry;
633 }
634
635 /**
636 * The per-entry notes: the things that are true about writing this key and
637 * are not visible from its type.
638 *
639 * @since 4.9.0
640 *
641 * @param array $entry The entry so far.
642 * @param bool $secret Whether the key holds a secret.
643 * @return string[]
644 */
645 private static function notes_for( array $entry, $secret ) {
646 $notes = [];
647
648 if ( $entry['rewrite_consequence'] ) {
649 $notes[] = __( 'Changing this makes the permalink rules stale; BetterDocs flushes them on the next request, so pretty URLs may take one more request to settle.', 'betterdocs' );
650 }
651
652 if ( 'multiple_kb' === $entry['key'] ) {
653 $notes[] = __( 'Takes effect from the next request: the knowledge_base taxonomy registers on the next load.', 'betterdocs' );
654 }
655
656 if ( in_array( $entry['key'], self::REFUSE_EMPTY, true ) ) {
657 $notes[] = __( 'Cannot be empty: BetterDocs restores the default when an empty value is saved, so this tool refuses one instead.', 'betterdocs' );
658 } elseif ( in_array( $entry['key'], self::CANNOT_BE_EMPTY, true ) ) {
659 $notes[] = __( 'With builtin_doc_page off, saving this empty switches builtin_doc_page back on rather than leaving the site without a docs page.', 'betterdocs' );
660 }
661
662 if ( $secret ) {
663 $notes[] = __( 'Write-only: this value is never read back, only replaced.', 'betterdocs' );
664 }
665
666 if ( ! $entry['writable'] ) {
667 $notes[] = __( 'Disabled on this site, so it cannot be written.', 'betterdocs' );
668 }
669
670 if ( $entry['pro'] ) {
671 $notes[] = __( 'Needs BetterDocs Pro.', 'betterdocs' );
672 }
673
674 return $notes;
675 }
676
677 /**
678 * The field's help text. The tree uses `label_subtitle` throughout — `help`
679 * is in the field vocabulary but unused on this checkout — so both are read.
680 *
681 * @since 4.9.0
682 *
683 * @param array $field The field.
684 * @return string
685 */
686 private static function help_of( array $field ) {
687 foreach ( [ 'help', 'label_subtitle' ] as $candidate ) {
688 if ( ! empty( $field[ $candidate ] ) && is_string( $field[ $candidate ] ) ) {
689 return $field[ $candidate ];
690 }
691 }
692
693 return '';
694 }
695
696 /**
697 * The allowed values behind a field's `options`, or null.
698 *
699 * `Settings::normalize_options()` turns `value => label` into
700 * `value => [ 'value' => …, 'label' => … ]`, but the tree also carries raw
701 * `value => label` maps and plain lists, so all three shapes are read.
702 *
703 * @since 4.9.0
704 *
705 * @param array $field The field.
706 * @return array|null
707 */
708 private static function enum_of( array $field ) {
709 if ( empty( $field['options'] ) || ! is_array( $field['options'] ) ) {
710 return null;
711 }
712
713 $options = $field['options'];
714 $is_list = array_keys( $options ) === range( 0, count( $options ) - 1 );
715 $values = [];
716
717 foreach ( $options as $option_key => $option ) {
718 if ( is_array( $option ) ) {
719 if ( array_key_exists( 'value', $option ) && is_scalar( $option['value'] ) ) {
720 $values[] = $option['value'];
721 } elseif ( ! $is_list ) {
722 $values[] = $option_key;
723 }
724
725 continue;
726 }
727
728 // A list of scalars is the values themselves; a map is keyed by them.
729 $values[] = $is_list ? $option : $option_key;
730 }
731
732 $values = array_values( array_filter( $values, 'is_scalar' ) );
733
734 return empty( $values ) ? null : $values;
735 }
736
737 /**
738 * Cast a default to the JSON type the entry advertises.
739 *
740 * @since 4.9.0
741 *
742 * @param string $type JSON type.
743 * @param mixed $value Default value.
744 * @return mixed
745 */
746 private static function cast( $type, $value ) {
747 switch ( $type ) {
748 case 'boolean':
749 return self::truthy( $value );
750
751 case 'integer':
752 return (int) $value;
753
754 case 'array':
755 $out = [];
756
757 foreach ( (array) $value as $item ) {
758 if ( is_scalar( $item ) ) {
759 $out[] = (string) $item;
760 }
761 }
762
763 return array_values( $out );
764
765 default:
766 return is_scalar( $value ) ? (string) $value : '';
767 }
768 }
769
770 /**
771 * Type check with the coercions this plugin's own storage performs.
772 *
773 * @since 4.9.0
774 *
775 * @param array $entry The schema entry.
776 * @param mixed $value Proposed value.
777 * @return true|\WP_Error
778 */
779 private static function check_type( array $entry, $value ) {
780 switch ( $entry['type'] ) {
781 case 'boolean':
782 $accepted = is_bool( $value )
783 || ( is_int( $value ) && in_array( $value, [ 0, 1 ], true ) )
784 || ( is_string( $value ) && in_array( strtolower( trim( $value ) ), [ '1', '0', '', 'on', 'off', 'true', 'false', 'yes', 'no' ], true ) );
785
786 return $accepted ? true : self::type_error( $entry, __( 'true or false', 'betterdocs' ), $value );
787
788 case 'integer':
789 $accepted = is_int( $value ) || ( is_string( $value ) && '' !== $value && (string) (int) $value === trim( $value ) );
790
791 return $accepted ? true : self::type_error( $entry, __( 'a whole number', 'betterdocs' ), $value );
792
793 case 'array':
794 if ( ! is_array( $value ) ) {
795 return self::type_error( $entry, __( 'an array of strings', 'betterdocs' ), $value );
796 }
797
798 foreach ( $value as $item ) {
799 if ( ! is_scalar( $item ) ) {
800 return self::type_error( $entry, __( 'an array of strings', 'betterdocs' ), $value );
801 }
802 }
803
804 return true;
805
806 default:
807 return is_scalar( $value ) ? true : self::type_error( $entry, __( 'a string', 'betterdocs' ), $value );
808 }
809 }
810
811 /**
812 * The typed refusal for a wrong type.
813 *
814 * @since 4.9.0
815 *
816 * @param array $entry The schema entry.
817 * @param string $wanted What the field takes, as a phrase.
818 * @param mixed $value What arrived.
819 * @return \WP_Error
820 */
821 private static function type_error( array $entry, $wanted, $value ) {
822 return AbilityError::invalid_input(
823 $entry['key'],
824 sprintf(
825 /* translators: 1: setting key, 2: what the field accepts, 3: what was sent. */
826 __( '"%1$s" takes %2$s; got %3$s.', 'betterdocs' ),
827 $entry['key'],
828 $wanted,
829 is_scalar( $value ) ? '"' . (string) $value . '"' : gettype( $value )
830 ),
831 is_array( $entry['enum'] ) ? $entry['enum'] : null
832 );
833 }
834
835 /**
836 * Whether a coerced value counts as empty for the `$cannot_be_empty` rule.
837 *
838 * @since 4.9.0
839 *
840 * @param mixed $value Coerced value.
841 * @return bool
842 */
843 private static function is_empty( $value ) {
844 if ( is_array( $value ) ) {
845 return empty( $value );
846 }
847
848 return '' === (string) $value || '0' === (string) $value;
849 }
850
851 /**
852 * The site's secret setting keys.
853 *
854 * @since 4.9.0
855 *
856 * @return string[]
857 */
858 private static function secret_keys() {
859 $ai_keys = class_exists( Settings::class ) && method_exists( Settings::class, 'sensitive_api_key_fields' )
860 ? (array) Settings::sensitive_api_key_fields()
861 : [];
862
863 $keys = array_merge( $ai_keys, self::MCP_SECRET_KEYS );
864
865 /**
866 * Filters the setting keys the MCP surface treats as secret: masked on
867 * read, reported in `masked[]`, and marked write-only in the schema. Pro
868 * and add-ons extend it so credential-shaped keys their own settings add
869 * are never read back through `bd-get-settings`.
870 *
871 * @since 4.9.0
872 *
873 * @param string[] $keys Setting keys treated as secret.
874 */
875 $keys = (array) apply_filters( 'betterdocs_mcp_secret_setting_keys', $keys );
876
877 return array_values( array_unique( array_map( 'strval', $keys ) ) );
878 }
879 }
880