|null */ private ?array $cache = null; /** * The stored settings before the `featherdash/settings` filter. * * @var array|null */ private ?array $raw = null; private function __construct() { $clear = function () : void { $this->cache = null; $this->raw = null; }; add_action( 'update_option_' . self::OPTION, $clear ); add_action( 'delete_option_' . self::OPTION, $clear ); $saved = static function ( mixed $old, mixed $new ) : void { /** * Fires after the settings were saved (settings page, import or code). * * @param array $new The new saved settings. * @param array $old The previous saved settings. */ do_action( 'featherdash/settings_saved', ( is_array( $new ) ? $new : [] ), ( is_array( $old ) ? $old : [] ) ); }; add_action( 'update_option_' . self::OPTION, $saved, 20, 2 ); add_action( 'add_option_' . self::OPTION, static fn( string $option, mixed $value ) => $saved( [], $value ), 20, 2 ); } public static function instance() : self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } /** * Dot-path lookup with the schema defaults merged in. */ public function get( string $path, mixed $default = null ) : mixed { $value = self::dot( $this->merged(), $path ); return ( $value === null ? $default : $value ); } /** * Dot-path lookup in the saved settings, ignoring the `featherdash/settings` * filter (what the settings page shows and exports). */ public function saved( string $path, mixed $default = null ) : mixed { $value = self::dot( $this->stored(), $path ); return ( $value === null ? $default : $value ); } /** * @return array */ public function all() : array { return $this->merged(); } /** * Sanitize a (possibly partial) settings tree against the schema. * * Keys missing from the input keep their stored value, so settings owned * by inactive integrations (WooCommerce, ACF, Yoast) survive a save. * * @param mixed $input * * @return array */ public static function sanitize( mixed $input ) : array { // Unfiltered, so values forced in code are never written to the database. $current = self::instance()->stored(); if ( !is_array( $input ) ) { return $current; } return self::sanitizeLevel( Schema::defaults(), $input, $current, '' ); } /** * @return array */ private function merged() : array { if ( $this->cache !== null ) { return $this->cache; } $settings = $this->stored(); /** * Filters the settings FeatherDash runs with: the defaults with the saved * values merged in. Use it to force values in code (per environment, * per site). Forced values are not saved and the settings page still * shows the saved ones. * * @param array $settings */ $filtered = apply_filters( 'featherdash/settings', $settings ); $settings = ( is_array( $filtered ) ? $filtered : $settings ); $this->cache = $settings; return $this->cache; } /** * The saved settings merged over the defaults, unfiltered. * * @return array */ public function stored() : array { if ( $this->raw !== null ) { return $this->raw; } $stored = get_option( self::OPTION ); $stored = ( is_array( $stored ) ? $stored : [] ); $this->raw = self::merge( Schema::defaults(), $stored, '' ); return $this->raw; } /** * Recursively overlay stored values on defaults. Keys whose default is a * list (checklists) are replaced wholesale instead of merged. * * @param array $defaults * @param array $stored * * @return array */ private static function merge( array $defaults, array $stored, string $prefix ) : array { $result = $defaults; foreach ( $stored as $key => $value ) { // A setting this build doesn't have: // kept as saved, so sanitize() doesn't drop it. if ( !array_key_exists( $key, $defaults ) ) { $result[$key] = $value; continue; } $default = $defaults[$key]; $path = ( $prefix === '' ? (string) $key : $prefix . '.' . $key ); // Keyed values (e.g. tables.columns per post type) replace the default. if ( (Schema::fieldTypes()[$path]['type'] ?? '') === 'map' ) { $result[$key] = ( is_array( $value ) ? $value : $default ); continue; } if ( is_array( $default ) && array_is_list( $default ) ) { $result[$key] = ( is_array( $value ) ? array_values( $value ) : $default ); continue; } if ( is_array( $default ) ) { $result[$key] = ( is_array( $value ) ? self::merge( $default, $value, $path ) : $default ); continue; } $result[$key] = $value; } return $result; } /** * @param array $defaults * @param array $input * @param array $current * * @return array */ private static function sanitizeLevel( array $defaults, array $input, array $current, string $prefix ) : array { $out = []; foreach ( $defaults as $key => $default ) { $key = (string) $key; $path = ( $prefix === '' ? $key : $prefix . '.' . $key ); $hasInput = array_key_exists( $key, $input ); $in = ( $hasInput ? $input[$key] : null ); $cur = ( array_key_exists( $key, $current ) ? $current[$key] : $default ); $meta = Schema::fieldTypes()[$path] ?? []; if ( ($meta['type'] ?? '') === 'map' ) { $out[$key] = ( $hasInput && isset( $meta['sanitize'] ) && is_callable( $meta['sanitize'] ) ? call_user_func( $meta['sanitize'], $in, $cur ) : $cur ); continue; } if ( is_array( $default ) && array_is_list( $default ) ) { $out[$key] = ( $hasInput ? self::sanitizeChecklist( $path, $in ) : (( is_array( $cur ) ? array_values( $cur ) : $default )) ); continue; } if ( is_array( $default ) ) { $out[$key] = self::sanitizeLevel( $default, ( is_array( $in ) ? $in : [] ), ( is_array( $cur ) ? $cur : $default ), $path ); continue; } $out[$key] = ( $hasInput ? self::cast( $path, $default, $in ) : $cur ); } // Saved values this build has no setting for stay as they are. foreach ( $current as $key => $value ) { if ( !array_key_exists( $key, $out ) ) { $out[$key] = $value; } } return $out; } /** * @return array */ private static function sanitizeChecklist( string $path, mixed $input ) : array { if ( !is_array( $input ) ) { return []; } $meta = Schema::fieldTypes()[$path] ?? []; $allowed = $meta['options'] ?? []; $values = []; foreach ( $input as $value ) { if ( is_scalar( $value ) ) { $values[] = (string) $value; } } // Free lists of IDs (e.g. toolbar node IDs found at runtime); '*' keeps every item. if ( ($meta['type'] ?? '') === 'keys' ) { return array_values( array_unique( array_filter( $values, static fn( string $value ): bool => (bool) preg_match( '/^(\\*|[A-Za-z0-9_-]{1,100})$/', $value ) ) ) ); } return array_values( array_intersect( $values, $allowed ) ); } private static function cast( string $path, mixed $default, mixed $value ) : mixed { $meta = Schema::fieldTypes()[$path] ?? null; $type = $meta['type'] ?? (( is_bool( $default ) ? 'toggle' : 'text' )); if ( isset( $meta['sanitize'] ) && is_callable( $meta['sanitize'] ) ) { return call_user_func( $meta['sanitize'], $value ); } return match ($type) { 'toggle' => $value === true || $value === 1 || $value === '1', 'color' => self::castColor( $value, $default ), 'textarea' => wp_kses_post( ( is_string( $value ) ? $value : '' ) ), 'select' => self::castSelect( $meta['options'] ?? [], $value, $default ), 'range' => self::castRange( $meta, $value, $default ), 'image' => self::castImage( $value ), default => sanitize_text_field( ( is_string( $value ) ? $value : '' ) ), }; } private static function castColor( mixed $value, mixed $default ) : string { $hex = sanitize_hex_color( ( is_string( $value ) ? $value : '' ) ); if ( is_string( $hex ) && $hex !== '' ) { return $hex; } return ( is_string( $default ) && $default !== '' ? $default : '#0fc0c0' ); } /** * A number within the field's min/max, on its step. * * @param array $meta */ private static function castRange( array $meta, mixed $value, mixed $default ) : int { if ( !is_numeric( $value ) ) { return (int) $default; } $min = (int) ($meta['min'] ?? 0); $max = (int) ($meta['max'] ?? 100); $step = max( 1, (int) ($meta['step'] ?? 1) ); $number = (int) round( ((float) $value - $min) / $step ) * $step + $min; return min( $max, max( $min, $number ) ); } /** * Attachment ID of an image in the media library, or 0. */ private static function castImage( mixed $value ) : int { $id = ( is_scalar( $value ) ? absint( $value ) : 0 ); return ( $id > 0 && wp_attachment_is_image( $id ) ? $id : 0 ); } /** * @param array $options */ private static function castSelect( array $options, mixed $value, mixed $default ) : mixed { if ( is_string( $value ) && in_array( $value, $options, true ) ) { return $value; } return $default; } /** * @param array $array * * @return array */ private static function setDot( array $array, string $path, mixed $value ) : array { $segments = explode( '.', $path ); $key = array_shift( $segments ); if ( $segments === [] ) { $array[$key] = $value; return $array; } $array[$key] = self::setDot( ( is_array( $array[$key] ?? null ) ? $array[$key] : [] ), implode( '.', $segments ), $value ); return $array; } private static function dot( array $array, string $path ) : mixed { $value = $array; foreach ( explode( '.', $path ) as $segment ) { if ( !is_array( $value ) || !array_key_exists( $segment, $value ) ) { return null; } $value = $value[$segment]; } return $value; } }