'text', 2 => 'textarea', 3 => 'url', 4 => 'date', 5 => 'email', 6 => 'text', // EXTRA_USER 7 => 'select', // EXTRA_RADIO 8 => 'select', // EXTRA_SELECTLIST 9 => 'checkbox', ); /** The type a legacy cal_type value maps onto. */ public static function type_for_legacy( int $legacy_type ): string { return self::LEGACY_TYPES[ $legacy_type ] ?? 'text'; } /** * The defined fields, in the order they should be shown. * * @return list}> */ public static function all(): array { $stored = get_option( self::OPTION, array() ); if ( ! is_array( $stored ) ) { return array(); } $fields = array(); foreach ( $stored as $field ) { $clean = self::sanitize_field( $field ); if ( null !== $clean ) { $fields[] = $clean; } } return $fields; } /** * Replace the definitions. * * @param array $fields Raw definitions from a form or the API. * @return list}> What was stored. * * @psalm-suppress PossiblyUnusedMethod -- Called from ExtraFieldsController, * which Psalm cannot see because its own methods are * reached only through the REST API's callback registry. * @psalm-suppress PossiblyUnusedReturnValue -- The controller returns it to * the client, for the same reason. */ public static function save( array $fields ): array { $clean = array(); $seen = array(); foreach ( $fields as $field ) { $sanitized = self::sanitize_field( $field ); if ( null === $sanitized ) { continue; } if ( isset( $seen[ $sanitized['name'] ] ) ) { // A name that arrived with the field belongs to a definition // already in use: its values are filed under it, so rewriting // it would orphan every one of them. First definition wins, // as before. if ( self::has_explicit_name( $field ) ) { continue; } // A name that was derived from the label is ours to adjust. // sanitize_key() strips spaces rather than converting them, so // "Room 1" and "Room1" both reduce to "room1" — two labels an // admin would reasonably type, one of which used to vanish on // save while quietly inheriting the other's values on every // event. $sanitized['name'] = self::unique_name( $sanitized['name'], $seen ); } $seen[ $sanitized['name'] ] = true; $clean[] = $sanitized; } update_option( self::OPTION, $clean, false ); return $clean; } /** * The defined field names, for validating submitted values. * * @return list */ public static function names(): array { return array_column( self::all(), 'name' ); } /** * Keep only values whose field is defined, in definition order. * * @param array $values Submitted name => value pairs. * @return array * * @psalm-suppress PossiblyUnusedMethod -- Called from EventsController's * create and update paths, which Psalm reaches only * through the REST API's callback registry. */ public static function filter_values( array $values ): array { $kept = array(); foreach ( self::all() as $field ) { if ( ! array_key_exists( $field['name'], $values ) ) { continue; } $raw = $values[ $field['name'] ]; if ( ! is_scalar( $raw ) ) { continue; } $kept[ $field['name'] ] = self::sanitize_for_field( (string) $raw, $field ); } return $kept; } /** Whether the caller supplied a name rather than leaving it to the label. */ private static function has_explicit_name( mixed $field ): bool { return is_array( $field ) && isset( $field['name'] ) && is_scalar( $field['name'] ) && '' !== trim( (string) $field['name'] ); } /** * The first free variant of a derived name. * * Suffixes rather than truncating the label further, so the name still * resembles what was typed. cal_name is VARCHAR(60), so the base is cut * back to make room for the suffix instead of overflowing it — a name the * database silently shortened would collide all over again, in a place * nothing checks. * * @param array $seen Names already taken in this save. */ private static function unique_name( string $base, array $seen ): string { for ( $n = 2; $n < 1000; $n++ ) { $suffix = '-' . $n; $candidate = substr( $base, 0, 60 - strlen( $suffix ) ) . $suffix; if ( ! isset( $seen[ $candidate ] ) ) { return $candidate; } } return $base; } /** * One definition, or null when it cannot be used. * * @param mixed $field Raw definition. * @return array{name: string, label: string, type: string, options?: list}|null */ private static function sanitize_field( $field ): ?array { if ( ! is_array( $field ) ) { return null; } $label = isset( $field['label'] ) && is_scalar( $field['label'] ) ? trim( (string) $field['label'] ) : ''; $name = isset( $field['name'] ) && is_scalar( $field['name'] ) ? trim( (string) $field['name'] ) : ''; // A name may be derived from the label, which is what an admin screen // that only asks for a label needs. if ( '' === $name && '' !== $label ) { $name = sanitize_key( $label ); } if ( '' === $name ) { return null; } // cal_name is VARCHAR(60) and half of a primary key, so the name is // truncated here rather than by the database. $name = substr( sanitize_key( $name ), 0, 60 ); if ( '' === $name ) { return null; } $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? (string) $field['type'] : 'text'; if ( ! in_array( $type, self::TYPES, true ) ) { $type = 'text'; } $definition = array( 'name' => $name, 'label' => '' === $label ? $name : $label, 'type' => $type, ); // Only a select carries choices, and a select with none is a text box // that refuses everything, so it degrades rather than trapping values. if ( 'select' === $type ) { $options = self::sanitize_options( $field['options'] ?? array() ); if ( array() === $options ) { $definition['type'] = 'text'; } else { $definition['options'] = $options; } } return $definition; } /** * The choices a select offers. * * @param mixed $options Raw options. * @return list */ private static function sanitize_options( $options ): array { if ( ! is_array( $options ) ) { return array(); } $clean = array(); foreach ( $options as $option ) { if ( ! is_scalar( $option ) ) { continue; } $text = trim( sanitize_text_field( (string) $option ) ); if ( '' !== $text && ! in_array( $text, $clean, true ) ) { $clean[] = $text; } } return $clean; } /** * Sanitize one submitted value for its field. * * @param array{name: string, label: string, type: string, options?: list} $field */ private static function sanitize_for_field( string $raw, array $field ): string { if ( 'select' === $field['type'] ) { // A value outside the list is not saved. The alternative is // storing something the form can never show again, which reads to // the user as the field having silently emptied itself. $options = $field['options'] ?? array(); return in_array( trim( $raw ), $options, true ) ? trim( $raw ) : ''; } if ( 'checkbox' === $field['type'] ) { // One canonical truthy value, so a page rendering it does not have // to guess whether "0", "false" or "off" mean yes. return in_array( strtolower( trim( $raw ) ), array( '1', 'y', 'yes', 'true', 'on' ), true ) ? '1' : ''; } return self::sanitize_value( $raw, $field['type'] ); } /** Sanitize one submitted value for its field type. */ private static function sanitize_value( string $raw, string $type ): string { switch ( $type ) { case 'url': return esc_url_raw( trim( $raw ) ); case 'email': return sanitize_email( trim( $raw ) ); case 'number': return is_numeric( trim( $raw ) ) ? trim( $raw ) : ''; case 'date': // Stored as text, so an unparseable date is dropped rather // than written back as 1970-01-01. $date = trim( $raw ); return ( '' === $date || false !== strtotime( $date ) ) ? $date : ''; case 'textarea': return sanitize_textarea_field( $raw ); default: return sanitize_text_field( $raw ); } } }