|null */ private $values = null; /** * Returns one setting. * * @since 0.1.0 * * @param string $key Setting name. * @param mixed $fallback Returned when the setting does not exist. * @return mixed */ public function get( string $key, $fallback = null ) { $values = $this->all(); return array_key_exists( $key, $values ) ? $values[ $key ] : $fallback; } /** * Returns every setting with defaults applied. * * @since 0.1.0 * * @return array */ public function all(): array { if ( null === $this->values ) { $saved = get_option( self::OPTION, array() ); $saved = is_array( $saved ) ? $saved : array(); $values = array(); foreach ( Definitions::fields() as $key => $definition ) { $values[ $key ] = array_key_exists( $key, $saved ) ? $this->cast( $definition, $saved[ $key ] ) : $definition['default']; } $this->values = $values; } return $this->values; } /** * Validates and saves settings. * * @since 0.1.0 * * @param array $input Raw values keyed by setting name. Unknown keys are ignored. * @return array{values: array, errors: array} `errors` is empty on success. */ public function update( array $input ): array { $fields = Definitions::fields(); $clean = array(); $errors = array(); foreach ( $input as $key => $raw ) { if ( ! isset( $fields[ $key ] ) ) { continue; } // A secret is never sent back to the app, so an empty value means "keep the saved one". if ( 'secret' === $fields[ $key ]['type'] && ( ! is_string( $raw ) || '' === trim( $raw ) ) ) { continue; } $result = $this->sanitize_value( $fields[ $key ], $raw ); if ( null !== $result['error'] ) { $errors[ $key ] = $result['error']; continue; } $clean[ $key ] = $result['value']; } if ( $errors ) { return array( 'values' => $this->all(), 'errors' => $errors, ); } $saved = get_option( self::OPTION, array() ); $saved = is_array( $saved ) ? $saved : array(); update_option( self::OPTION, array_merge( $saved, $clean ), true ); $this->values = null; return array( 'values' => $this->all(), 'errors' => array(), ); } /** * Formats an amount in minor units with the store currency settings. * * @since 0.1.0 * * @param int $minor Amount in minor units. * @return string */ public function format_money( int $minor ): string { return Money::from_minor( $minor )->format( array( 'symbol' => $this->get( 'currency_symbol' ), 'position' => $this->get( 'currency_position' ), 'decimals' => $this->get( 'currency_decimals' ), 'thousand_separator' => $this->get( 'thousand_separator' ), 'decimal_separator' => $this->get( 'decimal_separator' ), ) ); } /** * Data handed to the admin app: values, field schema, tab labels and the page list. * * @since 0.1.0 * * @return array */ public function export_for_app(): array { $schema = array(); $values = $this->all(); foreach ( Definitions::fields() as $key => $definition ) { $extra = array(); // Secrets (passwords, keys) are never exposed: the app only learns whether one is saved. if ( 'secret' === $definition['type'] ) { $extra['has_value'] = '' !== (string) $values[ $key ]; $values[ $key ] = ''; } $schema[] = array_merge( array( 'key' => $key ), $extra, array_diff_key( $definition, array( 'default' => true, 'pattern' => true, 'pattern_message' => true, ) ) ); } return array( 'values' => $values, 'schema' => $schema, 'groups' => Definitions::groups(), 'pages' => $this->page_options(), ); } /** * Sanitises and validates one value against its definition. * * @param array $definition Field definition. * @param mixed $raw Raw value. * @return array{value: mixed, error: string|null} */ private function sanitize_value( array $definition, $raw ): array { $type = $definition['type']; $value = null; switch ( $type ) { case 'string': $value = Sanitizer::text( $raw ); $max = $definition['max'] ?? 0; if ( ! empty( $definition['uppercase'] ) ) { $value = strtoupper( $value ); } if ( ! empty( $definition['required'] ) && '' === $value ) { return $this->fail( __( 'This field is required.', 'hostkhata' ) ); } if ( $max && mb_strlen( $value ) > $max ) { /* translators: %d: maximum number of characters. */ return $this->fail( sprintf( __( 'Use at most %d characters.', 'hostkhata' ), $max ) ); } if ( '' !== $value && ! empty( $definition['pattern'] ) && 1 !== preg_match( $definition['pattern'], $value ) ) { return $this->fail( $definition['pattern_message'] ?? __( 'This value is not in the expected format.', 'hostkhata' ) ); } break; case 'textarea': $value = Sanitizer::textarea( $raw ); $max = $definition['max'] ?? 0; if ( $max && mb_strlen( $value ) > $max ) { /* translators: %d: maximum number of characters. */ return $this->fail( sprintf( __( 'Use at most %d characters.', 'hostkhata' ), $max ) ); } break; case 'secret': // Left as typed: stripping characters would change the password. Only surrounding spaces go. $value = is_string( $raw ) ? trim( $raw ) : ''; $max = $definition['max'] ?? 0; if ( $max && mb_strlen( $value ) > $max ) { /* translators: %d: maximum number of characters. */ return $this->fail( sprintf( __( 'Use at most %d characters.', 'hostkhata' ), $max ) ); } break; case 'email': $value = Sanitizer::email( $raw ); if ( '' === $value && '' !== trim( (string) ( is_scalar( $raw ) ? $raw : '' ) ) ) { return $this->fail( __( 'Enter a valid email address.', 'hostkhata' ) ); } break; case 'url': $value = Sanitizer::url( $raw ); if ( '' === $value && '' !== trim( (string) ( is_scalar( $raw ) ? $raw : '' ) ) ) { return $this->fail( __( 'Enter a valid URL.', 'hostkhata' ) ); } break; case 'int': if ( ! is_int( $raw ) && ! ( is_string( $raw ) && 1 === preg_match( '/^-?\d+$/', trim( $raw ) ) ) ) { return $this->fail( __( 'Enter a whole number.', 'hostkhata' ) ); } $value = (int) $raw; if ( isset( $definition['min'] ) && $value < $definition['min'] ) { /* translators: %d: minimum number. */ return $this->fail( sprintf( __( 'Must be at least %d.', 'hostkhata' ), $definition['min'] ) ); } if ( isset( $definition['max'] ) && $value > $definition['max'] ) { /* translators: %d: maximum number. */ return $this->fail( sprintf( __( 'Must be at most %d.', 'hostkhata' ), $definition['max'] ) ); } break; case 'bool': $value = Sanitizer::boolean( $raw ); break; case 'enum': $choices = array_keys( $definition['choices'] ); $value = Sanitizer::enum( $raw, $choices, '' ); if ( '' === $value ) { return $this->fail( __( 'Choose one of the allowed values.', 'hostkhata' ) ); } break; case 'color': $value = Sanitizer::hex_color( $raw ); if ( '' === $value ) { return $this->fail( __( 'Enter a colour such as #4f46e5.', 'hostkhata' ) ); } break; case 'decimal': $value = Sanitizer::decimal( $raw ); if ( '' === $value ) { return $this->fail( __( 'Enter a number such as 12.50.', 'hostkhata' ) ); } break; case 'page': $value = Sanitizer::absint( $raw ); if ( $value > 0 && 'page' !== get_post_type( $value ) ) { return $this->fail( __( 'Choose an existing page.', 'hostkhata' ) ); } break; } return array( 'value' => $value, 'error' => null, ); } /** * Builds a failing result. * * @param string $message Error message. * @return array{value: null, error: string} */ private function fail( string $message ): array { return array( 'value' => null, 'error' => $message, ); } /** * Casts a stored value to the type of its definition, so a corrupted option cannot break callers. * * @param array $definition Field definition. * @param mixed $stored Stored value. * @return mixed */ private function cast( array $definition, $stored ) { switch ( $definition['type'] ) { case 'int': case 'page': return (int) $stored; case 'bool': return (bool) $stored; case 'enum': return array_key_exists( (string) $stored, $definition['choices'] ) ? (string) $stored : $definition['default']; default: return is_scalar( $stored ) ? (string) $stored : $definition['default']; } } /** * Published pages for the page pickers. * * @return array */ private function page_options(): array { $pages = get_pages( array( 'sort_column' => 'post_title', 'number' => 200, ) ); $options = array(); foreach ( (array) $pages as $page ) { $options[] = array( 'id' => (int) $page->ID, 'title' => '' !== $page->post_title ? $page->post_title : __( '(no title)', 'hostkhata' ), ); } return $options; } }