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/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php new file mode 100644 index 000000000..3fcaaab40 --- /dev/null +++ b/includes/blocks/helpers/class-convertkit-block-post-helper.php @@ -0,0 +1,301 @@ +post_content ); + $found = array(); + + $occurrence_index = 0; + + foreach ( $blocks as $index => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + $found[] = array( + 'index' => (int) $index, + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => $block['attrs'], + ); + + ++$occurrence_index; + } + + // If no blocks found, return false. + if ( empty( $found ) ) { + return false; + } + + return $found; + + } + + /** + * Inserts a new block into the Post's content at the specified position. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $block_name Programmatic Block Name. + * @param array $attrs Block Attributes. + * @param string $position One of 'prepend', 'append', 'index'. + * @param int $index Zero-based top-level block index; only used when $position is 'index'. + * @return WP_Error|array + */ + public static function insert( $post_id, $block_name, $attrs, $position = 'append', $index = 0 ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_helper_insert_block_post_not_found', + /* translators: %d: Post ID */ + sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Parse blocks. + $blocks = parse_blocks( $post->post_content ); + + // Build the new block to insert. + $new_block = array( + 'blockName' => $block_name, + 'attrs' => (array) $attrs, + 'innerBlocks' => array(), + 'innerHTML' => '', + 'innerContent' => array(), + ); + + // Resolve $position into a concrete zero-based splice point in the + // top-level block array. + switch ( $position ) { + case 'prepend': + $insert_at = 0; + break; + + case 'index': + $insert_at = max( 0, min( (int) $index, count( $blocks ) ) ); + break; + + case 'append': + default: + $insert_at = count( $blocks ); + break; + } + + // Splice in the new block. + array_splice( $blocks, $insert_at, 0, array( $new_block ) ); + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the index the block was inserted at. + return array( + 'post_id' => $post_id, + 'index' => $insert_at, + ); + + } + + /** + * Updates the attributes of an existing block in the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $block_name Programmatic Block Name. + * @param int $occurrence_index Position to update block. + * @param array $attrs Block Attributes. + * @return WP_Error|array + */ + public static function update( $post_id, $block_name, $occurrence_index, $attrs ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_helper_update_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Parse blocks. + $blocks = parse_blocks( $post->post_content ); + $update_at = 0; + $block_index = 0; + $matched = false; + + foreach ( $blocks as $key => $block ) { + ++$update_at; + + // Skip if the block name does not match. + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + // Update the block if the occurrence index matches. + if ( $block_index === (int) $occurrence_index ) { + $blocks[ $key ]['attrs'] = array_merge( (array) $block['attrs'], (array) $attrs ); + $matched = true; + break; + } + + ++$block_index; + } + + // Bail if the block was not found. + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_post_helper_occurrence_not_found', + /* translators: 1: block name, 2: occurrence index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $block_name, (int) $occurrence_index, $post_id ) + ); + } + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the index the block was updated at. + return array( + 'post_id' => $post_id, + 'index' => ( $update_at - 1 ), + ); + + } + + /** + * Deletes a specific block from the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $block_name Programmatic Block Name. + * @param int $occurrence_index Zero-based index among this block's occurrences in the post. + * @return WP_Error|array + */ + public static function delete( $post_id, $block_name, $occurrence_index ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_helper_update_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Parse blocks. + $blocks = parse_blocks( $post->post_content ); + $delete_at = 0; + $block_index = 0; + $matched = false; + + foreach ( $blocks as $key => $block ) { + ++$delete_at; + + // Skip if the block name does not match. + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + // Update the block if the occurrence index matches. + if ( $block_index === (int) $occurrence_index ) { + unset( $blocks[ $key ] ); + $blocks = array_values( $blocks ); + $matched = true; + break; + } + + ++$block_index; + } + + // Bail if the block was not found. + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_post_helper_occurrence_not_found', + /* translators: 1: block name, 2: occurrence index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $block_name, (int) $occurrence_index, $post_id ) + ); + } + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the index the block was deleted from. + return array( + 'post_id' => $post_id, + 'index' => ( $delete_at - 1 ), + ); + + } + +} 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/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php new file mode 100644 index 000000000..207a20d75 --- /dev/null +++ b/tests/Integration/BlockPostHelperTest.php @@ -0,0 +1,441 @@ +postID = $this->createPost(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Test that the find() method returns the correct block indicies and attributes. + * + * @since 3.4.0 + */ + public function testFind() + { + // Find the block. + $blocks = \ConvertKit_Block_Post_Helper::find( $this->postID, 'convertkit/form' ); + $this->assertIsArray( $blocks ); + $this->assertCount( 2, $blocks ); + + // Assert first matching block indicies and attributes are correct. + $this->assertEquals( $this->formBlockIndices[0], $blocks[0]['index'] ); + $this->assertEquals( 0, $blocks[0]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $blocks[0]['attrs']['form'] ); + + // Assert second matching block indicies and attributes are correct. + $this->assertEquals( $this->formBlockIndices[1], $blocks[1]['index'] ); + $this->assertEquals( 1, $blocks[1]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $blocks[1]['attrs']['form'] ); + } + + /** + * Test that the find() method returns false when no blocks match the given block name. + * + * @since 3.4.0 + */ + public function testFindWhenNoBlocksMatch() + { + $this->assertFalse(\ConvertKit_Block_Post_Helper::find( $this->postID, 'fake/block' )); + } + + /** + * Test that the find() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testFindWhenPostDoesNotExist() + { + $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Block_Post_Helper::find( 999999, 'convertkit/form' )); + } + + /** + * Test that the insert() method inserts a new block at the beginning of the content + * when the position is set to prepend. + * + * @since 3.4.0 + */ + public function testInsertPrepend() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'prepend' + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( 0, $result['index'] ); + } + + /** + * Test that the insert() method inserts a new block at the end of the content + * when the position is set to append. + * + * @since 3.4.0 + */ + public function testInsertAppend() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'append' + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( $this->totalBlocks + 1, $result['index'] ); + } + + /** + * Test that the insert() method inserts a new block at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndex() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 1 + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( 1, $result['index'] ); + } + + /** + * Test that the insert() method inserts a new block at end of the content when + * the index is out of bounds. + * + * @since 3.4.0 + */ + public function testInsertIndexOutOfBounds() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 100 + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( $this->totalBlocks + 1, $result['index'] ); + } + + /** + * Test that the insert() method inserts a new block at the beginning of the content when + * the index is negative. + * + * @since 3.4.0 + */ + public function testInsertIndexNegative() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: -1 + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( 0, $result['index'] ); + } + + /** + * Test that the insert() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testInsertWhenPostDoesNotExist() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: 999999, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 0 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the update() method updates the attributes of an existing block. + * + * @since 3.4.0 + */ + public function testUpdate() + { + $result = \ConvertKit_Block_Post_Helper::update( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 0, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( $this->formBlockIndices[0], $result['index'] ); + + $result = \ConvertKit_Block_Post_Helper::update( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 1, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( $this->formBlockIndices[1], $result['index'] ); + } + + /** + * Test that the update() method returns a WP_Error when the occurrence index is out of bounds. + * + * @since 3.4.0 + */ + public function testUpdateWhenOccurrenceIndexIsOutOfBounds() + { + $result = \ConvertKit_Block_Post_Helper::update( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 999, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the update() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testUpdateWhenPostDoesNotExist() + { + $result = \ConvertKit_Block_Post_Helper::update( + post_id: 999999, + block_name: 'convertkit/form', + occurrence_index: 0, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the delete() method deletes an existing block. + * + * @since 3.4.0 + */ + public function testDelete() + { + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 1 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( $this->formBlockIndices[1], $result['index'] ); + + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 0 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertEquals( $this->formBlockIndices[0], $result['index'] ); + } + + /** + * Test that the delete() method returns a WP_Error when the occurrence index is out of bounds. + * + * @since 3.4.0 + */ + public function testDeleteWhenOccurrenceIndexIsOutOfBounds() + { + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 999 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the delete() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testDeleteWhenPostDoesNotExist() + { + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: 999999, + block_name: 'convertkit/form', + occurrence_index: 0 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Mocks a post for testing. + * + * @since 3.4.0 + * @return int + */ + private function createPost() + { + // Create a Post with the given block. + return $this->factory->post->create( + [ + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Block Post', + 'post_content' => ' +
Item #1
+ + + +Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium
+ + + +Item #3
+ + + +Item #4
+ + + +Item #5
+ + + +