diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 12a82447e..1426f5e42 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -55,6 +55,28 @@ public function register( $blocks ) { } + /** + * Registers this block's MCP abilities. + * + * @since 3.4.0 + * + * @param array $abilities Abilities to Register. + * @return array + */ + public function register_abilities( $abilities ) { + + return array_merge( + $abilities, + array( + new ConvertKit_MCP_Ability_Block_List( $this ), + new ConvertKit_MCP_Ability_Block_Insert( $this ), + new ConvertKit_MCP_Ability_Block_Update( $this ), + new ConvertKit_MCP_Ability_Block_Delete( $this ), + ) + ); + + } + /** * Returns this block's programmatic name, excluding the convertkit- prefix. * diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php new file mode 100644 index 000000000..67531683b --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -0,0 +1,135 @@ +-delete` (e.g. `kit/form-delete`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { + + /** + * Sets whether the ability is destructive. + * + * @since 3.4.0 + * + * @var bool + */ + private $destructive = true; // @phpstan-ignore-line + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'delete'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'Delete an existing %s block from a post', 'convertkit' ), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: 1: block full name e.g. convertkit/form, 2: block title */ + __( 'Removes a single occurrence of the %1$s (%2$s) block from the given post.', 'convertkit' ), + 'convertkit/' . $this->block->get_name(), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'occurrence_index' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'The zero-based occurrence index of the block to delete.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Get occurrence index. + $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0; + + // Delete block from post. + return ConvertKit_Block_Post_Helper::delete( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php new file mode 100644 index 000000000..c76b30edd --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -0,0 +1,139 @@ +-insert` (e.g. `kit/form-insert`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Insert extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'insert'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'Insert a %s block into a post', 'convertkit' ), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: 1: block full name e.g. convertkit/form, 2: block title */ + __( 'Inserts a new %1$s (%2$s) block into the given post\'s content. The block can be appended (default), prepended, or positioned relative to an existing block using a zero-based index.', 'convertkit' ), + 'convertkit/' . $this->block->get_name(), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Page / Post / Custom Post Type ID to insert the block into.', 'convertkit' ), + ), + 'position' => array( + 'type' => 'string', + 'enum' => array( 'append', 'prepend', 'index' ), + 'default' => 'append', + 'description' => __( 'Where to insert the new block. "index" requires the "index" property.', 'convertkit' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'When position is "index", the zero-based top-level block index at which to insert the new block.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Block attributes.', 'convertkit' ), + 'properties' => $this->get_input_schema_properties(), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Get attributes, position and index. + $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); + $position = isset( $input['position'] ) ? (string) $input['position'] : 'append'; + $index = isset( $input['index'] ) ? (int) $input['index'] : 0; + + // Insert block into post. + return ConvertKit_Block_Post_Helper::insert( $post_id, 'convertkit/' . $this->block->get_name(), $attrs, $position, $index ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php new file mode 100644 index 000000000..2d23b5ac8 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -0,0 +1,189 @@ +-list` (e.g. `kit/form-list`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { + + /** + * Sets whether the ability is readonly. + * + * @since 3.4.0 + * + * @var bool + */ + private $readonly = true; // @phpstan-ignore-line + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'list'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'List %s blocks in a post', 'convertkit' ), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: 1: block full name e.g. convertkit/form, 2: block title */ + __( 'Lists every occurrence of the %1$s (%2$s) block in the given post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), + 'convertkit/' . $this->block->get_name(), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post to inspect.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'count', 'occurrences' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'count' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'occurrences' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'required' => array( 'index', 'attrs' ), + 'properties' => array( + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Block attributes for this occurrence.', 'convertkit' ), + ), + ), + ), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Find blocks in post. + $occurrences = ConvertKit_Block_Post_Helper::find( $post_id, 'convertkit/' . $this->block->get_name() ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'count' => count( $occurrences ), + 'occurrences' => $occurrences, + ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php new file mode 100644 index 000000000..5f6cc3fc0 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -0,0 +1,144 @@ +-update` (e.g. `kit/form-update`). + * + * By default the provided attributes are merged into the existing attributes. + * Set `replace_all` to true to replace all attributes with the supplied set. + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Update extends ConvertKit_MCP_Ability_Block { + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'update'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'Update an existing %s block in a post', 'convertkit' ), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: 1: block full name e.g. convertkit/form, 2: block title */ + __( 'Updates the attributes of a single occurrence of the %1$s (%2$s) block in the given post. By default the provided attributes are merged into the existing attributes; set replace_all to true to replace them entirely.', 'convertkit' ), + 'convertkit/' . $this->block->get_name(), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Page / Post / Custom Post Type ID containing the existing block.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'The zero-based occurrence index of the block to update.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Block attributes to update. Any attributes not provided will be left unchanged.', 'convertkit' ), + 'properties' => $this->get_input_schema_properties(), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Get attributes, position and index. + $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); + $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0; + + // Update block into post. + return ConvertKit_Block_Post_Helper::update( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index, $attrs ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php new file mode 100644 index 000000000..f691e51f9 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -0,0 +1,187 @@ +block = $block; + + } + + /** + * Returns the ability name, derived from the block's name and the verb + * returned by get_verb(). + * + * For example, the Form block's insert ability would be named `kit/form-block-insert`. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'kit/' . $this->block->get_name() . '-block-' . $this->get_verb(); + + } + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_verb(); + + /** + * Only permit an ability to be executed if the current user can edit the given post. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Bail if the current user does not have permission to edit the post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_edit_post', + __( 'You do not have permission to edit this post.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'occurrence_index', 'index' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'description' => __( 'The Post/Page/Custom Post Type ID.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'description' => __( 'The zero-based occurrence index of the block in the post.', 'convertkit' ), + ), + 'index' => array( + 'type' => 'integer', + 'description' => __( 'The zero-based index of the block in the post.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Returns JSON Schema properties derived from the block's get_fields(), + * suitable for use as the `attrs` object in an Abilities API input schema. + * + * Used by verb subclasses whose input schema includes an `attrs` object + * (insert, update). + * + * @since 3.4.0 + * + * @return array + */ + protected function get_input_schema_properties() { + + // Define properties. + $properties = array(); + $fields = $this->block->get_fields(); + + foreach ( $fields as $field_name => $field ) { + $properties[ $field_name ] = array( + 'description' => isset( $field['label'] ) ? (string) $field['label'] : '', + 'type' => $this->get_input_schema_property_type( $field ), + ); + } + + return $properties; + + } + + /** + * Returns the JSON Schema type for the given field definition. + * + * @since 3.4.0 + * + * @param array $field Field definition. + * @return string + */ + private function get_input_schema_property_type( $field ) { + + $type = isset( $field['type'] ) ? (string) $field['type'] : 'string'; + + switch ( $type ) { + case 'resource': + return 'string'; + + case 'number': + return 'integer'; + + case 'toggle': + return 'boolean'; + + default: + return $type; + } + + } + +} diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php index 017b53c41..4cbf26ee6 100644 --- a/includes/mcp/class-convertkit-mcp-ability.php +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -125,10 +125,7 @@ abstract public function get_input_schema(); abstract public function get_output_schema(); /** - * Returns the MCP annotations for this ability. - * - * Defaults to a non-readonly, non-destructive, non-idempotent action. - * Subclasses override the returned array to set the appropriate hints. + * Define the annotations for the ability. * * @since 3.4.0 * diff --git a/wp-convertkit.php b/wp-convertkit.php index 55641a47a..43a8d9713 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -113,6 +113,11 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-product-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/pre-publish-actions/class-convertkit-pre-publish-action.php';