* * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_iso_date( bool $required = true ): array { return [ 'required' => $required, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => static function ( mixed $value ): bool { if ( ! is_string( $value ) || $value === '' ) { return false; } $parsed = strtotime( $value ); return $parsed !== false; }, ]; } /** * Access level enum — `P` (public), `C` (confidential), `R` (private). * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_access_level(): array { $allowed = [ 'P', 'C', 'R' ]; return [ 'required' => false, 'type' => 'string', 'default' => 'P', 'enum' => $allowed, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => static fn( mixed $value ): bool => is_string( $value ) && in_array( $value, $allowed, true ), ]; } /** * Positive integer (> 0). * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_positive_int( bool $required = true ): array { return [ 'required' => $required, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static function ( mixed $value ) use ( $required ): bool { // When optional, accept absent-equivalent values (null, 0, '0', '') // as "not provided" rather than rejecting the entire request. UIs // commonly include the field with an empty value when the user // hasn't set one (e.g. category_id when no category is chosen). if ( ! $required && ( $value === null || $value === 0 || $value === '0' || $value === '' ) ) { return true; } if ( is_int( $value ) ) { return $value > 0; } if ( ! is_string( $value ) || $value === '' ) { return false; } return ctype_digit( ltrim( $value, '-' ) ) && ! str_starts_with( $value, '-' ) && (int) $value > 0; }, ]; } /** * iCal RRULE string (RFC 5545). Empty string means "no recurrence". * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_rrule(): array { return [ 'required' => false, 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => static function ( mixed $value ): bool { if ( ! is_string( $value ) ) { return false; } if ( $value === '' ) { return true; } // RFC 5545 requires FREQ as the first and only mandatory rule part, // and keywords are case-sensitive. return str_starts_with( $value, 'FREQ=' ); }, ]; } /** * WordPress user login — 1–60 chars after `sanitize_user(..., true)`. * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_login(): array { return [ 'required' => false, 'type' => 'string', 'sanitize_callback' => static fn( mixed $value ): string => sanitize_user( is_string( $value ) ? $value : '', true ), 'validate_callback' => static function ( mixed $value ): bool { if ( ! is_string( $value ) ) { return false; } $len = strlen( $value ); // Empty string is a valid "global" owner sentinel (categories, // nonuser calendars, etc. distinguish "no owner" from a // specific user login). Treat it as "not provided" rather // than rejecting the request. return $len === 0 || $len <= 60; }, ]; } /** * Hex color — `#rgb` or `#rrggbb`. Empty string accepted as "unset". * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_hex_color(): array { return [ 'required' => false, 'type' => 'string', 'default' => '', 'sanitize_callback' => static function ( mixed $value ): string { $sanitized = sanitize_hex_color( is_string( $value ) ? $value : '' ); return is_string( $sanitized ) ? $sanitized : ''; }, 'validate_callback' => static function ( mixed $value ): bool { if ( ! is_string( $value ) ) { return false; } if ( $value === '' ) { return true; } return preg_match( '/^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$/', $value ) === 1; }, ]; } /** * Rich-text body — allowed HTML is filtered by `wp_kses_post()`. * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_rich_text(): array { return [ 'required' => false, 'type' => 'string', 'default' => '', 'sanitize_callback' => 'wp_kses_post', ]; } /** * Generic bounded string — `sanitize_text_field` with a length cap. * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_string( int $max_length, bool $required = true ): array { return [ 'required' => $required, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => static function ( mixed $value ) use ( $max_length ): bool { return is_string( $value ) && strlen( $value ) <= $max_length; }, ]; } /** * Free-text tag list — an array of names, not ids. * * Tags are global flat labels users type rather than pick, so the wire * format is names and the server matches-or-creates. Sanitized to * trimmed, non-empty, case-insensitively unique names, each capped at * the category name column's 80 characters, with the list itself capped * so one request cannot create unbounded rows. * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_tag_names( int $max_tags = 50 ): array { return [ 'required' => false, 'type' => 'array', 'sanitize_callback' => static function ( mixed $value ) use ( $max_tags ): array { if ( ! is_array( $value ) ) { return []; } $out = []; $seen = []; foreach ( $value as $item ) { if ( ! is_scalar( $item ) ) { continue; } $name = trim( sanitize_text_field( (string) $item ) ); if ( '' === $name ) { continue; } if ( mb_strlen( $name ) > 80 ) { $name = mb_substr( $name, 0, 80 ); } $key = mb_strtolower( $name ); if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $out[] = $name; if ( count( $out ) >= $max_tags ) { break; } } return $out; }, 'validate_callback' => static fn( mixed $value ): bool => is_array( $value ), ]; } /** * Boolean — uses WP's built-in `rest_sanitize_boolean` so that string * forms (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"on"`) coerce * correctly. * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_bool( bool $default = false ): array { return [ 'required' => false, 'type' => 'boolean', 'default' => $default, 'sanitize_callback' => 'rest_sanitize_boolean', ]; } /** * Event attachments: a list of `{ id, url, filename }` records. * * Attachments are persisted in a wp_option and handed straight back out * over REST, and the frontend renders each one as a link and, for image * extensions, an ``. EventTransformer re-resolves the URL from the * Media Library whenever the record carries an `id`, but a record without * one keeps whatever URL the caller sent — and React will happily render * `href="javascript:…"`. This shape-checks each record, pins URLs to * http/https, and caps the list so a single request cannot grow the * option without bound. * * @param int $max Maximum number of attachments accepted per request. * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_attachments( int $max = 50 ): array { return [ 'required' => false, 'type' => 'array', 'default' => [], 'sanitize_callback' => static function ( mixed $value ) use ( $max ): array { if ( ! is_array( $value ) ) { return []; } $out = []; foreach ( $value as $item ) { if ( count( $out ) >= $max ) { break; } if ( ! is_array( $item ) ) { continue; } $record = []; $id = isset( $item['id'] ) && is_scalar( $item['id'] ) ? absint( $item['id'] ) : 0; if ( $id > 0 ) { $record['id'] = $id; } $raw_url = isset( $item['url'] ) && is_string( $item['url'] ) ? trim( $item['url'] ) : ''; if ( $raw_url !== '' ) { $url = esc_url_raw( $raw_url, [ 'http', 'https' ] ); // esc_url_raw() strips a disallowed scheme rather than // rejecting the string, so check what actually survived // instead of trusting a non-empty return. if ( $url !== '' && in_array( wp_parse_url( $url, PHP_URL_SCHEME ), [ 'http', 'https' ], true ) ) { $record['url'] = $url; } } // A record is only useful if it names something: either a // Media Library id the transformer can resolve, or a URL // that survived the scheme check. if ( ! isset( $record['id'] ) && ! isset( $record['url'] ) ) { continue; } if ( isset( $item['filename'] ) && is_scalar( $item['filename'] ) ) { $filename = sanitize_file_name( (string) $item['filename'] ); if ( $filename !== '' ) { $record['filename'] = mb_substr( $filename, 0, 255 ); } } $out[] = $record; } return $out; }, 'validate_callback' => static fn( mixed $value ): bool => is_array( $value ) || $value === null, ]; } /** * URL — sanitized with `esc_url_raw` and validated with * `FILTER_VALIDATE_URL`. * * @return array * * @psalm-suppress PossiblyUnusedMethod -- See trait-level note. */ protected function rest_arg_url( bool $required = true, bool $allow_empty = false ): array { return [ 'required' => $required, 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'validate_callback' => static function ( mixed $value ) use ( $allow_empty ): bool { if ( ! is_string( $value ) ) { return false; } if ( $value === '' ) { // Optional, clearable fields send '' to mean "remove // this link". A required URL still rejects it. return $allow_empty; } return filter_var( $value, FILTER_VALIDATE_URL ) !== false; }, ]; } }