'array', 'sanitize_callback' => array( $this, 'sanitize' ), ) ); } /** * Keeps the option out of autoload, including on a first save. * * The Settings API's first save calls add_option() with no autoload value. * This covers every site on a network, which an activation hook would not. * Core stores the result as 'auto-off', which it does not autoload. * * @param bool|null $autoload Default autoload value. * @param string $option Option name. * @return bool|null */ public function filter_default_autoload( $autoload, $option ) { return self::OPTION === $option ? false : $autoload; } /** * Returns the saved limits. Anything that is not a valid limit is ignored. * * @return array */ public static function get_limits() { $stored = get_option( self::OPTION, array() ); $limits = array(); if ( ! is_array( $stored ) ) { return $limits; } foreach ( $stored as $post_type => $value ) { $limit = self::parse_limit( $value ); if ( null !== $limit ) { $limits[ $post_type ] = $limit; } } return $limits; } /** * Returns the post types shown on the screen, sorted by label. * * @return array */ public static function get_post_types() { $post_types = array(); foreach ( get_post_types( array( 'show_ui' => true ), 'objects' ) as $name => $post_type ) { if ( post_type_supports( $name, 'revisions' ) ) { $post_types[ $name ] = $post_type; } } uasort( $post_types, static function ( $a, $b ) { return strcasecmp( $a->label, $b->label ); } ); return $post_types; } /** * Returns the dropdown choices and their labels. * * @return array */ public static function get_modes() { return array( self::MODE_DEFAULT => __( 'Use site default', 'leafcap-revision-limits' ), self::MODE_UNLIMITED => __( 'Unlimited', 'leafcap-revision-limits' ), self::MODE_NONE => __( 'Store no new revisions', 'leafcap-revision-limits' ), self::MODE_NUMBER => __( 'Keep a set number', 'leafcap-revision-limits' ), ); } /** * Sanitizes the option. * * Takes two formats, and both are handled row by row: * - The form posts each row as array( 'mode' => ..., 'number' => ... ). * - The stored format is post type => integer. On a first save core runs * this callback again on its own output, through add_option(). That * output holds only valid limits, so the second run adds no errors. * * A post type absent from the input keeps its limit, in either format. Only * a form row set to "Use site default" removes a limit. Limits for post * types not on the screen are always kept, and submitted keys for them are * discarded. * * @param mixed $input Submitted value, already unslashed by core. * @return array */ public function sanitize( $input ) { $previous = self::get_limits(); if ( ! is_array( $input ) ) { return $previous; } $post_types = self::get_post_types(); $sanitized = array_diff_key( $previous, $post_types ); foreach ( $post_types as $name => $post_type ) { $stored = isset( $previous[ $name ] ) ? $previous[ $name ] : null; if ( ! array_key_exists( $name, $input ) ) { $limit = $stored; } elseif ( is_array( $input[ $name ] ) ) { $limit = $this->sanitize_row( $input[ $name ], $name, $post_type->label, $stored ); } else { $limit = self::parse_limit( $input[ $name ] ); if ( null === $limit ) { $this->add_invalid_limit_error( $name, $post_type->label ); $limit = $stored; } } if ( null !== $limit ) { $sanitized[ $name ] = $limit; } } ksort( $sanitized ); return $sanitized; } /** * Applies the save rules to one form row. * * @param array $row Submitted row. * @param string $name Post type key. * @param string $label Post type label. * @param int|null $stored Saved limit, or null if none. * @return int|null The limit to store, or null for none. */ private function sanitize_row( array $row, $name, $label, $stored ) { $mode = isset( $row['mode'] ) ? $row['mode'] : null; $number = isset( $row['number'] ) ? $row['number'] : ''; $number = is_string( $number ) || is_int( $number ) ? trim( (string) $number ) : null; if ( ! is_string( $mode ) || ! array_key_exists( $mode, self::get_modes() ) ) { $this->add_invalid_limit_error( $name, $label ); return $stored; } if ( self::MODE_NUMBER === $mode ) { $keep = self::parse_number_to_keep( $number ); if ( null === $keep ) { $this->add_number_error( $name, $label ); return $stored; } return $keep; } // The field still shows the saved number, so that value is a leftover. $is_leftover = null !== $stored && $stored >= 1 && (string) $stored === $number; if ( '' !== $number && ! $is_leftover ) { $this->add_stray_number_error( $name, $label ); return $stored; } $limits = array( self::MODE_DEFAULT => null, self::MODE_UNLIMITED => -1, self::MODE_NONE => 0, ); return $limits[ $mode ]; } /** * Reads a stored-format value: an integer of -1 or more. * * @param mixed $value Value to read. * @return int|null The limit, or null if it is not valid. */ private static function parse_limit( $value ) { if ( ! is_int( $value ) && ! ( is_string( $value ) && preg_match( '/^-?[0-9]+$/', $value ) ) ) { return null; } $limit = filter_var( $value, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => -1 ) ) ); return false === $limit ? null : $limit; } /** * Reads the "Number to keep" field: digits only, with a value of 1 or more. * * This is stricter than parse_limit(), because 0 and -1 are reached only * through the dropdown. A number too large for an integer is rejected. * * @param string|null $number Trimmed field value. * @return int|null The number, or null if it is not valid. */ private static function parse_number_to_keep( $number ) { if ( null === $number || ! preg_match( '/^[0-9]+$/', $number ) ) { return null; } $keep = filter_var( ltrim( $number, '0' ), FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); return false === $keep ? null : $keep; } /** * Adds the error for a "Keep a set number" row with a bad number. * * @param string $name Post type key. * @param string $label Post type label. * @return void */ private function add_number_error( $name, $label ) { $modes = self::get_modes(); $this->add_error( $name, sprintf( /* translators: 1: Post type label. 2: Name of the "Keep a set number" dropdown option. */ __( '%1$s: enter a whole number of 1 or more for "%2$s". The previous limit is still in place. Any other changes were saved.', 'leafcap-revision-limits' ), $label, $modes[ self::MODE_NUMBER ] ) ); } /** * Adds the error for a number typed on a row that does not use one. * * @param string $name Post type key. * @param string $label Post type label. * @return void */ private function add_stray_number_error( $name, $label ) { $modes = self::get_modes(); $this->add_error( $name, sprintf( /* translators: 1: Post type label. 2: Name of the "Keep a set number" dropdown option. */ __( '%1$s: choose "%2$s" to use the number you entered. The previous limit is still in place. Any other changes were saved.', 'leafcap-revision-limits' ), $label, $modes[ self::MODE_NUMBER ] ) ); } /** * Adds the error for an unrecognised limit. * * @param string $name Post type key. * @param string $label Post type label. * @return void */ private function add_invalid_limit_error( $name, $label ) { $this->add_error( $name, sprintf( /* translators: %s: Post type label. */ __( '%s: that limit is not valid. The previous limit is still in place. Any other changes were saved.', 'leafcap-revision-limits' ), $label ) ); } /** * Records a settings error. Core prints these messages without escaping. * * @param string $name Post type key. * @param string $message Plain text message. * @return void */ private function add_error( $name, $message ) { add_settings_error( self::OPTION, 'leafcap-revision-limits-' . $name, esc_html( $message ), 'error' ); } }