| 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 |
|