registry = $registry; } /** * Persist a submitted settings payload. * * The form rule: a feature absent from the payload is untouched, and a field absent from a * feature that is present resets to its default — that is how an unticked checkbox arrives. * * @param array $posted Payload keyed by feature id. * @return void */ public function save_values( array $posted ) { // Two things post under PluginSettings::KEY: the Plugin settings panel and the "Hide unused // features" switch. A key present in the payload is written. An absent key is left alone, // except that the panel form (recognised by its `_present` marker) saves each key it shows // as off when absent, since that is how an unticked checkbox arrives. So the switch cannot // touch the panel's boxes, and the panel cannot reset the switch. if ( isset( $posted[ PluginSettings::KEY ] ) && is_array( $posted[ PluginSettings::KEY ] ) ) { $submitted = $posted[ PluginSettings::KEY ]; $panel_keys = isset( $submitted['_present'] ) ? PluginSettings::panel_keys() : array(); $changes = array(); foreach ( PluginSettings::schema() as $key => $spec ) { if ( array_key_exists( $key, $submitted ) ) { $changes[ $key ] = $this->sanitize_field( $submitted[ $key ], $spec ); } elseif ( in_array( $key, $panel_keys, true ) ) { $changes[ $key ] = $this->sanitize_field( null, $spec ); } } PluginSettings::set( $changes ); } foreach ( $this->registry->all() as $id => $feature ) { // Each panel saves only itself. A feature absent from the payload is untouched — // otherwise saving one panel would silently switch every other feature off. if ( ! isset( $posted[ $id ] ) || ! is_array( $posted[ $id ] ) ) { continue; } $submitted = $posted[ $id ]; $existing = Settings::get( $id ); $values = array( 'enabled' => ! empty( $submitted['enabled'] ) ); foreach ( $feature->settings_schema() as $key => $spec ) { $ui = isset( $spec['ui'] ) ? $spec['ui'] : ''; // Machine-managed data is never round-tripped through the form. if ( 'status' === $ui ) { if ( array_key_exists( $key, $existing ) ) { $values[ $key ] = $existing[ $key ]; } continue; } $values[ $key ] = $this->sanitize_field( isset( $submitted[ $key ] ) ? $submitted[ $key ] : null, $spec ); } $this->store( $feature, $values ); } } /** * Switch one feature on or off, keeping everything else it has stored. * * @param Feature $feature Feature. * @param bool $on Whether it should be on. * @return void */ public function set_enabled( Feature $feature, $on ) { $values = Settings::get( $feature->id() ); $values['enabled'] = (bool) $on; $this->store( $feature, $values ); } /** * Change some of a feature's settings, keeping the rest. * * Unlike save_values(), a key absent from $changes is left as it is. Machine-managed keys and * keys the schema does not declare are ignored. * * @param Feature $feature Feature. * @param array $changes Field key => new value, already typed. * @return void */ public function patch( Feature $feature, array $changes ) { $values = Settings::get( $feature->id() ); $values['enabled'] = ! empty( $values['enabled'] ); foreach ( $feature->settings_schema() as $key => $spec ) { if ( ! array_key_exists( $key, $changes ) ) { continue; } if ( isset( $spec['ui'] ) && 'status' === $spec['ui'] ) { continue; } $values[ $key ] = $this->sanitize_field( $changes[ $key ], $spec ); } $this->store( $feature, $values ); } /** * Write one feature's settings, and tell the feature when that switched it on or off. * * Every door ends here (the form and the REST route through save_values(), WP-CLI through * set_enabled() and patch()), so this is the one place that sees the switch move whichever way * it was moved. A feature that has something to do at that moment declares enabled_changed(); * it is called directly, not through a hook, because a feature that was off at the start of * the request has registered none. * * @param Feature $feature Feature. * @param array $values Its complete settings, 'enabled' included. * @return void */ private function store( Feature $feature, array $values ) { $was = Settings::is_enabled( $feature->id() ); $now = ! empty( $values['enabled'] ); Settings::update( $feature->id(), $values ); if ( $was !== $now && method_exists( $feature, 'enabled_changed' ) ) { $feature->enabled_changed( $now ); } } /** * Sanitize one submitted value against its schema entry. * * An unusable value falls back to the declared default rather than persisting. * * @param mixed $raw Submitted value. * @param array $spec Schema entry. * @return mixed */ private function sanitize_field( $raw, array $spec ) { $type = isset( $spec['type'] ) ? $spec['type'] : 'string'; $default = isset( $spec['default'] ) ? $spec['default'] : null; switch ( $type ) { case 'bool': return ! empty( $raw ); case 'int': return is_numeric( $raw ) ? (int) $raw : $default; case 'array': if ( ! is_array( $raw ) ) { return is_array( $default ) ? $default : array(); } return array_values( array_filter( array_map( 'sanitize_text_field', array_map( 'strval', $raw ) ) ) ); case 'string': default: $value = is_scalar( $raw ) ? sanitize_text_field( (string) $raw ) : $default; // A field that declares its choices accepts nothing outside them. if ( isset( $spec['choices'] ) && is_array( $spec['choices'] ) && ! in_array( $value, $spec['choices'], true ) ) { return $default; } return $value; } } }