'is', 'label' => 'is', 'evaluate' => fn( $a, $b ) => 0 === self::compare( $a, $b ) ] ); Operator_Registry::register( [ 'id' => 'is_not', 'label' => 'is not', 'evaluate' => fn( $a, $b ) => 0 !== self::compare( $a, $b ) ] ); Operator_Registry::register( [ 'id' => 'contains', 'label' => 'contains', 'evaluate' => function ( $a, $b ) { if ( is_array( $a ) ) { return in_array( (string) $b, array_map( 'strval', $a ), true ); } return false !== stripos( (string) $a, (string) $b ); } ] ); Operator_Registry::register( [ 'id' => 'not_contains', 'label' => 'does not contain', 'evaluate' => function ( $a, $b ) { if ( is_array( $a ) ) { return ! in_array( (string) $b, array_map( 'strval', $a ), true ); } return false === stripos( (string) $a, (string) $b ); } ] ); Operator_Registry::register( [ 'id' => 'starts_with', 'label' => 'starts with', 'evaluate' => fn( $a, $b ) => 0 === stripos( (string) $a, (string) $b ) ] ); Operator_Registry::register( [ 'id' => 'not_starts_with', 'label' => 'does not start with', 'evaluate' => fn( $a, $b ) => 0 !== stripos( (string) $a, (string) $b ) ] ); Operator_Registry::register( [ 'id' => 'ends_with', 'label' => 'ends with', 'evaluate' => function ( $a, $b ) { $a = (string) $a; $b = (string) $b; return '' === $b || strtolower( substr( $a, -strlen( $b ) ) ) === strtolower( $b ); } ] ); Operator_Registry::register( [ 'id' => 'not_ends_with', 'label' => 'does not end with', 'evaluate' => function ( $a, $b ) { $a = (string) $a; $b = (string) $b; return ! ( '' === $b || strtolower( substr( $a, -strlen( $b ) ) ) === strtolower( $b ) ); } ] ); Operator_Registry::register( [ 'id' => 'greater_than', 'label' => 'greater than', 'evaluate' => fn( $a, $b ) => self::compare( $a, $b ) > 0 ] ); Operator_Registry::register( [ 'id' => 'greater_or_equal', 'label' => 'greater than or equal to', 'evaluate' => fn( $a, $b ) => self::compare( $a, $b ) >= 0 ] ); Operator_Registry::register( [ 'id' => 'less_than', 'label' => 'less than', 'evaluate' => fn( $a, $b ) => self::compare( $a, $b ) < 0 ] ); Operator_Registry::register( [ 'id' => 'less_or_equal', 'label' => 'less than or equal to', 'evaluate' => fn( $a, $b ) => self::compare( $a, $b ) <= 0 ] ); Operator_Registry::register( [ 'id' => 'between', 'label' => 'is between', 'evaluate' => function ( $a, $range ) { [ $lo, $hi ] = self::range_bounds( $range ); return self::compare( $a, $lo ) >= 0 && self::compare( $a, $hi ) <= 0; } ] ); Operator_Registry::register( [ 'id' => 'not_between', 'label' => 'is not between', 'evaluate' => function ( $a, $range ) { [ $lo, $hi ] = self::range_bounds( $range ); return ! ( self::compare( $a, $lo ) >= 0 && self::compare( $a, $hi ) <= 0 ); } ] ); Operator_Registry::register( [ 'id' => 'is_empty', 'label' => 'is empty', 'evaluate' => fn( $a ) => self::is_empty_value( $a ) ] ); Operator_Registry::register( [ 'id' => 'is_not_empty', 'label' => 'is not empty', 'evaluate' => fn( $a ) => ! self::is_empty_value( $a ) ] ); Operator_Registry::register( [ 'id' => 'in_list', 'label' => 'is one of', 'evaluate' => function ( $a, $list ) { foreach ( self::to_list( $list ) as $item ) { if ( 0 === self::compare( $a, $item ) ) { return true; } } return false; } ] ); Operator_Registry::register( [ 'id' => 'not_in_list', 'label' => 'is not one of', 'evaluate' => function ( $a, $list ) { foreach ( self::to_list( $list ) as $item ) { if ( 0 === self::compare( $a, $item ) ) { return false; } } return true; } ] ); Operator_Registry::register( [ 'id' => 'regex', 'label' => 'matches pattern (regex)', 'evaluate' => function ( $a, $pattern ) { if ( ! self::is_safe_regex_pattern( $pattern ) ) { return false; } $delimited = '#' . str_replace( '#', '\#', (string) $pattern ) . '#'; $previous_limit = ini_get( 'pcre.backtrack_limit' ); ini_set( 'pcre.backtrack_limit', '100000' ); // phpcs:ignore WordPress.PHP.IniSet.Risky, Squiz.PHP.DiscouragedFunctions.Discouraged -- scoped guard for author-supplied regex. $matched = @preg_match( $delimited, (string) $a ) === 1; // phpcs:ignore WordPress.PHP.NoSilencedErrors -- invalid patterns must never fatal mid-evaluation. ini_set( 'pcre.backtrack_limit', (string) $previous_limit ); // phpcs:ignore WordPress.PHP.IniSet.Risky, Squiz.PHP.DiscouragedFunctions.Discouraged -- restore host setting immediately. return $matched; } ] ); Operator_Registry::register( [ 'id' => 'is_true', 'label' => 'is checked / true', 'evaluate' => fn( $a ) => true === $a || '1' === (string) $a || 'true' === (string) $a ] ); Operator_Registry::register( [ 'id' => 'is_false', 'label' => 'is unchecked / false', 'evaluate' => fn( $a ) => ! ( true === $a || '1' === (string) $a || 'true' === (string) $a ) ] ); } /** Numeric-aware, whitespace-trimmed compare — mirroring operators/builtins.js's toComparable()/compare() exactly, including the trim (see that file's header comment on why: the old per-field is_field_visible() this engine replaces always trimmed before comparing, and dropping that silently would have been a real regression). */ private static function compare( $a, $b ) { $na = self::to_number( $a ); $nb = self::to_number( $b ); if ( null !== $na && null !== $nb ) { return $na <=> $nb; } return strtolower( trim( self::to_comparable_string( $a ) ) ) <=> strtolower( trim( self::to_comparable_string( $b ) ) ); } /** * A real client/server evaluator divergence found live: PHP's bare * `(string) $array` cast produces the literal string "Array" (plus an * "Array to string conversion" warning), while JS's implicit * `String(['a','b'])` coercion — what operators/builtins.js's * toComparable() actually does — produces the comma-joined "a,b". * A checkbox/multi-select field's array value compared with a * non-contains operator (is/is_not/greater_than/between/in_list/etc) * therefore evaluated DIFFERENTLY between the browser's live * required/visible state and this server-side authority — every value * collapsed to "Array" here regardless of what was actually selected. * Joining with a bare comma (no space, matching JS's Array.prototype. * toString exactly) keeps the two engines in agreement. */ private static function to_comparable_string( $value ) { if ( is_array( $value ) ) { return implode( ',', array_map( 'strval', $value ) ); } return (string) $value; } private static function to_number( $value ) { if ( null === $value || '' === $value || is_array( $value ) ) { return null; } $trimmed = trim( (string) $value ); return is_numeric( $trimmed ) ? (float) $trimmed : null; } private static function is_empty_value( $value ) { return null === $value || '' === $value || ( is_array( $value ) && empty( $value ) ); } private static function to_list( $value ) { if ( is_array( $value ) ) { return $value; } return array_map( 'trim', explode( ',', (string) $value ) ); } private static function range_bounds( $range ) { if ( is_array( $range ) ) { return [ $range[0] ?? null, $range[1] ?? null ]; } $parts = explode( ',', (string) $range ); return [ $parts[0] ?? null, $parts[1] ?? null ]; } private static function is_safe_regex_pattern( $pattern ) { $pattern = (string) $pattern; if ( '' === $pattern || strlen( $pattern ) > self::MAX_REGEX_LENGTH ) { return false; } return ! preg_match( '/\([^)]*[+*][^)]*\)\s*[+*{]/', $pattern ); } }