'/jet-engine/v2/get-post-types', 'get_post_type' => '/jet-engine/v2/get-post-type/', 'add_post_type' => '/jet-engine/v2/add-post-type', 'edit_post_type' => '/jet-engine/v2/edit-post-type/', 'get_meta_boxes' => '/jet-engine/v2/get-meta-boxes', 'add_meta_box' => '/jet-engine/v2/add-meta-box', 'edit_meta_box' => '/jet-engine/v2/edit-meta-box/', ); private const CPT_SLUG_PATTERN = '/^[a-z0-9_-]{1,20}$/'; private const FIELD_TYPES = array( 'text', 'textarea', 'number', 'url', 'email', 'select', 'checkbox', 'radio', 'date', 'time', 'datetime', 'wysiwyg', 'media', 'gallery', 'repeater', ); private const JETENGINE_FIELD_TYPES = array( 'text', 'textarea', 'repeater', ); private const POST_TYPE_ALLOWED_KEYS = array( 'provider', 'post_type', 'label', 'plural_label', 'labels', 'description', 'public', 'hierarchical', 'show_ui', 'show_in_rest', 'rest_base', 'supports', 'taxonomies', 'has_archive', 'rewrite', 'fields', 'provider_options', ); private const POST_TYPE_ALLOWED_PATHS = array( 'label' => 'string', 'plural_label' => 'string', 'labels.name' => 'string', 'labels.singular_name' => 'string', 'description' => 'string', 'public' => 'boolean', 'hierarchical' => 'boolean', 'show_ui' => 'boolean', 'show_in_rest' => 'boolean', 'rest_base' => 'string', 'supports' => 'array', 'taxonomies' => 'array', 'has_archive' => 'boolean', 'rewrite.slug' => 'string', 'rewrite.with_front' => 'boolean', 'rewrite.feeds' => 'boolean', 'rewrite.pages' => 'boolean', 'fields' => 'array', 'provider_options' => 'object', 'provider_options.acf' => 'object', 'provider_options.jetengine' => 'object', ); /** * Create a handler for one post-types write capability. */ public function __construct( private string $capability ) { } /** * Run the shared validate/execute pipeline for one wp.post_types command. * * @param array $input Command input document. * @param string $phase Either `validate` or `execute`. * @return array Internal result: `['data' => array]` or `['error' => array]`. */ public function run( array $input, string $phase ): array { return match ( $this->capability ) { self::CREATE_NAME => $this->create( $input, $phase ), self::UPDATE_NAME => $this->update( $input, $phase ), default => $this->error( 'unsupported_capability', 'This post type write capability is not supported.' ), }; } /** * Return provider availability facts for schema projection and validation. * * @return array> */ public static function provider_statuses(): array { return array( self::acf_provider_status(), self::jetengine_provider_status() ); } /** * Return the portable field types exposed by this first provider slice. * * @return array */ public static function supported_field_types(): array { return array_values( array_unique( array_merge( self::FIELD_TYPES, self::JETENGINE_FIELD_TYPES ) ) ); } /** * Return the field types verified for one provider. * * @param string $provider_id Provider id. * @return array */ public static function supported_field_types_for_provider( string $provider_id ): array { return self::JETENGINE_PROVIDER_ID === $provider_id ? self::JETENGINE_FIELD_TYPES : self::FIELD_TYPES; } /** * Return the provider that owns a registered post type slug, when known. */ public static function provider_owner_for_post_type( string $post_type ): string { $handler = new self( self::CREATE_NAME ); if ( $handler->acf_post_type_exists( $post_type ) ) { return self::ACF_PROVIDER_ID; } if ( is_array( $handler->jetengine_get_post_type( $post_type ) ) ) { return self::JETENGINE_PROVIDER_ID; } return ''; } /** * Return ACF-managed post type facts that affect schema/discovery freshness. * * @return array> */ public static function acf_post_type_shape_facts(): array { if ( ! function_exists( 'acf_get_acf_post_types' ) ) { return array(); } $post_types = acf_get_acf_post_types(); if ( ! is_array( $post_types ) ) { return array(); } $facts = array(); foreach ( $post_types as $post_type ) { if ( ! is_array( $post_type ) || ! isset( $post_type['post_type'] ) || ! is_scalar( $post_type['post_type'] ) ) { continue; } $name = (string) $post_type['post_type']; if ( '' === $name ) { continue; } $facts[ $name ] = self::acf_post_type_fingerprint_subset( $post_type ); } ksort( $facts ); return $facts; } /** * Return JetEngine-managed post type facts that affect schema/discovery freshness. * * @return array> */ public static function jetengine_post_type_shape_facts(): array { $facts = array(); foreach ( self::jetengine_post_types() as $post_type ) { if ( ! is_array( $post_type ) || ! isset( $post_type['slug'] ) || ! is_scalar( $post_type['slug'] ) ) { continue; } $slug = (string) $post_type['slug']; if ( '' === $slug ) { continue; } $facts[ $slug ] = array( 'id' => isset( $post_type['id'] ) && is_scalar( $post_type['id'] ) ? (string) $post_type['id'] : '', 'labels' => isset( $post_type['labels'] ) && is_array( $post_type['labels'] ) ? $post_type['labels'] : array(), 'args' => isset( $post_type['args'] ) && is_array( $post_type['args'] ) ? $post_type['args'] : array(), 'fields' => array_map( static fn ( array $field ): array => array( 'name' => isset( $field['name'] ) && is_scalar( $field['name'] ) ? (string) $field['name'] : '', 'type' => isset( $field['type'] ) && is_scalar( $field['type'] ) ? (string) $field['type'] : '', ), self::exposed_jetengine_fields_for_post_type( $slug ) ), ); } ksort( $facts ); return $facts; } /** * Return the ACF capability floor used for structural ACF changes. */ public static function acf_capability(): string { if ( function_exists( 'acf_get_setting' ) ) { $capability = acf_get_setting( 'capability' ); if ( is_scalar( $capability ) && '' !== (string) $capability ) { return (string) $capability; } } return 'manage_options'; } /** * Return ACF fields that are intentionally exposed through assistant post schemas. * * @param string $post_type Registered post type. * @return array> */ public static function exposed_acf_fields_for_post_type( string $post_type ): array { return self::acf_fields_for_post_type_filtered( $post_type, true ); } /** * Return ACF fields attached to one post type, optionally filtered to schema-exposed fields. * * @param string $post_type Registered post type. * @param bool $assistant_exposed Whether to require assistant/schema visibility. * @return array> */ private static function acf_fields_for_post_type_filtered( string $post_type, bool $assistant_exposed ): array { if ( ! function_exists( 'acf_get_field_groups' ) || ! function_exists( 'acf_get_fields' ) ) { return array(); } $groups = acf_get_field_groups( array( 'post_type' => $post_type ) ); if ( ! is_array( $groups ) || array() === $groups ) { $groups = acf_get_field_groups(); } if ( ! is_array( $groups ) ) { return array(); } $fields = array(); foreach ( $groups as $group ) { if ( ! is_array( $group ) || ! self::field_group_matches_post_type( $group, $post_type ) ) { continue; } if ( self::acf_false( $group['active'] ?? true ) ) { continue; } if ( $assistant_exposed && self::acf_false( $group['show_in_rest'] ?? true ) ) { continue; } $group_fields = acf_get_fields( $group ); if ( ! is_array( $group_fields ) ) { continue; } foreach ( $group_fields as $field ) { if ( ! is_array( $field ) || ! isset( $field['name'] ) || ! is_scalar( $field['name'] ) || '' === (string) $field['name'] ) { continue; } if ( $assistant_exposed && ! self::acf_field_is_assistant_exposed( $field, $group ) ) { continue; } $fields[] = $field; } } return $fields; } /** * Return one ACF field definition by name for a post type. */ public static function acf_field_for_post_type( string $post_type, string $name ): ?array { foreach ( self::exposed_acf_fields_for_post_type( $post_type ) as $field ) { if ( isset( $field['name'] ) && (string) $field['name'] === $name ) { return $field; } } return null; } /** * Convert an ACF field definition to the command schema value type. */ public static function schema_type_for_acf_field( array $field ): string { $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? (string) $field['type'] : 'text'; return match ( $type ) { 'number' => 'number', 'checkbox', 'gallery', 'repeater' => 'array', 'image' => 'integer', default => 'string', }; } /** * Read ACF field values for one post into the `acf` document namespace. */ public static function acf_values_for_post( int $post_id, string $post_type ): array { $values = array(); foreach ( self::exposed_acf_fields_for_post_type( $post_type ) as $field ) { $name = isset( $field['name'] ) && is_scalar( $field['name'] ) ? (string) $field['name'] : ''; if ( '' === $name ) { continue; } if ( function_exists( 'get_field' ) ) { $value = get_field( $name, $post_id, false ); } else { $value = get_post_meta( $post_id, $name, true ); } $values[ $name ] = $value; } return $values; } /** * Persist one ACF value through the provider value API. */ public static function update_acf_field_value( int $post_id, string $post_type, string $name, $value ): array { $field = self::acf_field_for_post_type( $post_type, $name ); if ( null === $field ) { return array( 'error' => array( 'code' => 'invalid_path', 'message' => 'ACF path is not registered for this post type.', ), ); } if ( ! function_exists( 'update_field' ) ) { return array( 'error' => array( 'code' => 'unsupported_capability', 'message' => 'ACF value writes are unavailable because update_field() is missing.', ), ); } $key = isset( $field['key'] ) && is_scalar( $field['key'] ) && '' !== (string) $field['key'] ? (string) $field['key'] : $name; $result = update_field( $key, $value, $post_id ); if ( false === $result ) { $current = function_exists( 'get_field' ) ? get_field( $key, $post_id, false ) : get_post_meta( $post_id, $name, true ); if ( $current === $value || ( is_scalar( $current ) && is_scalar( $value ) && (string) $current === (string) $value ) ) { return array( 'data' => array( 'updated' => false, 'noop' => true, ), ); } return array( 'error' => array( 'code' => 'execution_failed', 'message' => 'ACF failed to update the field value.', ), ); } return array( 'data' => array( 'updated' => true ) ); } /** * Return JetEngine fields intentionally exposed through assistant post schemas. * * @param string $post_type Registered post type. * @return array> */ public static function exposed_jetengine_fields_for_post_type( string $post_type ): array { $fields = array(); foreach ( self::jetengine_managed_meta_boxes_for_post_type( $post_type ) as $box ) { foreach ( self::jetengine_meta_fields_from_box( $box ) as $field ) { if ( ! is_array( $field ) || ! isset( $field['name'] ) || ! is_scalar( $field['name'] ) || '' === (string) $field['name'] ) { continue; } $fields[] = $field; } } return $fields; } /** * Return one JetEngine field definition by name for a post type. */ public static function jetengine_field_for_post_type( string $post_type, string $name ): ?array { foreach ( self::exposed_jetengine_fields_for_post_type( $post_type ) as $field ) { if ( isset( $field['name'] ) && (string) $field['name'] === $name ) { return $field; } } return null; } /** * Convert a JetEngine field definition to the command schema value type. */ public static function schema_type_for_jetengine_field( array $field ): string { $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? (string) $field['type'] : 'text'; return 'repeater' === $type ? 'array' : 'string'; } /** * Read JetEngine field values for one post into the `jetengine` document namespace. */ public static function jetengine_values_for_post( int $post_id, string $post_type ): array { $values = array(); foreach ( self::exposed_jetengine_fields_for_post_type( $post_type ) as $field ) { $name = isset( $field['name'] ) && is_scalar( $field['name'] ) ? (string) $field['name'] : ''; if ( '' === $name ) { continue; } $values[ $name ] = get_post_meta( $post_id, $name, true ); } return $values; } /** * Persist one JetEngine value through WordPress post meta, matching JetEngine post-field storage. */ public static function update_jetengine_field_value( int $post_id, string $post_type, string $name, $value ): array { $field = self::jetengine_field_for_post_type( $post_type, $name ); if ( null === $field ) { return array( 'error' => array( 'code' => 'invalid_path', 'message' => 'JetEngine path is not registered for this post type.', ), ); } update_post_meta( $post_id, $name, $value ); return array( 'data' => array( 'updated' => true ) ); } /** * Validate and optionally create a provider-backed post type. */ private function create( array $input, string $phase ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : null; if ( null === $values ) { return $this->error( 'invalid_command', 'wp.post_types.create requires a values object.', array( 'input', 'values' ) ); } $shape = $this->validate_create_values( $values ); if ( isset( $shape['error'] ) ) { return $shape; } $provider = $this->selected_provider( $input ); if ( isset( $provider['error'] ) ) { return $provider; } $provider_id = $this->executable_provider_id( $provider['provider'] ); if ( isset( $provider_id['error'] ) ) { return $provider_id; } $selected_provider = $provider_id['provider_id']; $post_type = (string) $shape['post_type']; if ( $this->provider_post_type_exists( $post_type ) || $this->registered_post_type_exists( $post_type ) ) { return $this->error( 'conflict', 'A post type with that slug already exists.', array( 'input', 'values', 'post_type' ) ); } $provider_values = $this->validate_provider_values( $selected_provider, $values, array( 'input', 'values' ) ); if ( isset( $provider_values['error'] ) ) { return $provider_values; } $field_semantics = $this->validate_field_upsert_semantics( $post_type, $values['fields'] ?? array(), array(), $this->field_parent_key( $selected_provider, $post_type, array() ), array( 'input', 'values', 'fields' ) ); if ( isset( $field_semantics['error'] ) ) { return $field_semantics; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true, 'provider' => $provider['provider']['id'], ), ); } if ( self::JETENGINE_PROVIDER_ID === $selected_provider ) { return $this->create_jetengine_post_type( $values, $post_type, $provider['provider'] ); } $acf_post_type = $this->post_type_settings_from_values( array(), $values, $post_type ); $saved = $this->acf_update_post_type( $acf_post_type ); if ( isset( $saved['error'] ) ) { return $saved; } $acf_post_type = $saved['post_type']; $this->register_post_type_immediately( $acf_post_type ); $field_result = $this->upsert_acf_fields( $post_type, $values['fields'] ?? array(), $acf_post_type ); if ( isset( $field_result['error'] ) ) { return $field_result; } return array( 'data' => array( 'created' => true, 'target' => $this->target_data( $post_type, $provider_id['provider_id'] ), 'fields' => $field_result['data'], 'provider_facts' => $provider['provider'], 'routing' => $this->routing_facts( true ), ), ); } /** * Validate and optionally update a provider-backed post type. */ private function update( array $input, string $phase ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target ) { return $this->error( 'invalid_target', 'wp.post_types.update requires target.post_type or target.name.', array( 'input', 'target' ) ); } $post_type = $this->post_type_from_target( $target ); if ( '' === $post_type ) { return $this->error( 'invalid_target', 'wp.post_types.update requires target.post_type or target.name.', array( 'input', 'target', 'post_type' ) ); } $provider = $this->selected_provider( $input ); if ( isset( $provider['error'] ) ) { return $provider; } $provider_id = $this->executable_provider_id( $provider['provider'] ); if ( isset( $provider_id['error'] ) ) { return $provider_id; } $selected_provider = $provider_id['provider_id']; $changes = isset( $input['changes'] ) && is_array( $input['changes'] ) ? array_values( $input['changes'] ) : null; if ( null === $changes || array() === $changes ) { return $this->error( 'invalid_command', 'wp.post_types.update requires a non-empty changes list.', array( 'input', 'changes' ) ); } $values = array(); $value_paths = array(); foreach ( $changes as $index => $change ) { $applied = $this->apply_change_to_values( $values, is_array( $change ) ? $change : array(), $index, $selected_provider ); if ( isset( $applied['error'] ) ) { return $applied; } $values = $applied['values']; if ( isset( $applied['dotted_path'] ) ) { $value_paths[ (string) $applied['dotted_path'] ] = array( 'input', 'changes', $index, 'value' ); } } $shape = $this->validate_update_values( $values, $value_paths ); if ( isset( $shape['error'] ) ) { return $shape; } $provider_values = $this->validate_provider_values( $selected_provider, $values, array( 'input', 'changes' ), $value_paths ); if ( isset( $provider_values['error'] ) ) { return $provider_values; } $existing = self::JETENGINE_PROVIDER_ID === $selected_provider ? $this->jetengine_get_post_type( $post_type ) : $this->acf_get_post_type( $post_type ); if ( ! is_array( $existing ) ) { return $this->error( 'not_found', 'No provider-managed post type exists for the requested target.', array( 'input', 'target', 'post_type' ) ); } $existing_fields = self::JETENGINE_PROVIDER_ID === $selected_provider ? $this->existing_actionpanel_jetengine_fields_by_name( $post_type ) : $this->existing_actionpanel_acf_fields_by_name( $post_type ); $field_semantics = $this->validate_field_upsert_semantics( $post_type, $values['fields'] ?? array(), $existing_fields, $this->field_parent_key( $selected_provider, $post_type, $existing ), $this->value_path( $value_paths, array( 'input', 'changes' ), array( 'fields' ) ) ); if ( isset( $field_semantics['error'] ) ) { return $field_semantics; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true, 'provider' => $provider['provider']['id'], ), ); } if ( self::JETENGINE_PROVIDER_ID === $selected_provider ) { return $this->update_jetengine_post_type( $existing, $values, $changes, $post_type, $provider['provider'] ); } $acf_post_type = $this->post_type_settings_from_values( $existing, $values, $post_type ); $saved = $this->acf_update_post_type( $acf_post_type ); if ( isset( $saved['error'] ) ) { return $saved; } $acf_post_type = $saved['post_type']; $this->register_post_type_immediately( $acf_post_type ); $field_result = $this->upsert_acf_fields( $post_type, $values['fields'] ?? array(), $acf_post_type ); if ( isset( $field_result['error'] ) ) { return $field_result; } return array( 'data' => array( 'updated' => true, 'target' => $this->target_data( $post_type, $provider_id['provider_id'] ), 'changed_paths' => $this->change_paths( $changes ), 'fields' => $field_result['data'], 'provider_facts' => $provider['provider'], 'routing' => $this->routing_facts( $this->rewrite_related_values_present( $values ) ), ), ); } /** * Validate create payload shape. */ private function validate_create_values( array $values ): array { $key_validation = $this->validate_post_type_value_keys( $values, array( 'input', 'values' ) ); if ( isset( $key_validation['error'] ) ) { return $key_validation; } $raw_post_type = isset( $values['post_type'] ) && is_scalar( $values['post_type'] ) ? (string) $values['post_type'] : ''; $post_type = sanitize_key( $raw_post_type ); if ( '' === $raw_post_type || '' === $post_type || ! preg_match( self::CPT_SLUG_PATTERN, $post_type ) || $post_type !== $raw_post_type ) { return $this->error( 'invalid_value', 'values.post_type must be a valid custom post type slug.', array( 'input', 'values', 'post_type' ) ); } if ( '' === $this->singular_label_from_values( $values ) ) { return $this->error( 'invalid_value', 'wp.post_types.create requires values.label or values.labels.singular_name.', array( 'input', 'values', 'label' ) ); } if ( '' === $this->plural_label_from_values( $values ) ) { return $this->error( 'invalid_value', 'wp.post_types.create requires values.plural_label or values.labels.name.', array( 'input', 'values', 'plural_label' ) ); } return $this->validate_post_type_values( $values, array( 'input', 'values' ) ) + array( 'post_type' => $post_type ); } /** * Validate update payload shape. */ private function validate_update_values( array $values, array $value_paths ): array { $key_validation = $this->validate_post_type_value_keys( $values, array( 'input', 'changes' ), $value_paths ); if ( isset( $key_validation['error'] ) ) { return $key_validation; } if ( isset( $values['post_type'] ) ) { return $this->error( 'invalid_path', 'Post type identity changes are not supported.', $this->value_path( $value_paths, array( 'input', 'changes' ), array( 'post_type' ) ) ); } return $this->validate_post_type_values( $values, array( 'input', 'changes' ), $value_paths ); } /** * Reject unknown top-level value keys. */ private function validate_post_type_value_keys( array $values, array $path, array $value_paths = array() ): array { foreach ( $values as $key => $_ ) { if ( ! in_array( (string) $key, self::POST_TYPE_ALLOWED_KEYS, true ) ) { return $this->error( 'invalid_command', 'Post type values contain an unknown key.', $this->value_path( $value_paths, $path, array( (string) $key ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate the common post type definition fields. */ private function validate_post_type_values( array $values, array $path, array $value_paths = array() ): array { if ( isset( $values['labels'] ) ) { if ( ! is_array( $values['labels'] ) ) { return $this->error( 'invalid_value', 'labels must be an object.', $this->value_path( $value_paths, $path, array( 'labels' ) ) ); } foreach ( $values['labels'] as $key => $value ) { if ( ! in_array( (string) $key, array( 'name', 'singular_name' ), true ) ) { return $this->error( 'invalid_command', 'labels may contain only name and singular_name.', $this->value_path( $value_paths, $path, array( 'labels', (string) $key ) ) ); } if ( ! is_string( $value ) || '' === $value ) { return $this->error( 'invalid_value', 'labels values must be non-empty strings.', $this->value_path( $value_paths, $path, array( 'labels', (string) $key ) ) ); } } } foreach ( array( 'label', 'plural_label', 'description', 'rest_base' ) as $key ) { if ( isset( $values[ $key ] ) && ! is_string( $values[ $key ] ) ) { return $this->error( 'invalid_value', $key . ' must be a string.', $this->value_path( $value_paths, $path, array( $key ) ) ); } } foreach ( array( 'public', 'hierarchical', 'show_ui', 'show_in_rest', 'has_archive' ) as $key ) { if ( isset( $values[ $key ] ) && ! is_bool( $values[ $key ] ) ) { return $this->error( 'invalid_value', $key . ' must be a boolean.', $this->value_path( $value_paths, $path, array( $key ) ) ); } } foreach ( array( 'supports', 'taxonomies' ) as $key ) { if ( isset( $values[ $key ] ) ) { $list_validation = $this->validate_string_list( $values[ $key ], $this->value_path( $value_paths, $path, array( $key ) ) ); if ( isset( $list_validation['error'] ) ) { return $list_validation; } } } if ( isset( $values['rewrite'] ) ) { $rewrite_validation = $this->validate_rewrite( $values['rewrite'], $this->value_path( $value_paths, $path, array( 'rewrite' ) ), $value_paths, $path ); if ( isset( $rewrite_validation['error'] ) ) { return $rewrite_validation; } } if ( isset( $values['provider_options'] ) ) { $options_validation = $this->validate_provider_options( $values['provider_options'], $this->value_path( $value_paths, $path, array( 'provider_options' ) ), $value_paths, $path ); if ( isset( $options_validation['error'] ) ) { return $options_validation; } } if ( isset( $values['fields'] ) ) { $fields_validation = $this->validate_fields( $values['fields'], $this->value_path( $value_paths, $path, array( 'fields' ) ), false ); if ( isset( $fields_validation['error'] ) ) { return $fields_validation; } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate fields whose support differs by provider. */ private function validate_provider_values( string $provider_id, array $values, array $path, array $value_paths = array() ): array { if ( isset( $values['provider_options'] ) && is_array( $values['provider_options'] ) ) { foreach ( $values['provider_options'] as $provider => $_ ) { if ( $provider_id !== (string) $provider ) { return $this->error( 'invalid_command', 'provider_options namespace does not match the selected provider.', $this->value_path( $value_paths, $path, array( 'provider_options', (string) $provider ) ) ); } } } if ( self::JETENGINE_PROVIDER_ID !== $provider_id ) { return array( 'data' => array( 'valid' => true ) ); } foreach ( array( 'description', 'rest_base', 'taxonomies' ) as $key ) { if ( array_key_exists( $key, $values ) ) { return $this->error( 'invalid_path', 'The selected JetEngine provider does not support this post type path in the verified contract.', $this->value_path( $value_paths, $path, array( $key ) ) ); } } foreach ( array( 'feeds', 'pages' ) as $rewrite_key ) { if ( isset( $values['rewrite'] ) && is_array( $values['rewrite'] ) && array_key_exists( $rewrite_key, $values['rewrite'] ) ) { return $this->error( 'invalid_path', 'The selected JetEngine provider does not support this rewrite path in the verified contract.', $this->value_path( $value_paths, $path, array( 'rewrite', $rewrite_key ) ) ); } } if ( isset( $values['fields'] ) ) { $field_validation = $this->validate_provider_fields( $values['fields'], $provider_id, $this->value_path( $value_paths, $path, array( 'fields' ) ) ); if ( isset( $field_validation['error'] ) ) { return $field_validation; } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate provider-specific field support recursively. */ private function validate_provider_fields( $fields, string $provider_id, array $path ): array { if ( ! is_array( $fields ) ) { return array( 'data' => array( 'valid' => true ) ); } $supported = self::supported_field_types_for_provider( $provider_id ); foreach ( array_values( $fields ) as $index => $field ) { if ( ! is_array( $field ) ) { continue; } $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? strtolower( (string) $field['type'] ) : ''; if ( ! in_array( $type, $supported, true ) ) { return $this->error( 'invalid_value', 'Field type is not supported by the selected provider.', array_merge( $path, array( $index, 'type' ) ), array( 'allowed_types' => $supported ) ); } if ( self::JETENGINE_PROVIDER_ID === $provider_id && array_key_exists( 'show_in_rest', $field ) && true !== $field['show_in_rest'] ) { return $this->error( 'invalid_value', 'JetEngine field definitions do not support hiding fields from the assistant schema in this slice.', array_merge( $path, array( $index, 'show_in_rest' ) ) ); } if ( 'repeater' === $type ) { $nested_validation = $this->validate_provider_fields( $field['fields'] ?? array(), $provider_id, array_merge( $path, array( $index, 'fields' ) ) ); if ( isset( $nested_validation['error'] ) ) { return $nested_validation; } } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate a rewrite object. */ private function validate_rewrite( $rewrite, array $path, array $value_paths = array(), array $base_path = array() ): array { if ( ! is_array( $rewrite ) ) { return $this->error( 'invalid_value', 'rewrite must be an object.', $path ); } foreach ( $rewrite as $key => $value ) { if ( ! in_array( (string) $key, array( 'slug', 'with_front', 'feeds', 'pages' ), true ) ) { return $this->error( 'invalid_command', 'rewrite contains an unknown key.', $this->value_path( $value_paths, $base_path, array( 'rewrite', (string) $key ) ) ); } if ( 'slug' === (string) $key ) { if ( ! is_string( $value ) || '' === $value ) { return $this->error( 'invalid_value', 'rewrite.slug must be a non-empty string.', $this->value_path( $value_paths, $base_path, array( 'rewrite', 'slug' ) ) ); } continue; } if ( ! is_bool( $value ) ) { return $this->error( 'invalid_value', 'rewrite booleans must be true or false.', $this->value_path( $value_paths, $base_path, array( 'rewrite', (string) $key ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate provider options. */ private function validate_provider_options( $options, array $path, array $value_paths = array(), array $base_path = array() ): array { if ( ! is_array( $options ) ) { return $this->error( 'invalid_value', 'provider_options must be an object.', $path ); } foreach ( $options as $provider => $provider_options ) { $provider_id = (string) $provider; if ( ! in_array( $provider_id, array( self::ACF_PROVIDER_ID, self::JETENGINE_PROVIDER_ID ), true ) ) { return $this->error( 'invalid_command', 'Unknown provider_options namespace.', $this->value_path( $value_paths, $base_path, array( 'provider_options', (string) $provider ) ) ); } if ( ! is_array( $provider_options ) ) { return $this->error( 'invalid_value', 'provider_options.' . $provider_id . ' must be an object.', $this->value_path( $value_paths, $base_path, array( 'provider_options', $provider_id ) ) ); } if ( array() !== $provider_options ) { return $this->error( 'invalid_command', 'provider_options.' . $provider_id . ' does not support any options in this slice.', $this->value_path( $value_paths, $base_path, array( 'provider_options', $provider_id ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate a string list. */ private function validate_string_list( $value, array $path ): array { if ( ! is_array( $value ) || array_values( $value ) !== $value ) { return $this->error( 'invalid_value', 'Value must be a list of strings.', $path ); } foreach ( $value as $index => $item ) { if ( ! is_string( $item ) || '' === $item ) { return $this->error( 'invalid_value', 'List items must be non-empty strings.', array_merge( $path, array( $index ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate choice field options before translating them to an ACF choices map. */ private function validate_choice_options( $options, array $path ): array { if ( ! is_array( $options ) || array() === $options ) { return $this->error( 'invalid_value', 'Choice field options must be a non-empty list or object.', $path ); } foreach ( $options as $key => $value ) { if ( is_int( $key ) ) { if ( ! is_scalar( $value ) || '' === (string) $value ) { return $this->error( 'invalid_value', 'Choice option values must be non-empty scalars.', array_merge( $path, array( $key ) ) ); } continue; } if ( '' === (string) $key || ! is_scalar( $value ) || '' === (string) $value ) { return $this->error( 'invalid_value', 'Choice option keys and labels must be non-empty scalars.', array_merge( $path, array( (string) $key ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate portable field definitions. */ private function validate_fields( $fields, array $path, bool $nested ): array { if ( ! is_array( $fields ) || array_values( $fields ) !== $fields ) { return $this->error( 'invalid_value', 'fields must be a list.', $path ); } foreach ( $fields as $index => $field ) { if ( ! is_array( $field ) ) { return $this->error( 'invalid_value', 'Field definitions must be objects.', array_merge( $path, array( $index ) ) ); } foreach ( $field as $key => $_ ) { if ( ! in_array( (string) $key, array( 'name', 'label', 'type', 'description', 'required', 'default', 'options', 'show_in_rest', 'fields' ), true ) ) { return $this->error( 'invalid_command', 'Field definition contains an unknown key.', array_merge( $path, array( $index, (string) $key ) ) ); } } $name = isset( $field['name'] ) && is_scalar( $field['name'] ) ? sanitize_key( (string) $field['name'] ) : ''; if ( '' === $name || (string) ( $field['name'] ?? '' ) !== $name ) { return $this->error( 'invalid_value', 'Field name must be a valid key.', array_merge( $path, array( $index, 'name' ) ) ); } if ( ! isset( $field['label'] ) || ! is_string( $field['label'] ) || '' === $field['label'] ) { return $this->error( 'invalid_value', 'Field label must be a non-empty string.', array_merge( $path, array( $index, 'label' ) ) ); } $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? strtolower( (string) $field['type'] ) : ''; if ( ! in_array( $type, self::FIELD_TYPES, true ) ) { return $this->error( 'invalid_value', 'Field type is not supported.', array_merge( $path, array( $index, 'type' ) ), array( 'allowed_types' => self::FIELD_TYPES ) ); } if ( $nested && 'repeater' === $type ) { return $this->error( 'invalid_value', 'Nested repeater fields are not supported.', array_merge( $path, array( $index, 'type' ) ) ); } if ( isset( $field['description'] ) && ! is_string( $field['description'] ) ) { return $this->error( 'invalid_value', 'Field description must be a string.', array_merge( $path, array( $index, 'description' ) ) ); } foreach ( array( 'required', 'show_in_rest' ) as $bool_key ) { if ( isset( $field[ $bool_key ] ) && ! is_bool( $field[ $bool_key ] ) ) { return $this->error( 'invalid_value', 'Field boolean settings must be true or false.', array_merge( $path, array( $index, $bool_key ) ) ); } } if ( in_array( $type, array( 'select', 'checkbox', 'radio' ), true ) && isset( $field['options'] ) ) { $options_validation = $this->validate_choice_options( $field['options'], array_merge( $path, array( $index, 'options' ) ) ); if ( isset( $options_validation['error'] ) ) { return $options_validation; } } if ( 'repeater' === $type ) { if ( ! isset( $field['fields'] ) ) { return $this->error( 'invalid_value', 'Repeater fields require a fields list.', array_merge( $path, array( $index, 'fields' ) ) ); } $nested_validation = $this->validate_fields( $field['fields'], array_merge( $path, array( $index, 'fields' ) ), true ); if ( isset( $nested_validation['error'] ) ) { return $nested_validation; } } elseif ( isset( $field['fields'] ) ) { return $this->error( 'invalid_value', 'Only repeater fields may contain nested fields.', array_merge( $path, array( $index, 'fields' ) ) ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate field semantics that depend on existing provider state. */ private function validate_field_upsert_semantics( string $post_type, $fields, array $existing, string $parent_key, array $path ): array { if ( null === $fields || array() === $fields ) { return array( 'data' => array( 'valid' => true ) ); } if ( ! is_array( $fields ) || array_values( $fields ) !== $fields ) { return $this->error( 'invalid_value', 'fields must be a list.', $path ); } foreach ( $fields as $index => $field ) { if ( ! is_array( $field ) ) { return $this->error( 'invalid_value', 'Field definitions must be objects.', array_merge( $path, array( $index ) ) ); } $name = isset( $field['name'] ) && is_scalar( $field['name'] ) ? (string) $field['name'] : ''; $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? strtolower( (string) $field['type'] ) : ''; $existing_field = $this->existing_field_for_upsert( $post_type, $parent_key, $name, $existing ); if ( in_array( $type, array( 'select', 'checkbox', 'radio' ), true ) && array() === $existing_field && ! array_key_exists( 'options', $field ) ) { return $this->error( 'invalid_value', 'New choice fields require non-empty options.', array_merge( $path, array( $index, 'options' ) ) ); } if ( 'repeater' !== $type ) { continue; } $existing_sub_fields = $this->fields_by_name( $existing_field['sub_fields'] ?? array() ); $nested_parent_key = isset( $existing_field['key'] ) && is_scalar( $existing_field['key'] ) ? (string) $existing_field['key'] : $this->field_key( $post_type, $parent_key, $name ); $nested_validation = $this->validate_field_upsert_semantics( $post_type, $field['fields'] ?? array(), $existing_sub_fields, $nested_parent_key, array_merge( $path, array( $index, 'fields' ) ) ); if ( isset( $nested_validation['error'] ) ) { return $nested_validation; } } return array( 'data' => array( 'valid' => true ) ); } /** * Return an existing field by name or deterministic key. */ private function existing_field_for_upsert( string $post_type, string $parent_key, string $name, array $existing ): array { if ( '' === $name ) { return array(); } if ( isset( $existing[ $name ] ) && is_array( $existing[ $name ] ) ) { return $existing[ $name ]; } if ( '' === $parent_key || ! function_exists( 'acf_get_field' ) ) { return array(); } $matched = acf_get_field( $this->field_key( $post_type, $parent_key, $name ) ); return is_array( $matched ) ? $matched : array(); } /** * Select an available provider for the command. */ private function selected_provider( array $input ): array { $requested = ''; $provider_path = array( 'input', 'values', 'provider' ); $has_provider = false; $provider_value = null; if ( isset( $input['values'] ) && is_array( $input['values'] ) && array_key_exists( 'provider', $input['values'] ) ) { $has_provider = true; $provider_value = $input['values']['provider']; } elseif ( isset( $input['target'] ) && is_array( $input['target'] ) && array_key_exists( 'provider', $input['target'] ) ) { $has_provider = true; $provider_value = $input['target']['provider']; $provider_path = array( 'input', 'target', 'provider' ); } if ( $has_provider ) { if ( ! is_scalar( $provider_value ) || '' === trim( (string) $provider_value ) ) { return $this->error( 'invalid_target', 'Post type provider must be a non-empty string when provided.', $provider_path ); } $requested = trim( (string) $provider_value ); } $providers = self::provider_statuses(); if ( '' !== $requested ) { foreach ( $providers as $provider ) { if ( $requested !== (string) $provider['id'] ) { continue; } if ( true !== ( $provider['writable'] ?? false ) ) { return $this->error( 'invalid_target', 'Requested post type provider is unavailable.', $provider_path, array( 'providers' => $providers ) ); } return array( 'provider' => $provider ); } return $this->error( 'invalid_target', 'Unknown post type provider.', $provider_path, array( 'providers' => $providers ) ); } $writable = array_values( array_filter( $providers, static fn ( array $provider ): bool => true === ( $provider['writable'] ?? false ) ) ); if ( 1 === count( $writable ) ) { return array( 'provider' => $writable[0] ); } if ( count( $writable ) > 1 ) { return $this->error( 'invalid_target', 'Multiple writable post type providers are available; specify provider.', array( 'input', 'values', 'provider' ), array( 'providers' => $providers ) ); } return $this->error( 'invalid_target', 'No writable post type provider is available.', array( 'input', 'values', 'provider' ), array( 'providers' => $providers ) ); } /** * Return an executable provider id for this implementation slice. */ private function executable_provider_id( array $provider ): array { $provider_id = isset( $provider['id'] ) && is_scalar( $provider['id'] ) ? (string) $provider['id'] : ''; if ( ! in_array( $provider_id, array( self::ACF_PROVIDER_ID, self::JETENGINE_PROVIDER_ID ), true ) ) { return $this->error( 'invalid_target', 'Selected post type provider is not implemented by this plugin version.', null, array( 'provider' => $provider ) ); } return array( 'provider_id' => $provider_id ); } /** * Apply one update change into a value object. */ private function apply_change_to_values( array $values, array $change, int $index, string $selected_provider ): array { foreach ( $change as $key => $_ ) { if ( ! in_array( (string) $key, array( 'op', 'path', 'value', 'provider' ), true ) ) { return $this->error( 'invalid_command', 'Change items may contain only op, path, value, and provider.', array( 'input', 'changes', $index, (string) $key ) ); } } $op = isset( $change['op'] ) && is_scalar( $change['op'] ) ? (string) $change['op'] : ''; if ( 'set' !== $op ) { return $this->error( 'invalid_command', 'Post type updates support only set changes.', array( 'input', 'changes', $index, 'op' ) ); } $path = isset( $change['path'] ) && is_array( $change['path'] ) ? array_values( $change['path'] ) : array(); if ( array() === $path || ! $this->is_path( $path ) ) { return $this->error( 'invalid_path', 'Change path must be a non-empty array-segment path.', array( 'input', 'changes', $index, 'path' ) ); } $dotted = implode( '.', array_map( 'strval', $path ) ); if ( ! isset( self::POST_TYPE_ALLOWED_PATHS[ $dotted ] ) ) { return $this->error( 'invalid_path', 'Path is not writable for wp.post_types in this slice.', array( 'input', 'changes', $index, 'path' ) ); } if ( ! array_key_exists( 'value', $change ) ) { return $this->error( 'invalid_value', 'set changes require a value.', array( 'input', 'changes', $index, 'value' ) ); } if ( array_key_exists( 'provider', $change ) && ( ! is_scalar( $change['provider'] ) || $selected_provider !== (string) $change['provider'] ) ) { return $this->error( 'invalid_path', 'provider does not match the selected post type provider.', array( 'input', 'changes', $index, 'provider' ) ); } return array( 'dotted_path' => $dotted, 'values' => $this->path_set( $values, $path, $change['value'] ), ); } /** * Create a JetEngine-backed post type through the verified REST contract. */ private function create_jetengine_post_type( array $values, string $post_type, array $provider_facts ): array { $payload = $this->jetengine_post_type_payload_from_values( array(), $values, $post_type ); $route = '/jet-engine/v2/add-post-type'; $saved = self::jetengine_rest_data( 'POST', $route, $payload ); if ( isset( $saved['error'] ) ) { return $saved; } $item_id = $saved['data']['item_id'] ?? null; if ( empty( $saved['data']['success'] ) ) { return $this->jetengine_response_error( $route, $saved['data'], 'JetEngine failed to create the post type.', 'provider_operation_failed' ); } if ( ! is_numeric( $item_id ) ) { return $this->jetengine_response_error( $route, $saved['data'], 'JetEngine failed to create the post type.', 'provider_contract_failed' ); } $payload['id'] = (int) $item_id; $payload['general_settings']['id'] = (int) $item_id; $this->register_jetengine_post_type_immediately( $payload ); $field_result = $this->upsert_jetengine_fields( $post_type, $values['fields'] ?? array(), $this->plural_label_from_values( $values ) ); if ( isset( $field_result['error'] ) ) { return $field_result; } return array( 'data' => array( 'created' => true, 'target' => $this->target_data( $post_type, self::JETENGINE_PROVIDER_ID ), 'fields' => $field_result['data'], 'provider_facts' => $provider_facts, 'routing' => $this->routing_facts( true ), ), ); } /** * Update a JetEngine-backed post type through the verified REST contract. */ private function update_jetengine_post_type( array $existing, array $values, array $changes, string $post_type, array $provider_facts ): array { $payload = $this->jetengine_post_type_payload_from_values( $existing, $values, $post_type ); $id = $this->jetengine_post_type_id( $existing ); if ( '' === $id ) { return $this->error( 'not_found', 'JetEngine post type id could not be resolved for the requested target.', array( 'input', 'target', 'post_type' ) ); } $updated_post_type = false; if ( $this->jetengine_values_include_post_type_settings( $values ) ) { $payload['id'] = is_numeric( $id ) ? (int) $id : $id; $payload['initial_slug'] = $post_type; $payload['update_posts'] = false; $route = '/jet-engine/v2/edit-post-type/' . rawurlencode( (string) $id ); $saved = self::jetengine_rest_data( 'POST', $route, $payload ); if ( isset( $saved['error'] ) ) { return $saved; } if ( empty( $saved['data']['success'] ) ) { return $this->jetengine_response_error( $route, $saved['data'], 'JetEngine failed to update the post type.', 'provider_operation_failed' ); } $this->register_jetengine_post_type_immediately( $payload ); $updated_post_type = true; } $field_result = $this->upsert_jetengine_fields( $post_type, $values['fields'] ?? array(), $this->jetengine_payload_plural_label( $payload ) ); if ( isset( $field_result['error'] ) ) { return $field_result; } return array( 'data' => array( 'updated' => true, 'target' => $this->target_data( $post_type, self::JETENGINE_PROVIDER_ID ), 'changed_paths' => $this->change_paths( $changes ), 'fields' => $field_result['data'], 'provider_facts' => $provider_facts, 'routing' => $this->routing_facts( $this->rewrite_related_values_present( $values ) ), ), ); } /** * Build the JetEngine REST payload from portable values. */ private function jetengine_post_type_payload_from_values( array $base, array $values, string $post_type ): array { $general = isset( $base['general_settings'] ) && is_array( $base['general_settings'] ) ? $base['general_settings'] : array(); $labels = isset( $base['labels'] ) && is_array( $base['labels'] ) ? $base['labels'] : array(); $advanced = isset( $base['advanced_settings'] ) && is_array( $base['advanced_settings'] ) ? $base['advanced_settings'] : $this->jetengine_default_advanced_settings( $post_type ); $general = array_merge( array( 'name' => $this->plural_label_from_values( $values ), 'slug' => $post_type, 'custom_storage' => false, 'show_edit_link' => true, 'hide_field_names' => false, 'delete_metadata' => false, ), $general ); $general['slug'] = $post_type; $singular = $this->singular_label_from_values( $values ); $plural = $this->plural_label_from_values( $values ); if ( '' !== $plural ) { $general['name'] = $plural; $labels['menu_name'] = $plural; } if ( '' !== $singular ) { $labels['singular_name'] = $singular; } foreach ( array( 'public', 'hierarchical', 'show_ui', 'show_in_rest', 'has_archive' ) as $key ) { if ( array_key_exists( $key, $values ) ) { $advanced[ $key ] = $values[ $key ]; } } if ( array_key_exists( 'public', $values ) ) { $advanced['publicly_queryable'] = (bool) $values['public']; $advanced['show_in_nav_menus'] = (bool) $values['public']; $advanced['exclude_from_search'] = ! (bool) $values['public']; } if ( array_key_exists( 'supports', $values ) ) { $advanced['supports'] = $values['supports']; } if ( isset( $values['rewrite'] ) && is_array( $values['rewrite'] ) ) { if ( array_key_exists( 'slug', $values['rewrite'] ) ) { $advanced['rewrite_slug'] = (string) $values['rewrite']['slug']; $advanced['rewrite'] = true; } if ( array_key_exists( 'with_front', $values['rewrite'] ) ) { $advanced['with_front'] = (bool) $values['rewrite']['with_front']; } } return array( 'general_settings' => $general, 'labels' => $labels, 'advanced_settings' => array_merge( $this->jetengine_default_advanced_settings( $post_type ), $advanced ), 'admin_columns' => isset( $base['admin_columns'] ) && is_array( $base['admin_columns'] ) ? $base['admin_columns'] : array(), 'admin_filters' => isset( $base['admin_filters'] ) && is_array( $base['admin_filters'] ) ? $base['admin_filters'] : array(), 'meta_fields' => isset( $base['meta_fields'] ) && is_array( $base['meta_fields'] ) ? $base['meta_fields'] : array(), ); } /** * Return default JetEngine advanced settings for the verified route payload. */ private function jetengine_default_advanced_settings( string $post_type ): array { return array( 'public' => true, 'exclude_from_search' => false, 'publicly_queryable' => true, 'show_ui' => true, 'show_in_menu' => true, 'show_in_nav_menus' => true, 'show_in_rest' => true, 'query_var' => true, 'rewrite' => true, 'with_front' => true, 'map_meta_cap' => true, 'has_archive' => true, 'hierarchical' => false, 'rewrite_slug' => $post_type, 'capability_type' => 'post', 'menu_position' => '20', 'menu_icon' => 'dashicons-admin-post', 'supports' => array( 'title', 'editor' ), ); } /** * Return whether an update payload has post type settings beyond field upserts. */ private function jetengine_values_include_post_type_settings( array $values ): bool { foreach ( array_keys( $values ) as $key ) { if ( ! in_array( (string) $key, array( 'fields', 'provider', 'provider_options' ), true ) ) { return true; } } return false; } /** * Register or refresh a JetEngine post type in the current request. */ private function register_jetengine_post_type_immediately( array $payload ): void { if ( ! function_exists( 'register_post_type' ) ) { return; } $general = isset( $payload['general_settings'] ) && is_array( $payload['general_settings'] ) ? $payload['general_settings'] : array(); $advanced = isset( $payload['advanced_settings'] ) && is_array( $payload['advanced_settings'] ) ? $payload['advanced_settings'] : array(); $labels = isset( $payload['labels'] ) && is_array( $payload['labels'] ) ? $payload['labels'] : array(); $post_type = isset( $general['slug'] ) && is_scalar( $general['slug'] ) ? (string) $general['slug'] : ''; if ( '' === $post_type ) { return; } register_post_type( $post_type, array( 'labels' => array( 'name' => (string) ( $general['name'] ?? ( $labels['menu_name'] ?? $post_type ) ), 'singular_name' => (string) ( $labels['singular_name'] ?? ( $general['name'] ?? $post_type ) ), ), 'label' => (string) ( $general['name'] ?? $post_type ), 'public' => (bool) ( $advanced['public'] ?? true ), 'hierarchical' => (bool) ( $advanced['hierarchical'] ?? false ), 'show_ui' => (bool) ( $advanced['show_ui'] ?? true ), 'show_in_rest' => (bool) ( $advanced['show_in_rest'] ?? true ), 'supports' => isset( $advanced['supports'] ) && is_array( $advanced['supports'] ) ? array_values( $advanced['supports'] ) : array( 'title', 'editor' ), 'has_archive' => (bool) ( $advanced['has_archive'] ?? true ), 'rewrite' => array( 'slug' => (string) ( $advanced['rewrite_slug'] ?? $post_type ), 'with_front' => (bool) ( $advanced['with_front'] ?? true ), ), 'capability_type' => 'post', ) ); } /** * Upsert the ActionPanel-managed JetEngine meta box for a post type. */ private function upsert_jetengine_fields( string $post_type, $fields, string $label ): array { if ( null === $fields || array() === $fields ) { return array( 'data' => array( 'upserted' => array(), 'created' => array(), ), ); } if ( ! is_array( $fields ) ) { return $this->error( 'invalid_value', 'fields must be a list.', array( 'input', 'values', 'fields' ) ); } $existing_box = $this->jetengine_managed_meta_box_for_post_type( $post_type ); $existing_fields = self::jetengine_meta_fields_from_box( $existing_box ?? array() ); $merged = $this->merge_jetengine_fields( $post_type, $fields, $existing_fields ); if ( isset( $merged['error'] ) ) { return $merged; } $settings = $this->jetengine_meta_box_settings( $post_type, '' !== $label ? $label : $post_type, $existing_box ?? array() ); $payload = array( 'general_settings' => $settings, 'meta_fields' => $merged['fields'], ); $id = is_array( $existing_box ) ? $this->jetengine_meta_box_id( $existing_box ) : ''; $route = '' === $id ? '/jet-engine/v2/add-meta-box' : '/jet-engine/v2/edit-meta-box/' . rawurlencode( $id ); if ( '' !== $id ) { $payload['id'] = $id; } $saved = self::jetengine_rest_data( 'POST', $route, $payload ); if ( isset( $saved['error'] ) ) { return $saved; } $item_id = $saved['data']['item_id'] ?? null; if ( empty( $saved['data']['success'] ) ) { return $this->jetengine_response_error( $route, $saved['data'], 'JetEngine failed to update the meta box.', 'provider_operation_failed' ); } if ( '' === $id && ! is_scalar( $item_id ) ) { return $this->jetengine_response_error( $route, $saved['data'], 'JetEngine failed to update the meta box.', 'provider_contract_failed' ); } return array( 'data' => array( 'upserted' => $merged['upserted'], 'created' => $merged['created'], ), ); } /** * Merge portable fields into existing JetEngine field definitions. */ private function merge_jetengine_fields( string $post_type, array $fields, array $existing_fields, string $parent_key = '' ): array { $existing_by_name = $this->fields_by_name( $existing_fields ); $merged_by_name = $existing_by_name; $order = array_keys( $existing_by_name ); $upserted = array(); $created = array(); foreach ( array_values( $fields ) as $field ) { if ( ! is_array( $field ) ) { continue; } $name = (string) $field['name']; $existing = $existing_by_name[ $name ] ?? array(); if ( array() === $existing ) { $created[] = $name; $order[] = $name; } $merged_by_name[ $name ] = $this->portable_field_to_jetengine( $field, $post_type, $parent_key, $existing ); $upserted[] = $name; } $merged = array(); foreach ( array_values( array_unique( $order ) ) as $name ) { if ( isset( $merged_by_name[ $name ] ) ) { $merged[] = $merged_by_name[ $name ]; } } return array( 'fields' => $merged, 'upserted' => $upserted, 'created' => $created, ); } /** * Convert one portable field definition to a JetEngine raw field array. */ private function portable_field_to_jetengine( array $field, string $post_type, string $parent_key, array $existing ): array { $name = (string) $field['name']; $type = strtolower( (string) $field['type'] ); $out = $existing; $out['title'] = (string) $field['label']; $out['name'] = $name; $out['object_type'] = 'field'; $out['width'] = isset( $out['width'] ) && is_scalar( $out['width'] ) ? (string) $out['width'] : '100%'; $out['type'] = $type; $out['id'] = isset( $out['id'] ) && is_numeric( $out['id'] ) ? (int) $out['id'] : $this->jetengine_field_id( $post_type, $parent_key, $name ); $out['isNested'] = '' !== $parent_key; $out['is_nested'] = '' !== $parent_key; $out['options'] = isset( $out['options'] ) && is_array( $out['options'] ) ? $out['options'] : array(); $out['quick_editable'] = true; $out['is_required'] = true === ( $field['required'] ?? false ); $out['default'] = array_key_exists( 'default', $field ) ? $field['default'] : ( $out['default'] ?? '' ); $out['description'] = isset( $field['description'] ) ? (string) $field['description'] : ( $out['description'] ?? '' ); $out['placeholder'] = isset( $out['placeholder'] ) && is_scalar( $out['placeholder'] ) ? (string) $out['placeholder'] : ''; $out['args'] = isset( $out['args'] ) && is_array( $out['args'] ) ? $out['args'] : array(); $out['conditions'] = isset( $out['conditions'] ) && is_array( $out['conditions'] ) ? $out['conditions'] : array(); if ( 'repeater' === $type ) { $nested_existing = self::jetengine_repeater_fields( $out ); $nested = $this->merge_jetengine_fields( $post_type, $field['fields'] ?? array(), $nested_existing, $name ); $out['repeater-fields'] = $nested['fields']; } else { $out['repeater-fields'] = array(); } return $out; } /** * Return a stable numeric id for a JetEngine field. */ private function jetengine_field_id( string $post_type, string $parent_key, string $name ): int { return ( abs( crc32( $post_type . ':' . $parent_key . ':' . $name ) ) % 900000 ) + 10000; } /** * Return the parent key used by cross-provider field validation. */ private function field_parent_key( string $provider_id, string $post_type, array $existing ): string { if ( self::JETENGINE_PROVIDER_ID === $provider_id ) { $id = $this->jetengine_meta_box_id( $existing ); return '' !== $id ? $id : self::jetengine_managed_meta_box_name( $post_type ); } return 'group_actionpanel_ai_' . $post_type; } /** * Build ACF post type settings from portable values. */ private function post_type_settings_from_values( array $base, array $values, string $post_type ): array { $settings = $base; $settings['key'] = $settings['key'] ?? 'post_type_' . $post_type; $settings['post_type'] = $post_type; $settings['title'] = $settings['title'] ?? $this->plural_label_from_values( $values ); $settings['active'] = true; $settings['labels'] = isset( $settings['labels'] ) && is_array( $settings['labels'] ) ? $settings['labels'] : array(); $singular = $this->singular_label_from_values( $values ); $plural = $this->plural_label_from_values( $values ); if ( '' !== $singular ) { $settings['labels']['singular_name'] = $singular; } if ( '' !== $plural ) { $settings['labels']['name'] = $plural; $settings['title'] = $plural; } foreach ( array( 'description', 'public', 'hierarchical', 'show_ui', 'show_in_rest', 'rest_base', 'supports', 'taxonomies', 'has_archive' ) as $key ) { if ( array_key_exists( $key, $values ) ) { $settings[ $key ] = $values[ $key ]; } } if ( isset( $values['rewrite'] ) && is_array( $values['rewrite'] ) ) { $settings['rewrite'] = isset( $settings['rewrite'] ) && is_array( $settings['rewrite'] ) ? $settings['rewrite'] : array(); foreach ( $values['rewrite'] as $key => $value ) { $settings['rewrite'][ $key ] = $value; } } return $settings; } /** * Upsert the field group and field definitions for a post type. */ private function upsert_acf_fields( string $post_type, $fields, array $acf_post_type ): array { if ( null === $fields || array() === $fields ) { return array( 'data' => array( 'upserted' => array(), 'created' => array(), ), ); } if ( ! is_array( $fields ) ) { return $this->error( 'invalid_value', 'fields must be a list.', array( 'input', 'values', 'fields' ) ); } $field_group = $this->ensure_acf_field_group( $post_type, $acf_post_type ); if ( isset( $field_group['error'] ) ) { return $field_group; } $group = $field_group['group']; $group_key = isset( $group['key'] ) && is_scalar( $group['key'] ) ? (string) $group['key'] : ''; $group_parent = $this->acf_field_group_parent( $group ); $existing = $this->existing_acf_fields_by_name( $group ); $upserted_names = array(); $created_names = array(); foreach ( array_values( $fields ) as $index => $field ) { if ( ! is_array( $field ) ) { return $this->error( 'invalid_value', 'Field definitions must be objects.', array( 'input', 'values', 'fields', $index ) ); } $name = (string) $field['name']; $existing_field = $existing[ $name ] ?? array(); if ( array() === $existing_field && '' !== $group_key && function_exists( 'acf_get_field' ) ) { $key_candidate = $this->field_key( $post_type, $group_key, $name ); $matched_by_key = acf_get_field( $key_candidate ); if ( is_array( $matched_by_key ) ) { $existing_field = $matched_by_key; } } if ( array() === $existing_field ) { $created_names[] = $name; } $type = isset( $field['type'] ) && is_scalar( $field['type'] ) ? strtolower( (string) $field['type'] ) : ''; if ( array() === $existing_field && in_array( $type, array( 'select', 'checkbox', 'radio' ), true ) && ! array_key_exists( 'options', $field ) ) { return $this->error( 'invalid_value', 'New choice fields require non-empty options.', array( 'input', 'values', 'fields', $index, 'options' ) ); } $acf_field = $this->portable_field_to_acf( $field, $post_type, $group_key, $group_parent, $existing_field, false ); $acf_field['menu_order'] = $index; $result = acf_update_field( $acf_field ); if ( false === $result || ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) ) { return $this->error( 'execution_failed', 'ACF failed to update a field definition.', array( 'input', 'values', 'fields', $index ) ); } $upserted_names[] = $name; } return array( 'data' => array( 'upserted' => $upserted_names, 'created' => $created_names, ), ); } /** * Ensure the deterministic ACF field group exists for the post type. */ private function ensure_acf_field_group( string $post_type, array $acf_post_type ): array { if ( ! function_exists( 'acf_get_field_group' ) || ! function_exists( 'acf_update_field_group' ) ) { return $this->error( 'unsupported_capability', 'ACF field group APIs are unavailable.' ); } $key = 'group_actionpanel_ai_' . $post_type; $existing = acf_get_field_group( $key ); $group = is_array( $existing ) ? $existing : array(); $title = isset( $acf_post_type['labels']['name'] ) && is_scalar( $acf_post_type['labels']['name'] ) ? (string) $acf_post_type['labels']['name'] : $post_type; $group['key'] = $key; $group['title'] = 'ActionPanel AI - ' . $title; $group['active'] = true; $group['show_in_rest'] = true; $group['location'] = array( array( array( 'param' => 'post_type', 'operator' => '==', 'value' => $post_type, ), ), ); $result = acf_update_field_group( $group ); if ( false === $result || ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) ) { return $this->error( 'execution_failed', 'ACF failed to update the field group.' ); } return array( 'group' => is_array( $result ) ? $result : $group ); } /** * Read existing ACF fields keyed by field name. */ private function existing_acf_fields_by_name( array $group ): array { if ( ! function_exists( 'acf_get_fields' ) ) { return array(); } $raw = acf_get_fields( $group ); if ( ! is_array( $raw ) ) { return array(); } $fields = array(); foreach ( $raw as $field ) { if ( is_array( $field ) && isset( $field['name'] ) && is_scalar( $field['name'] ) ) { $fields[ (string) $field['name'] ] = $field; } } return $fields; } /** * Read existing fields from ActionPanel's managed ACF group for a post type. */ private function existing_actionpanel_acf_fields_by_name( string $post_type ): array { if ( ! function_exists( 'acf_get_field_group' ) ) { return array(); } $group = acf_get_field_group( 'group_actionpanel_ai_' . $post_type ); return is_array( $group ) ? $this->existing_acf_fields_by_name( $group ) : array(); } /** * Read existing fields from ActionPanel's managed JetEngine meta box for a post type. */ private function existing_actionpanel_jetengine_fields_by_name( string $post_type ): array { $fields = self::jetengine_meta_fields_from_box( $this->jetengine_managed_meta_box_for_post_type( $post_type ) ?? array() ); $by_name = array(); foreach ( $fields as $field ) { if ( ! is_array( $field ) || ! isset( $field['name'] ) || ! is_scalar( $field['name'] ) ) { continue; } $normalized = $field; $normalized['key'] = isset( $field['id'] ) && is_scalar( $field['id'] ) ? (string) $field['id'] : ''; $normalized['sub_fields'] = self::jetengine_repeater_fields( $field ); $by_name[ (string) $field['name'] ] = $normalized; } return $by_name; } /** * Return the parent value ACF expects when saving top-level fields. */ private function acf_field_group_parent( array $group ): string { if ( isset( $group['ID'] ) && is_numeric( $group['ID'] ) && (int) $group['ID'] > 0 ) { return (string) (int) $group['ID']; } return isset( $group['key'] ) && is_scalar( $group['key'] ) ? (string) $group['key'] : ''; } /** * Convert one portable field definition to an ACF field array. */ private function portable_field_to_acf( array $field, string $post_type, string $key_parent, string $acf_parent, array $existing, bool $nested ): array { $name = (string) $field['name']; $type = strtolower( (string) $field['type'] ); $acf_field = $existing; $acf_field['key'] = $acf_field['key'] ?? $this->field_key( $post_type, $key_parent, $name ); $acf_field['name'] = $name; $acf_field['label'] = (string) $field['label']; $acf_field['type'] = $this->acf_type_for_portable_type( $type ); $acf_field['parent'] = $acf_parent; $acf_field['instructions'] = isset( $field['description'] ) ? (string) $field['description'] : ( $acf_field['instructions'] ?? '' ); if ( array_key_exists( 'required', $field ) || ! array_key_exists( 'required', $acf_field ) ) { $acf_field['required'] = true === ( $field['required'] ?? false ) ? 1 : 0; } if ( array_key_exists( 'show_in_rest', $field ) || ! array_key_exists( 'show_in_rest', $acf_field ) ) { $acf_field['show_in_rest'] = true === ( $field['show_in_rest'] ?? true ); } if ( array_key_exists( 'default', $field ) ) { $acf_field['default_value'] = $field['default']; } if ( in_array( $type, array( 'select', 'checkbox', 'radio' ), true ) ) { if ( array_key_exists( 'options', $field ) || ! array_key_exists( 'choices', $acf_field ) ) { $acf_field['choices'] = $this->choices_from_options( $field['options'] ?? array() ); } } else { unset( $acf_field['choices'] ); } if ( 'media' === $type ) { $acf_field['return_format'] = 'id'; } if ( 'gallery' === $type ) { $acf_field['return_format'] = 'id'; } if ( 'repeater' === $type ) { $existing_sub_fields = $this->fields_by_name( $acf_field['sub_fields'] ?? array() ); $sub_fields = array(); foreach ( array_values( $field['fields'] ?? array() ) as $index => $sub_field ) { if ( ! is_array( $sub_field ) ) { continue; } $sub_name = (string) $sub_field['name']; $sub_fields[] = $this->portable_field_to_acf( $sub_field, $post_type, (string) $acf_field['key'], (string) $acf_field['key'], $existing_sub_fields[ $sub_name ] ?? array(), true ); $sub_fields[ count( $sub_fields ) - 1 ]['menu_order'] = $index; } $acf_field['sub_fields'] = $sub_fields; } elseif ( ! $nested ) { unset( $acf_field['sub_fields'] ); } return $acf_field; } /** * Return fields keyed by name. */ private function fields_by_name( $fields ): array { if ( ! is_array( $fields ) ) { return array(); } $by_name = array(); foreach ( $fields as $field ) { if ( is_array( $field ) && isset( $field['name'] ) && is_scalar( $field['name'] ) ) { $by_name[ (string) $field['name'] ] = $field; } } return $by_name; } /** * Map portable field types to ACF field types. */ private function acf_type_for_portable_type( string $type ): string { return match ( $type ) { 'date' => 'date_picker', 'time' => 'time_picker', 'datetime' => 'date_time_picker', 'media' => 'image', default => $type, }; } /** * Convert choices from list/object to ACF's value => label map. */ private function choices_from_options( $options ): array { if ( ! is_array( $options ) ) { return array(); } $choices = array(); foreach ( $options as $key => $value ) { if ( is_int( $key ) ) { $choice_key = is_scalar( $value ) ? (string) $value : ''; $choices[ $choice_key ] = $choice_key; continue; } $choices[ (string) $key ] = is_scalar( $value ) ? (string) $value : (string) $key; } return $choices; } /** * Return a deterministic ACF field key. */ private function field_key( string $post_type, string $parent_key, string $name ): string { return 'field_' . substr( md5( $post_type . ':' . $parent_key . ':' . $name ), 0, 24 ); } /** * Save an ACF post type and normalize the result. */ private function acf_update_post_type( array $acf_post_type ): array { if ( ! function_exists( 'acf_update_post_type' ) ) { return $this->error( 'unsupported_capability', 'ACF post type APIs are unavailable.' ); } $result = acf_update_post_type( $acf_post_type ); if ( false === $result || ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) ) { return $this->error( 'execution_failed', 'ACF failed to update the post type definition.' ); } return array( 'post_type' => is_array( $result ) ? $result : $acf_post_type ); } /** * Read an ACF post type by slug. */ private function acf_get_post_type( string $post_type ) { if ( function_exists( 'acf_get_post_type' ) ) { foreach ( array( $post_type, 'post_type_' . $post_type ) as $identifier ) { $result = acf_get_post_type( $identifier ); if ( is_array( $result ) ) { return $result; } } } if ( function_exists( 'acf_get_acf_post_types' ) ) { $post_types = acf_get_acf_post_types(); if ( is_array( $post_types ) ) { foreach ( $post_types as $candidate ) { if ( is_array( $candidate ) && (string) ( $candidate['post_type'] ?? '' ) === $post_type ) { return $candidate; } } } } return null; } /** * Return whether ACF already owns a post type slug. */ private function acf_post_type_exists( string $post_type ): bool { return is_array( $this->acf_get_post_type( $post_type ) ); } /** * Return whether WordPress already has a registered post type slug. */ private function registered_post_type_exists( string $post_type ): bool { if ( function_exists( 'post_type_exists' ) ) { return post_type_exists( $post_type ); } if ( function_exists( 'get_post_type_object' ) ) { return null !== get_post_type_object( $post_type ); } return false; } /** * Register or refresh a saved post type for same-request content writes/schema reads. */ private function register_post_type_immediately( array $acf_post_type ): void { if ( ! function_exists( 'register_post_type' ) ) { return; } $post_type = isset( $acf_post_type['post_type'] ) && is_scalar( $acf_post_type['post_type'] ) ? (string) $acf_post_type['post_type'] : ''; if ( '' === $post_type ) { return; } register_post_type( $post_type, $this->wordpress_post_type_args( $acf_post_type ) ); } /** * Build WordPress register_post_type args from the ACF settings subset. */ private function wordpress_post_type_args( array $acf_post_type ): array { $labels = isset( $acf_post_type['labels'] ) && is_array( $acf_post_type['labels'] ) ? $acf_post_type['labels'] : array(); $rewrite = isset( $acf_post_type['rewrite'] ) && is_array( $acf_post_type['rewrite'] ) ? $acf_post_type['rewrite'] : array(); return array( 'labels' => $labels, 'label' => (string) ( $labels['name'] ?? ( $acf_post_type['title'] ?? $acf_post_type['post_type'] ?? '' ) ), 'description' => (string) ( $acf_post_type['description'] ?? '' ), 'public' => (bool) ( $acf_post_type['public'] ?? true ), 'hierarchical' => (bool) ( $acf_post_type['hierarchical'] ?? false ), 'show_ui' => (bool) ( $acf_post_type['show_ui'] ?? true ), 'show_in_rest' => (bool) ( $acf_post_type['show_in_rest'] ?? true ), 'rest_base' => isset( $acf_post_type['rest_base'] ) ? (string) $acf_post_type['rest_base'] : '', 'supports' => isset( $acf_post_type['supports'] ) && is_array( $acf_post_type['supports'] ) ? array_values( $acf_post_type['supports'] ) : array( 'title', 'editor' ), 'taxonomies' => isset( $acf_post_type['taxonomies'] ) && is_array( $acf_post_type['taxonomies'] ) ? array_values( $acf_post_type['taxonomies'] ) : array(), 'has_archive' => (bool) ( $acf_post_type['has_archive'] ?? false ), 'rewrite' => array( 'slug' => (string) ( $rewrite['slug'] ?? ( $acf_post_type['post_type'] ?? '' ) ), 'with_front' => (bool) ( $rewrite['with_front'] ?? true ), 'feeds' => (bool) ( $rewrite['feeds'] ?? false ), 'pages' => (bool) ( $rewrite['pages'] ?? true ), ), 'capability_type' => 'post', ); } /** * Return whether any known provider already owns a post type slug. */ private function provider_post_type_exists( string $post_type ): bool { return $this->acf_post_type_exists( $post_type ) || is_array( $this->jetengine_get_post_type( $post_type ) ); } /** * Build ACF availability facts. */ private static function acf_provider_status(): array { $installed = defined( 'ACF_VERSION' ) || function_exists( 'acf_get_setting' ); $version = defined( 'ACF_VERSION' ) ? (string) ACF_VERSION : ''; $required_functions = array( 'acf_get_post_type', 'acf_update_post_type', 'acf_get_field_group', 'acf_update_field_group', 'acf_get_fields', 'acf_update_field', ); $missing_functions = array_values( array_filter( $required_functions, static fn ( string $function_name ): bool => ! function_exists( $function_name ) ) ); $supported_version = '' !== $version && version_compare( $version, self::ACF_MIN_VERSION, '>=' ); $capability = self::acf_capability(); $allowed = current_user_can( $capability ); $reasons = array(); if ( ! $installed ) { $reasons[] = 'not_installed'; } if ( $installed && ! $supported_version ) { $reasons[] = 'unsupported_version'; } if ( array() !== $missing_functions ) { $reasons[] = 'missing_api'; } if ( ! $allowed ) { $reasons[] = 'wp_capability_denied'; } $writable = $installed && $supported_version && array() === $missing_functions && $allowed; return array( 'id' => self::ACF_PROVIDER_ID, 'label' => 'Advanced Custom Fields', 'installed' => $installed, 'version' => $version, 'min_version' => self::ACF_MIN_VERSION, 'writable' => $writable, 'available' => $writable, 'actions' => $writable ? array( 'create', 'update' ) : array(), 'wp_capability' => $capability, 'wp_capability_allowed' => $allowed, 'supported_field_types' => self::FIELD_TYPES, 'supported_paths' => self::acf_supported_paths(), 'unavailable_reasons' => $reasons, 'missing_functions' => $missing_functions, ); } /** * Return the post type paths supported by the ACF provider adapter. */ private static function acf_supported_paths(): array { return array_values( array_diff( array_keys( self::POST_TYPE_ALLOWED_PATHS ), array( 'provider_options.jetengine' ) ) ); } /** * Build JetEngine availability facts. */ private static function jetengine_provider_status(): array { $plugins = self::installed_plugins(); $installed = isset( $plugins[ self::JETENGINE_PLUGIN_FILE ] ) || function_exists( 'jet_engine' ) || class_exists( 'Jet_Engine' ); $active = function_exists( 'jet_engine' ) || class_exists( 'Jet_Engine' ) || ( function_exists( 'is_plugin_active' ) && is_plugin_active( self::JETENGINE_PLUGIN_FILE ) ); $version = self::jetengine_version( $plugins ); $capability = 'manage_options'; $allowed = current_user_can( $capability ); $missing_api = array(); if ( ! class_exists( 'WP_REST_Request' ) ) { $missing_api[] = 'WP_REST_Request'; } if ( ! function_exists( 'rest_do_request' ) ) { $missing_api[] = 'rest_do_request'; } if ( ! function_exists( 'rest_get_server' ) ) { $missing_api[] = 'rest_get_server'; } $route_check = array( 'missing_routes' => self::JETENGINE_REQUIRED_ROUTES, 'registered' => false, ); if ( $active && array() === $missing_api ) { $route_check = self::jetengine_required_route_status(); } $missing_routes = $route_check['missing_routes']; $reasons = array(); if ( ! $installed ) { $reasons[] = 'not_installed'; } if ( $installed && ! $active ) { $reasons[] = 'not_active'; } if ( array() !== $missing_api ) { $reasons[] = 'missing_api'; } if ( $active && array() === $missing_api && array() !== $missing_routes ) { $reasons[] = 'missing_provider_routes'; } if ( ! $allowed ) { $reasons[] = 'wp_capability_denied'; } $writable = $installed && $active && array() === $missing_api && array() === $missing_routes && $allowed; return array( 'id' => self::JETENGINE_PROVIDER_ID, 'label' => 'JetEngine', 'installed' => $installed, 'active' => $active, 'version' => $version, 'api_namespace' => self::JETENGINE_API_NAMESPACE, 'required_routes' => self::JETENGINE_REQUIRED_ROUTES, 'missing_routes' => $missing_routes, 'writable' => $writable, 'available' => $writable, 'actions' => $writable ? array( 'create', 'update' ) : array(), 'wp_capability' => $capability, 'wp_capability_allowed' => $allowed, 'supported_field_types' => self::JETENGINE_FIELD_TYPES, 'supported_paths' => array( 'post_type', 'label', 'plural_label', 'labels.name', 'labels.singular_name', 'public', 'hierarchical', 'show_ui', 'show_in_rest', 'supports', 'has_archive', 'rewrite.slug', 'rewrite.with_front', 'fields', 'provider_options.jetengine', ), 'unavailable_reasons' => $reasons, 'missing_api' => $missing_api, ); } /** * Return whether JetEngine's verified REST contract is available for reads. */ private static function jetengine_contract_available(): bool { $plugins = self::installed_plugins(); $installed = isset( $plugins[ self::JETENGINE_PLUGIN_FILE ] ) || function_exists( 'jet_engine' ) || class_exists( 'Jet_Engine' ); $active = function_exists( 'jet_engine' ) || class_exists( 'Jet_Engine' ) || ( function_exists( 'is_plugin_active' ) && is_plugin_active( self::JETENGINE_PLUGIN_FILE ) ); return $installed && $active && class_exists( 'WP_REST_Request' ) && function_exists( 'rest_do_request' ) && function_exists( 'rest_get_server' ) && array() === self::jetengine_required_route_status()['missing_routes']; } /** * Return missing routes from the verified JetEngine REST API contract. * * @return array{missing_routes: array, registered: bool} */ private static function jetengine_required_route_status(): array { $routes = array(); if ( function_exists( 'rest_get_server' ) ) { $server = rest_get_server(); if ( is_object( $server ) && is_callable( array( $server, 'get_routes' ) ) ) { $registered = $server->get_routes(); if ( is_array( $registered ) ) { $routes = array_keys( $registered ); } } } $missing = array(); foreach ( self::JETENGINE_REQUIRED_ROUTES as $name => $route ) { if ( ! self::jetengine_route_registered( $routes, $route ) ) { $missing[ $name ] = $route; } } return array( 'missing_routes' => $missing, 'registered' => array() !== $routes, ); } /** * Return whether a static or parameterized JetEngine route is registered. * * @param array $registered_routes Registered REST route patterns. */ private static function jetengine_route_registered( array $registered_routes, string $required_route ): bool { $parameterized = '/' === substr( $required_route, -1 ); foreach ( $registered_routes as $route ) { $route = rtrim( (string) $route, '/' ); if ( ! $parameterized && $route === $required_route ) { return true; } if ( $parameterized && 0 === strpos( $route . '/', $required_route ) ) { return true; } } return false; } /** * Return installed plugin metadata when WordPress exposes it. */ private static function installed_plugins(): array { if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) ) { $plugin_file = ABSPATH . 'wp-admin/includes/plugin.php'; if ( file_exists( $plugin_file ) ) { require_once $plugin_file; } } return function_exists( 'get_plugins' ) ? get_plugins() : array(); } /** * Return the active or installed JetEngine version. */ private static function jetengine_version( array $plugins ): string { if ( function_exists( 'jet_engine' ) ) { $instance = jet_engine(); if ( is_object( $instance ) && is_callable( array( $instance, 'get_version' ) ) ) { $version = $instance->get_version(); if ( is_scalar( $version ) && '' !== (string) $version ) { return (string) $version; } } } return isset( $plugins[ self::JETENGINE_PLUGIN_FILE ]['Version'] ) && is_scalar( $plugins[ self::JETENGINE_PLUGIN_FILE ]['Version'] ) ? (string) $plugins[ self::JETENGINE_PLUGIN_FILE ]['Version'] : ''; } /** * Read a JetEngine post type document by slug. */ private function jetengine_get_post_type( string $post_type ): ?array { foreach ( self::jetengine_post_types() as $item ) { if ( ! is_array( $item ) ) { continue; } $slug = isset( $item['slug'] ) && is_scalar( $item['slug'] ) ? (string) $item['slug'] : ''; if ( $slug !== $post_type ) { continue; } $id = isset( $item['id'] ) && is_scalar( $item['id'] ) ? (string) $item['id'] : ''; if ( '' === $id ) { return $item; } $response = self::jetengine_rest_data( 'GET', '/jet-engine/v2/get-post-type/' . rawurlencode( $id ), array( 'id' => $id ) ); if ( isset( $response['error'] ) || empty( $response['data']['success'] ) || ! is_array( $response['data']['data'] ?? null ) ) { return $item; } return $response['data']['data']; } return null; } /** * Read JetEngine post type list through the verified REST route. * * @return array */ private static function jetengine_post_types(): array { if ( ! self::jetengine_contract_available() ) { return array(); } $response = self::jetengine_rest_data( 'GET', '/jet-engine/v2/get-post-types' ); if ( isset( $response['error'] ) || empty( $response['data']['success'] ) || ! is_array( $response['data']['data'] ?? null ) ) { return array(); } return array_values( $response['data']['data'] ); } /** * Read ActionPanel-managed JetEngine meta boxes for a post type. * * @return array> */ private static function jetengine_managed_meta_boxes_for_post_type( string $post_type ): array { if ( ! self::jetengine_contract_available() ) { return array(); } $response = self::jetengine_rest_data( 'GET', '/jet-engine/v2/get-meta-boxes' ); if ( isset( $response['error'] ) || empty( $response['data']['success'] ) || ! is_array( $response['data']['data'] ?? null ) ) { return array(); } $boxes = array(); foreach ( $response['data']['data'] as $box ) { if ( is_array( $box ) && self::jetengine_meta_box_matches_post_type( $box, $post_type ) ) { $boxes[] = $box; } } return $boxes; } /** * Read the first ActionPanel-managed JetEngine meta box for a post type. */ private function jetengine_managed_meta_box_for_post_type( string $post_type ): ?array { $boxes = self::jetengine_managed_meta_boxes_for_post_type( $post_type ); return $boxes[0] ?? null; } /** * Return whether a JetEngine meta box is managed by this capability for a post type. */ private static function jetengine_meta_box_matches_post_type( array $box, string $post_type ): bool { $settings = self::jetengine_box_settings( $box ); $name = isset( $settings['name'] ) && is_scalar( $settings['name'] ) ? (string) $settings['name'] : ''; if ( self::jetengine_managed_meta_box_name( $post_type ) !== $name ) { return false; } return in_array( $post_type, self::jetengine_allowed_post_types( $settings ), true ); } /** * Return a managed JetEngine meta box name. */ private static function jetengine_managed_meta_box_name( string $post_type ): string { return 'ActionPanel AI - ' . $post_type; } /** * Build JetEngine meta box settings. */ private function jetengine_meta_box_settings( string $post_type, string $label, array $existing_box ): array { $settings = self::jetengine_box_settings( $existing_box ); return array_merge( $settings, array( 'name' => self::jetengine_managed_meta_box_name( $post_type ), 'object_type' => 'post', 'allowed_post_type' => array( $post_type ), 'show_edit_link' => true, 'hide_field_names' => false, 'delete_metadata' => false, 'active_conditions' => array(), 'title' => 'ActionPanel AI - ' . $label, ) ); } /** * Return JetEngine meta box settings from either list or get response shape. */ private static function jetengine_box_settings( array $box ): array { if ( isset( $box['general_settings'] ) && is_array( $box['general_settings'] ) ) { return $box['general_settings']; } return isset( $box['args'] ) && is_array( $box['args'] ) ? $box['args'] : array(); } /** * Return JetEngine meta fields from either list or get response shape. * * @return array> */ private static function jetengine_meta_fields_from_box( array $box ): array { return isset( $box['meta_fields'] ) && is_array( $box['meta_fields'] ) ? array_values( $box['meta_fields'] ) : array(); } /** * Return nested repeater fields from a JetEngine field. * * @return array> */ private static function jetengine_repeater_fields( array $field ): array { return isset( $field['repeater-fields'] ) && is_array( $field['repeater-fields'] ) ? array_values( $field['repeater-fields'] ) : array(); } /** * Return JetEngine allowed post type list from meta box settings. * * @return array */ private static function jetengine_allowed_post_types( array $settings ): array { $value = $settings['allowed_post_type'] ?? array(); if ( is_string( $value ) && '' !== $value ) { return array( $value ); } return is_array( $value ) ? array_values( array_map( 'strval', $value ) ) : array(); } /** * Return JetEngine post type id from a list or get response shape. */ private function jetengine_post_type_id( array $post_type ): string { if ( isset( $post_type['general_settings']['id'] ) && is_scalar( $post_type['general_settings']['id'] ) ) { return (string) $post_type['general_settings']['id']; } return isset( $post_type['id'] ) && is_scalar( $post_type['id'] ) ? (string) $post_type['id'] : ''; } /** * Return JetEngine meta box id from a list or get response shape. */ private function jetengine_meta_box_id( array $box ): string { if ( isset( $box['general_settings']['id'] ) && is_scalar( $box['general_settings']['id'] ) ) { return (string) $box['general_settings']['id']; } return isset( $box['id'] ) && is_scalar( $box['id'] ) ? (string) $box['id'] : ''; } /** * Return plural label from a JetEngine payload. */ private function jetengine_payload_plural_label( array $payload ): string { return isset( $payload['general_settings']['name'] ) && is_scalar( $payload['general_settings']['name'] ) ? (string) $payload['general_settings']['name'] : ''; } /** * Dispatch a JetEngine REST request internally and return decoded data. */ private static function jetengine_rest_data( string $method, string $route, array $params = array() ): array { if ( ! class_exists( 'WP_REST_Request' ) || ! function_exists( 'rest_do_request' ) ) { return array( 'error' => array( 'code' => 'unsupported_capability', 'message' => 'JetEngine REST dispatch is unavailable in this WordPress runtime.', 'details' => self::jetengine_error_details( $route, 'missing_wordpress_rest_api' ), ), ); } $request = new \WP_REST_Request( $method, $route ); foreach ( $params as $key => $value ) { $request->set_param( (string) $key, $value ); } $response = rest_do_request( $request ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { return array( 'error' => array( 'code' => 'execution_failed', 'message' => is_callable( array( $response, 'get_error_message' ) ) ? (string) $response->get_error_message() : 'JetEngine REST request failed.', 'details' => self::jetengine_error_details( $route, 'rest_dispatch_failed' ), ), ); } $data = is_object( $response ) && is_callable( array( $response, 'get_data' ) ) ? $response->get_data() : $response; if ( ! is_array( $data ) ) { return array( 'error' => array( 'code' => 'execution_failed', 'message' => 'JetEngine REST request returned an invalid response.', 'details' => self::jetengine_error_details( $route, 'provider_contract_failed' ), ), ); } return array( 'data' => $data ); } /** * Build an execution error for an unexpected JetEngine response. */ private function jetengine_response_error( string $route, array $data, string $fallback, string $reason ): array { return $this->error( 'execution_failed', self::jetengine_notice_message( $data, $fallback ), null, self::jetengine_error_details( $route, $reason, $data ) ); } /** * Build safe provider/route context for JetEngine execution failures. */ private static function jetengine_error_details( string $route, string $reason, array $data = array() ): array { $details = array( 'provider' => self::JETENGINE_PROVIDER_ID, 'api_namespace' => self::JETENGINE_API_NAMESPACE, 'route' => $route, 'reason' => $reason, ); foreach ( array( 'success', 'item_id', 'code', 'message' ) as $key ) { if ( array_key_exists( $key, $data ) && ( is_scalar( $data[ $key ] ) || null === $data[ $key ] ) ) { $details[ $key ] = $data[ $key ]; } } $notices = self::jetengine_notice_messages( $data ); if ( array() !== $notices ) { $details['notices'] = $notices; } return $details; } /** * Return the first JetEngine notice message, or a fallback. */ private static function jetengine_notice_message( array $data, string $fallback ): string { $notices = self::jetengine_notice_messages( $data ); if ( array() !== $notices ) { return $notices[0]; } return $fallback; } /** * Return scalar JetEngine notice messages. * * @return array */ private static function jetengine_notice_messages( array $data ): array { $messages = array(); $notices = isset( $data['notices'] ) && is_array( $data['notices'] ) ? $data['notices'] : array(); foreach ( $notices as $notice ) { if ( is_array( $notice ) && isset( $notice['message'] ) && is_scalar( $notice['message'] ) && '' !== (string) $notice['message'] ) { $messages[] = (string) $notice['message']; } } return $messages; } /** * Reduce ACF post type settings to stable fields that affect effective schema. */ private static function acf_post_type_fingerprint_subset( array $post_type ): array { $labels = isset( $post_type['labels'] ) && is_array( $post_type['labels'] ) ? $post_type['labels'] : array(); $rewrite = isset( $post_type['rewrite'] ) && is_array( $post_type['rewrite'] ) ? $post_type['rewrite'] : array(); return array( 'key' => isset( $post_type['key'] ) && is_scalar( $post_type['key'] ) ? (string) $post_type['key'] : '', 'title' => isset( $post_type['title'] ) && is_scalar( $post_type['title'] ) ? (string) $post_type['title'] : '', 'labels' => array( 'name' => isset( $labels['name'] ) && is_scalar( $labels['name'] ) ? (string) $labels['name'] : '', 'singular_name' => isset( $labels['singular_name'] ) && is_scalar( $labels['singular_name'] ) ? (string) $labels['singular_name'] : '', ), 'public' => (bool) ( $post_type['public'] ?? true ), 'hierarchical' => (bool) ( $post_type['hierarchical'] ?? false ), 'show_ui' => (bool) ( $post_type['show_ui'] ?? true ), 'show_in_rest' => (bool) ( $post_type['show_in_rest'] ?? true ), 'rest_base' => isset( $post_type['rest_base'] ) && is_scalar( $post_type['rest_base'] ) ? (string) $post_type['rest_base'] : '', 'supports' => isset( $post_type['supports'] ) && is_array( $post_type['supports'] ) ? array_values( array_map( 'strval', $post_type['supports'] ) ) : array(), 'taxonomies' => isset( $post_type['taxonomies'] ) && is_array( $post_type['taxonomies'] ) ? array_values( array_map( 'strval', $post_type['taxonomies'] ) ) : array(), 'has_archive' => (bool) ( $post_type['has_archive'] ?? false ), 'rewrite' => array( 'slug' => isset( $rewrite['slug'] ) && is_scalar( $rewrite['slug'] ) ? (string) $rewrite['slug'] : '', 'with_front' => (bool) ( $rewrite['with_front'] ?? true ), 'feeds' => (bool) ( $rewrite['feeds'] ?? false ), 'pages' => (bool) ( $rewrite['pages'] ?? true ), ), ); } /** * Return whether an ACF field group targets a post type. */ private static function field_group_matches_post_type( array $group, string $post_type ): bool { $location_groups = isset( $group['location'] ) && is_array( $group['location'] ) ? $group['location'] : array(); foreach ( $location_groups as $location_group ) { if ( ! is_array( $location_group ) ) { continue; } foreach ( $location_group as $rule ) { if ( ! is_array( $rule ) ) { continue; } if ( 'post_type' === (string) ( $rule['param'] ?? '' ) && (string) ( $rule['value'] ?? '' ) === $post_type && in_array( (string) ( $rule['operator'] ?? '==' ), array( '==', '=' ), true ) ) { return true; } } } return false; } /** * Return whether a scalar-ish ACF flag should be treated as false. */ private static function acf_false( $value ): bool { return false === $value || 0 === $value || '0' === $value || 'false' === $value; } /** * Return whether a field should be projected into assistant-visible post schemas. */ private static function acf_field_is_assistant_exposed( array $field, array $group ): bool { if ( self::acf_false( $field['active'] ?? true ) ) { return false; } if ( array_key_exists( 'show_in_rest', $field ) ) { return ! self::acf_false( $field['show_in_rest'] ); } if ( array_key_exists( 'show_in_rest', $group ) ) { return ! self::acf_false( $group['show_in_rest'] ); } return isset( $group['key'] ) && is_scalar( $group['key'] ) && str_starts_with( (string) $group['key'], 'group_actionpanel_ai_' ); } /** * Return the singular label from aliases. */ private function singular_label_from_values( array $values ): string { if ( isset( $values['label'] ) && is_scalar( $values['label'] ) ) { return (string) $values['label']; } return isset( $values['labels']['singular_name'] ) && is_scalar( $values['labels']['singular_name'] ) ? (string) $values['labels']['singular_name'] : ''; } /** * Return the plural label from aliases. */ private function plural_label_from_values( array $values ): string { if ( isset( $values['plural_label'] ) && is_scalar( $values['plural_label'] ) ) { return (string) $values['plural_label']; } return isset( $values['labels']['name'] ) && is_scalar( $values['labels']['name'] ) ? (string) $values['labels']['name'] : ''; } /** * Return normalized post type target from either target key. */ private function post_type_from_target( array $target ): string { $value = $target['post_type'] ?? ( $target['name'] ?? null ); if ( ! is_scalar( $value ) ) { return ''; } $post_type = sanitize_key( (string) $value ); return $post_type === (string) $value && preg_match( self::CPT_SLUG_PATTERN, $post_type ) ? $post_type : ''; } /** * Return write result target facts. */ private function target_data( string $post_type, string $provider ): array { return array( 'resource' => 'post_types', 'name' => $post_type, 'post_type' => $post_type, 'provider' => $provider, ); } /** * Return routing facts for rewrite-sensitive changes. */ private function routing_facts( bool $rewrite_flush_likely, bool $rewrite_flush_performed = false, string $performed_by = '' ): array { $facts = array( 'rewrite_flush_likely' => $rewrite_flush_likely, 'rewrite_flush_performed' => $rewrite_flush_performed, ); if ( $rewrite_flush_performed && '' !== $performed_by ) { $facts['rewrite_flush_performed_by'] = $performed_by; } return $facts; } /** * Return whether changed values affect rewrite routing. */ private function rewrite_related_values_present( array $values ): bool { foreach ( array( 'public', 'has_archive', 'rewrite' ) as $key ) { if ( array_key_exists( $key, $values ) ) { return true; } } return false; } /** * Return touched change paths. */ private function change_paths( array $changes ): array { $paths = array(); foreach ( $changes as $change ) { if ( is_array( $change ) && isset( $change['path'] ) && is_array( $change['path'] ) ) { $paths[] = array_values( $change['path'] ); } } return $paths; } /** * Return the original command value path for a merged update value. */ private function value_path( array $value_paths, array $base_path, array $segments ): array { $dotted = implode( '.', array_map( 'strval', $segments ) ); if ( isset( $value_paths[ $dotted ] ) ) { return $value_paths[ $dotted ]; } for ( $length = count( $segments ) - 1; $length > 0; $length-- ) { $prefix = implode( '.', array_map( 'strval', array_slice( $segments, 0, $length ) ) ); if ( isset( $value_paths[ $prefix ] ) ) { return array_merge( $value_paths[ $prefix ], array_slice( $segments, $length ) ); } } return array_merge( $base_path, $segments ); } /** * Return whether a value is a valid command path. */ private function is_path( array $path ): bool { foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } return true; } /** * Set a nested value in an array. */ private function path_set( array $document, array $path, $value ): array { $key = (string) $path[0]; if ( 1 === count( $path ) ) { $document[ $key ] = $value; return $document; } $child = isset( $document[ $key ] ) && is_array( $document[ $key ] ) ? $document[ $key ] : array(); $document[ $key ] = $this->path_set( $child, array_slice( $path, 1 ), $value ); return $document; } /** * Build a handler error. * * @param string $code Twelve-code error code. * @param string $message Human/LLM message. * @param array|null $path Error path. * @param array|null $details Extra actionable details. */ private function error( string $code, string $message, ?array $path = null, ?array $details = null ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( null !== $path ) { $error['path'] = $path; } if ( null !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }