'Post/CPT Selection', ]; public static function reject_locked_fields( array $new_fields, array $existing_fields ) { $existing_types = array_column( self::flatten_field_types( $existing_fields ), 'type' ); foreach ( self::flatten_field_types( $new_fields ) as $field ) { $type = $field['type'] ?? ''; if ( in_array( $type, $existing_types, true ) ) { continue; // Grandfathered — already on this form before the check ran. } if ( isset( self::UNBUILT_FIELD_TYPES[ $type ] ) ) { return new \WP_Error( 'chargeforms_field_not_yet_available', /* translators: %s: field type label, e.g. "Ranking Field" */ sprintf( __( 'The "%s" field is not available yet — remove it before saving.', 'chargeforms' ), self::UNBUILT_FIELD_TYPES[ $type ] ), [ 'status' => 403 ] ); } } return true; } /** * Validates an AI/MCP-proposed field blueprint without writing anything. * This deliberately returns structured diagnostics so an AI client can * repair a proposal before calling a write tool. */ public static function validate_blueprint( array $fields, array $existing_fields = [] ) { $errors = []; $ids = []; $walk = function ( array $items, $path = '' ) use ( &$walk, &$errors, &$ids ) { foreach ( $items as $index => $field ) { $location = '' === $path ? (string) $index : $path . '.' . $index; if ( ! is_array( $field ) ) { $errors[] = [ 'path' => $location, 'code' => 'field_not_object', 'message' => __( 'Each field must be an object.', 'chargeforms' ) ]; continue; } $id = isset( $field['id'] ) ? sanitize_key( $field['id'] ) : ''; $type = isset( $field['type'] ) ? sanitize_key( $field['type'] ) : ''; if ( '' === $id || ! preg_match( '/^[a-z][a-z0-9_-]{0,63}$/', $id ) ) { $errors[] = [ 'path' => $location . '.id', 'code' => 'invalid_id', 'message' => __( 'Field IDs must start with a letter and contain only lowercase letters, numbers, underscores or hyphens.', 'chargeforms' ) ]; } elseif ( isset( $ids[ $id ] ) ) { $errors[] = [ 'path' => $location . '.id', 'code' => 'duplicate_id', 'message' => sprintf( __( 'Field ID "%s" is used more than once.', 'chargeforms' ), $id ) ]; } else { $ids[ $id ] = true; } if ( '' === $type ) { $errors[] = [ 'path' => $location . '.type', 'code' => 'missing_type', 'message' => __( 'Every field needs a type.', 'chargeforms' ) ]; } if ( ! isset( $field['label'] ) && ! in_array( $type, [ 'row', 'column', 'container', 'section', 'page_break' ], true ) ) { $errors[] = [ 'path' => $location . '.label', 'code' => 'missing_label', 'message' => __( 'Input fields need a label.', 'chargeforms' ) ]; } if ( ! empty( $field['children'] ) ) { if ( ! is_array( $field['children'] ) ) { $errors[] = [ 'path' => $location . '.children', 'code' => 'children_not_array', 'message' => __( 'Nested fields must use a children array.', 'chargeforms' ) ]; } else { $walk( $field['children'], $location . '.children' ); } } } }; $walk( $fields ); $gate = self::reject_locked_fields( $fields, $existing_fields ); if ( is_wp_error( $gate ) ) { $errors[] = [ 'path' => '', 'code' => $gate->get_error_code(), 'message' => $gate->get_error_message() ]; } return [ 'valid' => empty( $errors ), 'field_count' => count( $ids ), 'errors' => $errors ]; } /** Recursively walks a field tree (containers/sections/rows/columns can nest fields) into one flat list — mirrors core/tree.js's flattenFields() on the JS side. */ private static function flatten_field_types( array $fields ) { $flat = []; foreach ( $fields as $field ) { $flat[] = $field; if ( ! empty( $field['children'] ) && is_array( $field['children'] ) ) { $flat = array_merge( $flat, self::flatten_field_types( $field['children'] ) ); } } return $flat; } }