From 254d671df3b97562e016c9aa24b0631b9ff265c0 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 17:52:01 +0800 Subject: [PATCH 01/82] MCP + Abilities API: Registration Framework --- includes/blocks/class-convertkit-block.php | 341 ++++++++++++++++++ includes/class-wp-convertkit.php | 13 +- includes/functions.php | 24 ++ ...ss-convertkit-mcp-ability-block-delete.php | 189 ++++++++++ ...ss-convertkit-mcp-ability-block-insert.php | 224 ++++++++++++ ...lass-convertkit-mcp-ability-block-list.php | 194 ++++++++++ ...ss-convertkit-mcp-ability-block-update.php | 207 +++++++++++ .../class-convertkit-mcp-ability-block.php | 233 ++++++++++++ includes/mcp/class-convertkit-mcp-ability.php | 137 +++++++ includes/mcp/class-convertkit-mcp.php | 176 +++++++++ wp-convertkit.php | 8 + 11 files changed, 1740 insertions(+), 6 deletions(-) create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php create mode 100644 includes/mcp/class-convertkit-mcp-ability.php create mode 100644 includes/mcp/class-convertkit-mcp.php diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 12a82447e..b2ed81923 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -531,4 +531,345 @@ public function is_block_visible( $atts ) { } + /** + * Returns the block's full Gutenberg name (e.g. `convertkit/form`). + * + * @since 3.4.0 + * + * @return string + */ + public function get_full_block_name() { + + return 'convertkit/' . $this->get_name(); + + } + + /** + * Returns JSON Schema properties derived from this block's get_attributes() + * and get_fields(), suitable for use as the `attrs` object in an Abilities + * API input schema. + * + * Structural/styling attributes injected by Gutenberg (align, style, + * backgroundColor, textColor, className, is_gutenberg_example) are excluded + * so the agent-facing schema only covers block-specific attributes. + * + * Where possible, the schema is enriched using get_fields(): the field's + * `label` becomes the property description, and `resource`-type fields + * become an enum of valid IDs drawn from the corresponding resource class. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema_properties() { + + $properties = array(); + $fields = is_array( $this->get_fields() ) ? $this->get_fields() : array(); + + // JSON Schema type for each Gutenberg attribute type. + $type_map = array( + 'string' => 'string', + 'number' => 'integer', + 'boolean' => 'boolean', + 'object' => 'object', + 'array' => 'array', + ); + + // Attributes that are either provided by Gutenberg's own block supports + // or are internal-only. These should not appear in the agent-facing schema. + $skip_attrs = array( + 'align', + 'style', + 'backgroundColor', + 'textColor', + 'className', + 'is_gutenberg_example', + ); + + foreach ( $this->get_attributes() as $name => $definition ) { + if ( in_array( $name, $skip_attrs, true ) ) { + continue; + } + + $type = isset( $definition['type'] ) ? $definition['type'] : 'string'; + $json_type = isset( $type_map[ $type ] ) ? $type_map[ $type ] : 'string'; + $properties[ $name ] = array( 'type' => $json_type ); + + // Enrich from the field definition, if one exists. + if ( ! isset( $fields[ $name ] ) ) { + continue; + } + + $field = $fields[ $name ]; + + if ( ! empty( $field['label'] ) ) { + $properties[ $name ]['description'] = (string) $field['label']; + } + + // For resource-type fields, narrow the schema to a concrete list of + // valid IDs. This prevents agents from passing IDs that don't exist. + if ( isset( $field['type'] ) && $field['type'] === 'resource' && ! empty( $field['values'] ) && is_array( $field['values'] ) ) { + $ids = array_keys( $field['values'] ); + if ( ! empty( $ids ) ) { + // The attribute is typed as string in Gutenberg, but IDs are + // naturally integers. Preserve whatever the attribute's + // declared type is, and just cast enum values to match. + $properties[ $name ]['enum'] = array_map( + function ( $id ) use ( $json_type ) { + return $json_type === 'string' ? (string) $id : (int) $id; + }, + $ids + ); + } + } + } + + return $properties; + + } + + /** + * Finds all top-level occurrences of this block in the given post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @return array|WP_Error Array of ['index' => int, 'attrs' => array] entries, or WP_Error if the post is missing. + */ + public function find_blocks_in_post( $post_id ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + $full_name = $this->get_full_block_name(); + $found = array(); + + foreach ( $blocks as $index => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { + continue; + } + + $found[] = array( + 'index' => (int) $index, + 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), + ); + } + + return $found; + + } + + /** + * Inserts this block into the given post's content at the specified position. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param array $attrs Block attributes to set on the inserted block. + * @param string $position One of 'append', 'prepend', 'at_index'. + * @param int $index Zero-based index when $position is 'at_index'. + * @return array|WP_Error ['block_count' => int, 'position_used' => string] on success. + */ + public function insert_into_post( $post_id, $attrs, $position = 'append', $index = 0 ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + + $new_block = array( + 'blockName' => $this->get_full_block_name(), + 'attrs' => (array) $attrs, + 'innerBlocks' => array(), + 'innerHTML' => '', + 'innerContent' => array(), + ); + + switch ( $position ) { + case 'prepend': + array_unshift( $blocks, $new_block ); + break; + + case 'at_index': + $index = max( 0, min( (int) $index, count( $blocks ) ) ); + array_splice( $blocks, $index, 0, array( $new_block ) ); + break; + + case 'append': + default: + $blocks[] = $new_block; + $position = 'append'; + break; + } + + $updated = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + + return array( + 'block_count' => count( $blocks ), + 'position_used' => $position, + ); + + } + + /** + * Replaces the attributes of a specific top-level occurrence of this block + * in the given post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param int $target_index Zero-based index among this block's occurrences in the post (not the block-array index). + * @param array $attrs New attributes to apply. + * @param bool $merge If true, merge $attrs into the existing block attrs. If false, replace entirely. + * @return array|WP_Error + */ + public function replace_in_post( $post_id, $target_index, $attrs, $merge = true ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + $full_name = $this->get_full_block_name(); + $occurrence = 0; + $matched = false; + $final_attrs = array(); + + foreach ( $blocks as $key => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { + continue; + } + + if ( $occurrence === (int) $target_index ) { + $existing = isset( $block['attrs'] ) ? (array) $block['attrs'] : array(); + $final_attrs = $merge ? array_merge( $existing, (array) $attrs ) : (array) $attrs; + $blocks[ $key ]['attrs'] = $final_attrs; + $matched = true; + break; + } + + ++$occurrence; + } + + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_occurrence_not_found', + /* translators: 1: block name, 2: target index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) + ); + } + + $updated = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + + return array( + 'attrs' => $final_attrs, + ); + + } + + /** + * Deletes a specific top-level occurrence of this block from the given + * post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param int $target_index Zero-based index among this block's occurrences in the post. + * @return array|WP_Error + */ + public function delete_from_post( $post_id, $target_index ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + $full_name = $this->get_full_block_name(); + $occurrence = 0; + $matched = false; + + foreach ( $blocks as $key => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { + continue; + } + + if ( $occurrence === (int) $target_index ) { + unset( $blocks[ $key ] ); + $blocks = array_values( $blocks ); + $matched = true; + break; + } + + ++$occurrence; + } + + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_occurrence_not_found', + /* translators: 1: block name, 2: target index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) + ); + } + + $updated = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + + return array( + 'block_count' => count( $blocks ), + ); + + } + } diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 9e49cc858..63cfa5263 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -201,12 +201,13 @@ private function initialize_global() { $this->classes['broadcasts_importer'] = new ConvertKit_Broadcasts_Importer(); $this->classes['elementor'] = new ConvertKit_Elementor(); $this->classes['gutenberg'] = new ConvertKit_Gutenberg(); - $this->classes['media_library'] = new ConvertKit_Media_Library(); - $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content(); - $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH ); - $this->classes['preview_output'] = new ConvertKit_Preview_Output(); - $this->classes['setup'] = new ConvertKit_Setup(); - $this->classes['shortcodes'] = new ConvertKit_Shortcodes(); + $this->classes['mcp'] = new ConvertKit_MCP(); + $this->classes['media_library'] = new ConvertKit_Media_Library(); + $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content(); + $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH ); + $this->classes['preview_output'] = new ConvertKit_Preview_Output(); + $this->classes['setup'] = new ConvertKit_Setup(); + $this->classes['shortcodes'] = new ConvertKit_Shortcodes(); /** * Initialize integration classes for the frontend web site. diff --git a/includes/functions.php b/includes/functions.php index 42037ad75..590c340f1 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -307,6 +307,30 @@ function convertkit_get_form_importers() { } +/** + * Helper method to get registered abilities. + * + * @since 3.4.0 + * + * @return array Abilities. + */ +function convertkit_get_abilities() { + + $abilities = array(); + + /** + * Registers abilities for the Kit Plugin. + * + * @since 3.4.0 + * + * @param array $abilities Abilities. + */ + $abilities = apply_filters( 'convertkit_abilities', $abilities ); + + return $abilities; + +} + /** * Helper method to return the Plugin Settings Link * 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..87983b712 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -0,0 +1,189 @@ +-delete` (e.g. `kit/form-delete`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 a %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' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: destructive and not readonly; not idempotent, as repeated + * calls will attempt to delete sequential occurrences rather than a no-op. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, + ); + + } + + /** + * 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', 'target' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + ), + 'target' => $this->get_target_schema(), + ), + ); + + } + + /** + * 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', 'block', 'deleted_occurrence_index' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'deleted_occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the deleted block among this block\'s appearances in the post prior to deletion.', '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 target. + $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); + + // Resolve target. + $occurrence_index = $this->resolve_target( $post_id, $target ); + + // Bail if the target is not found. + if ( is_wp_error( $occurrence_index ) ) { + return $occurrence_index; + } + + // Delete block from post. + $result = $this->block->delete_from_post( $post_id, $occurrence_index ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'deleted_occurrence_index' => (int) $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..71dab6771 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -0,0 +1,224 @@ +-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 + */ + protected 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 occurrence by zero-based index.', 'convertkit' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: not readonly, not destructive, not idempotent + * (repeated calls insert additional blocks). + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ); + + } + + /** + * 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' => __( 'ID of the post to insert the block into.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Block attributes for the new occurrence.', 'convertkit' ), + 'properties' => $this->block->get_input_schema_properties(), + ), + 'position' => array( + 'type' => 'string', + 'enum' => array( 'append', 'prepend', 'at_index' ), + 'default' => 'append', + 'description' => __( 'Where to insert the new block. "at_index" requires the "index" property.', 'convertkit' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'When position is "at_index", the zero-based top-level block index at which to insert the new block.', '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', 'block', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the newly inserted block among this block\'s appearances in the post.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attributes of the newly inserted block.', '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 attributes. + $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. + $result = $this->block->insert_into_post( $post_id, $attrs, $position, $index ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Re-list occurrences to determine the newly inserted block's + // zero-based occurrence index among this block's appearances. + $occurrences = $this->block->find_blocks_in_post( $post_id ); + $occurrence_index = 0; + if ( is_array( $occurrences ) && count( $occurrences ) > 0 ) { + switch ( $position ) { + case 'prepend': + $occurrence_index = 0; + break; + + case 'at_index': + case 'append': + default: + // Find the first occurrence whose attrs match the just-inserted + // attrs; fall back to the last occurrence for 'append' and + // the first-after-$index for 'at_index'. + $occurrence_index = count( $occurrences ) - 1; + break; + } + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => $attrs, + ); + + } + +} 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..36c3f6358 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -0,0 +1,194 @@ +-list` (e.g. `kit/form-list`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: readonly + idempotent. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ); + + } + + /** + * 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', 'block', 'count', 'occurrences' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + '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 = $this->block->find_blocks_in_post( $post_id ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + '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..895c742ad --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -0,0 +1,207 @@ +-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 { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 a %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' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: not readonly, not destructive, idempotent + * (repeating the same update yields the same result). + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ); + + } + + /** + * 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', 'target', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + ), + 'target' => $this->get_target_schema(), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), + 'properties' => $this->block->get_input_schema_properties(), + ), + 'replace_all' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'If true, all existing attributes are replaced with the supplied set. If false (default), the supplied attributes are merged into the existing attributes.', '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', 'block', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the updated block.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attributes of the updated block.', '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 target. + $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); + $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); + $merge = ! ( isset( $input['replace_all'] ) && (bool) $input['replace_all'] ); + + // Resolve target. + $occurrence_index = $this->resolve_target( $post_id, $target ); + if ( is_wp_error( $occurrence_index ) ) { + return $occurrence_index; + } + + // Update block in post. + $result = $this->block->replace_in_post( $post_id, $occurrence_index, $attrs, $merge ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => isset( $result['attrs'] ) ? $result['attrs'] : $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..55b86ed62 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -0,0 +1,233 @@ +block = $block; + + } + + /** + * Returns the ability name, derived from the block's name and the verb + * returned by get_verb(). + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'kit/' . $this->block->get_name() . '-' . $this->get_verb(); + + } + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + abstract protected 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 JSON Schema fragment for a `target` object describing which + * occurrence of the block the ability should act on. Used by update/delete. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_target_schema() { + + return array( + 'type' => 'object', + 'description' => __( 'Identifies which occurrence of this block in the post to act on. Either by an attribute value match, or by zero-based occurrence index.', 'convertkit' ), + 'oneOf' => array( + array( + 'type' => 'object', + 'required' => array( 'by', 'attribute', 'value' ), + 'properties' => array( + 'by' => array( + 'type' => 'string', + 'enum' => array( 'attribute' ), + ), + 'attribute' => array( + 'type' => 'string', + 'description' => __( 'The block attribute name to match against (e.g. "form").', 'convertkit' ), + ), + 'value' => array( + 'description' => __( 'The value the attribute must match.', 'convertkit' ), + ), + ), + ), + array( + 'type' => 'object', + 'required' => array( 'by', 'index' ), + 'properties' => array( + 'by' => array( + 'type' => 'string', + 'enum' => array( 'index' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), + ), + ), + ), + ), + ); + + } + + /** + * Resolves a target descriptor into the zero-based occurrence index of the + * block in the post. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param array $target Target descriptor (see get_target_schema()). + * @return int|WP_Error Zero-based occurrence index, or WP_Error. + */ + protected function resolve_target( $post_id, $target ) { + + // Bail if target is not an array or does not have a 'by' key. + if ( ! is_array( $target ) || empty( $target['by'] ) ) { + return new WP_Error( + 'convertkit_mcp_invalid_target', + __( 'target.by is required.', 'convertkit' ) + ); + } + + // Find blocks in post. + $occurrences = $this->block->find_blocks_in_post( $post_id ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Bail if no blocks are found. + if ( empty( $occurrences ) ) { + return new WP_Error( + 'convertkit_mcp_no_block_occurrences', + /* translators: 1: block name, 2: post ID */ + sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), $this->block->get_full_block_name(), $post_id ) + ); + } + + // Resolve target. + switch ( $target['by'] ) { + case 'index': + $idx = isset( $target['index'] ) ? (int) $target['index'] : -1; + if ( $idx < 0 || $idx >= count( $occurrences ) ) { + return new WP_Error( + 'convertkit_mcp_target_index_out_of_range', + /* translators: 1: requested index, 2: number of occurrences */ + sprintf( __( 'Target index %1$d is out of range; post has %2$d occurrence(s).', 'convertkit' ), $idx, count( $occurrences ) ) + ); + } + return $idx; + + case 'attribute': + $attr = isset( $target['attribute'] ) ? (string) $target['attribute'] : ''; + $value = isset( $target['value'] ) ? $target['value'] : null; + if ( $attr === '' ) { + return new WP_Error( + 'convertkit_mcp_invalid_target', + __( 'target.attribute is required when target.by is "attribute".', 'convertkit' ) + ); + } + foreach ( $occurrences as $i => $occ ) { + if ( ! isset( $occ['attrs'][ $attr ] ) ) { + continue; + } + // Loose comparison so '123' == 123 resolves the same target, + // since Gutenberg attributes are often stringly typed. + if ( $occ['attrs'][ $attr ] == $value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return $i; + } + } + return new WP_Error( + 'convertkit_mcp_target_not_found', + /* translators: 1: attribute name, 2: value, 3: block name */ + sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), $this->block->get_full_block_name() ) + ); + + default: + return new WP_Error( + 'convertkit_mcp_invalid_target', + /* translators: %s: invalid 'by' value */ + sprintf( __( 'Unknown target.by value "%s". Expected "attribute" or "index".', 'convertkit' ), (string) $target['by'] ) + ); + } + + } + +} diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php new file mode 100644 index 000000000..e06170df1 --- /dev/null +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -0,0 +1,137 @@ + $this->get_label(), + 'description' => $this->get_description(), + 'category' => $this->get_category(), + 'input_schema' => $this->get_input_schema(), + 'output_schema' => $this->get_output_schema(), + 'permission_callback' => array( $this, 'permission_callback' ), + 'execute_callback' => array( $this, 'execute_callback' ), + 'meta' => array( + 'annotations' => $this->get_annotations(), + ), + ); + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_label(); + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_description(); + + /** + * Returns the ability's category. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_category(); + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + abstract public function get_input_schema(); + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + 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. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ); + + } + + /** + * Permission callback for this ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return bool|WP_Error + */ + abstract public function permission_callback( $input ); + + /** + * Execute callback for this ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + abstract public function execute_callback( $input ); + +} diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php new file mode 100644 index 000000000..e7ad2a6f5 --- /dev/null +++ b/includes/mcp/class-convertkit-mcp.php @@ -0,0 +1,176 @@ + __( 'Kit', 'convertkit' ), + 'description' => __( 'Abilities exposed by the Kit Plugin.', 'convertkit' ), + ) + ); + + } + + /** + * Register abilities with the WordPress Abilities API. + * + * @since 3.4.0 + */ + public function register_abilities() { + + // Get abilities. + $abilities = convertkit_get_abilities(); + + // Bail if no abilities are available. + if ( ! count( $abilities ) ) { + return; + } + + // Iterate through abilities, registering them. + foreach ( $abilities as $ability ) { + + // Skip if this ability is not an instance of ConvertKit_MCP_Ability. + if ( ! ( $ability instanceof ConvertKit_MCP_Ability ) ) { + continue; + } + + // Register ability. + wp_register_ability( $ability->get_name(), $ability->get_ability_args() ); + } + + } + + /** + * Register an MCP server that exposes Kit abilities as MCP tools. + * + * @since 3.4.0 + * + * @param object $adapter The MCP Adapter instance. + * @return void + */ + public function register_mcp_server( $adapter ) { + + // Bail if the adapter is not an object or does not have the create_server method. + if ( ! is_object( $adapter ) || ! method_exists( $adapter, 'create_server' ) ) { + return; + } + + // Get abilities. + $abilities = convertkit_get_abilities(); + + // Bail if no abilities are available. + if ( ! count( $abilities ) ) { + return; + } + + // Build array of ability names. + $ability_names = array(); + foreach ( $abilities as $ability ) { + $ability_names[] = $ability->get_name(); + } + + // Create the MCP server. + $adapter->create_server( + self::SERVER_ID, + self::SERVER_NAMESPACE, + self::SERVER_ROUTE, + __( 'Kit MCP', 'convertkit' ), + __( 'Exposes Kit Plugin abilities over the Model Context Protocol.', 'convertkit' ), + '1.0.0', + array( 'WP\\MCP\\Transport\\HttpTransport' ), + 'WP\\MCP\\Infrastructure\\ErrorHandling\\ErrorLogMcpErrorHandler', + 'WP\\MCP\\Infrastructure\\Observability\\NullMcpObservabilityHandler', + $ability_names, // Abilities (Tools). + array(), // Resources. + array() // Prompts. + ); + + } + +} diff --git a/wp-convertkit.php b/wp-convertkit.php index 34eabff92..3e4230a85 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -98,6 +98,14 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; 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/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/mcp/class-convertkit-mcp-ability-set-page-form.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.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'; From 6e52159fc3717b505ffe1402443d7a16864b1d59 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 18:01:15 +0800 Subject: [PATCH 02/82] Remove block abilities --- ...ss-convertkit-mcp-ability-block-delete.php | 189 -------------- ...ss-convertkit-mcp-ability-block-insert.php | 224 ----------------- ...lass-convertkit-mcp-ability-block-list.php | 194 --------------- ...ss-convertkit-mcp-ability-block-update.php | 207 ---------------- .../class-convertkit-mcp-ability-block.php | 233 ------------------ wp-convertkit.php | 6 - 6 files changed, 1053 deletions(-) delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php 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 deleted file mode 100644 index 87983b712..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ /dev/null @@ -1,189 +0,0 @@ --delete` (e.g. `kit/form-delete`). - * - * @package ConvertKit - * @author ConvertKit - */ -class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - protected 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 a %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' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: destructive and not readonly; not idempotent, as repeated - * calls will attempt to delete sequential occurrences rather than a no-op. - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => true, - 'idempotent' => false, - ); - - } - - /** - * 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', 'target' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - 'minimum' => 1, - 'description' => __( 'ID of the post containing the block.', 'convertkit' ), - ), - 'target' => $this->get_target_schema(), - ), - ); - - } - - /** - * 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', 'block', 'deleted_occurrence_index' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'deleted_occurrence_index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the deleted block among this block\'s appearances in the post prior to deletion.', '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 target. - $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); - - // Resolve target. - $occurrence_index = $this->resolve_target( $post_id, $target ); - - // Bail if the target is not found. - if ( is_wp_error( $occurrence_index ) ) { - return $occurrence_index; - } - - // Delete block from post. - $result = $this->block->delete_from_post( $post_id, $occurrence_index ); - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - 'deleted_occurrence_index' => (int) $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 deleted file mode 100644 index 71dab6771..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ /dev/null @@ -1,224 +0,0 @@ --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 - */ - protected 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 occurrence by zero-based index.', 'convertkit' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: not readonly, not destructive, not idempotent - * (repeated calls insert additional blocks). - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ); - - } - - /** - * 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' => __( 'ID of the post to insert the block into.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Block attributes for the new occurrence.', 'convertkit' ), - 'properties' => $this->block->get_input_schema_properties(), - ), - 'position' => array( - 'type' => 'string', - 'enum' => array( 'append', 'prepend', 'at_index' ), - 'default' => 'append', - 'description' => __( 'Where to insert the new block. "at_index" requires the "index" property.', 'convertkit' ), - ), - 'index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'When position is "at_index", the zero-based top-level block index at which to insert the new block.', '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', 'block', 'occurrence_index', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'occurrence_index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the newly inserted block among this block\'s appearances in the post.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attributes of the newly inserted block.', '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 attributes. - $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. - $result = $this->block->insert_into_post( $post_id, $attrs, $position, $index ); - if ( is_wp_error( $result ) ) { - return $result; - } - - // Re-list occurrences to determine the newly inserted block's - // zero-based occurrence index among this block's appearances. - $occurrences = $this->block->find_blocks_in_post( $post_id ); - $occurrence_index = 0; - if ( is_array( $occurrences ) && count( $occurrences ) > 0 ) { - switch ( $position ) { - case 'prepend': - $occurrence_index = 0; - break; - - case 'at_index': - case 'append': - default: - // Find the first occurrence whose attrs match the just-inserted - // attrs; fall back to the last occurrence for 'append' and - // the first-after-$index for 'at_index'. - $occurrence_index = count( $occurrences ) - 1; - break; - } - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - 'occurrence_index' => (int) $occurrence_index, - 'attrs' => $attrs, - ); - - } - -} 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 deleted file mode 100644 index 36c3f6358..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ /dev/null @@ -1,194 +0,0 @@ --list` (e.g. `kit/form-list`). - * - * @package ConvertKit - * @author ConvertKit - */ -class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - protected 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' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: readonly + idempotent. - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ); - - } - - /** - * 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', 'block', 'count', 'occurrences' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - '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 = $this->block->find_blocks_in_post( $post_id ); - if ( is_wp_error( $occurrences ) ) { - return $occurrences; - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - '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 deleted file mode 100644 index 895c742ad..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ /dev/null @@ -1,207 +0,0 @@ --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 { - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - protected 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 a %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' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: not readonly, not destructive, idempotent - * (repeating the same update yields the same result). - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ); - - } - - /** - * 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', 'target', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - 'minimum' => 1, - 'description' => __( 'ID of the post containing the block.', 'convertkit' ), - ), - 'target' => $this->get_target_schema(), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), - 'properties' => $this->block->get_input_schema_properties(), - ), - 'replace_all' => array( - 'type' => 'boolean', - 'default' => false, - 'description' => __( 'If true, all existing attributes are replaced with the supplied set. If false (default), the supplied attributes are merged into the existing attributes.', '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', 'block', 'occurrence_index', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'occurrence_index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the updated block.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attributes of the updated block.', '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 target. - $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); - $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); - $merge = ! ( isset( $input['replace_all'] ) && (bool) $input['replace_all'] ); - - // Resolve target. - $occurrence_index = $this->resolve_target( $post_id, $target ); - if ( is_wp_error( $occurrence_index ) ) { - return $occurrence_index; - } - - // Update block in post. - $result = $this->block->replace_in_post( $post_id, $occurrence_index, $attrs, $merge ); - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - 'occurrence_index' => (int) $occurrence_index, - 'attrs' => isset( $result['attrs'] ) ? $result['attrs'] : $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 deleted file mode 100644 index 55b86ed62..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ /dev/null @@ -1,233 +0,0 @@ -block = $block; - - } - - /** - * Returns the ability name, derived from the block's name and the verb - * returned by get_verb(). - * - * @since 3.4.0 - * - * @return string - */ - public function get_name() { - - return 'kit/' . $this->block->get_name() . '-' . $this->get_verb(); - - } - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - abstract protected 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 JSON Schema fragment for a `target` object describing which - * occurrence of the block the ability should act on. Used by update/delete. - * - * @since 3.4.0 - * - * @return array - */ - protected function get_target_schema() { - - return array( - 'type' => 'object', - 'description' => __( 'Identifies which occurrence of this block in the post to act on. Either by an attribute value match, or by zero-based occurrence index.', 'convertkit' ), - 'oneOf' => array( - array( - 'type' => 'object', - 'required' => array( 'by', 'attribute', 'value' ), - 'properties' => array( - 'by' => array( - 'type' => 'string', - 'enum' => array( 'attribute' ), - ), - 'attribute' => array( - 'type' => 'string', - 'description' => __( 'The block attribute name to match against (e.g. "form").', 'convertkit' ), - ), - 'value' => array( - 'description' => __( 'The value the attribute must match.', 'convertkit' ), - ), - ), - ), - array( - 'type' => 'object', - 'required' => array( 'by', 'index' ), - 'properties' => array( - 'by' => array( - 'type' => 'string', - 'enum' => array( 'index' ), - ), - 'index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), - ), - ), - ), - ), - ); - - } - - /** - * Resolves a target descriptor into the zero-based occurrence index of the - * block in the post. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param array $target Target descriptor (see get_target_schema()). - * @return int|WP_Error Zero-based occurrence index, or WP_Error. - */ - protected function resolve_target( $post_id, $target ) { - - // Bail if target is not an array or does not have a 'by' key. - if ( ! is_array( $target ) || empty( $target['by'] ) ) { - return new WP_Error( - 'convertkit_mcp_invalid_target', - __( 'target.by is required.', 'convertkit' ) - ); - } - - // Find blocks in post. - $occurrences = $this->block->find_blocks_in_post( $post_id ); - if ( is_wp_error( $occurrences ) ) { - return $occurrences; - } - - // Bail if no blocks are found. - if ( empty( $occurrences ) ) { - return new WP_Error( - 'convertkit_mcp_no_block_occurrences', - /* translators: 1: block name, 2: post ID */ - sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), $this->block->get_full_block_name(), $post_id ) - ); - } - - // Resolve target. - switch ( $target['by'] ) { - case 'index': - $idx = isset( $target['index'] ) ? (int) $target['index'] : -1; - if ( $idx < 0 || $idx >= count( $occurrences ) ) { - return new WP_Error( - 'convertkit_mcp_target_index_out_of_range', - /* translators: 1: requested index, 2: number of occurrences */ - sprintf( __( 'Target index %1$d is out of range; post has %2$d occurrence(s).', 'convertkit' ), $idx, count( $occurrences ) ) - ); - } - return $idx; - - case 'attribute': - $attr = isset( $target['attribute'] ) ? (string) $target['attribute'] : ''; - $value = isset( $target['value'] ) ? $target['value'] : null; - if ( $attr === '' ) { - return new WP_Error( - 'convertkit_mcp_invalid_target', - __( 'target.attribute is required when target.by is "attribute".', 'convertkit' ) - ); - } - foreach ( $occurrences as $i => $occ ) { - if ( ! isset( $occ['attrs'][ $attr ] ) ) { - continue; - } - // Loose comparison so '123' == 123 resolves the same target, - // since Gutenberg attributes are often stringly typed. - if ( $occ['attrs'][ $attr ] == $value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual - return $i; - } - } - return new WP_Error( - 'convertkit_mcp_target_not_found', - /* translators: 1: attribute name, 2: value, 3: block name */ - sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), $this->block->get_full_block_name() ) - ); - - default: - return new WP_Error( - 'convertkit_mcp_invalid_target', - /* translators: %s: invalid 'by' value */ - sprintf( __( 'Unknown target.by value "%s". Expected "attribute" or "index".', 'convertkit' ), (string) $target['by'] ) - ); - } - - } - -} diff --git a/wp-convertkit.php b/wp-convertkit.php index 3e4230a85..1cbedbc10 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -99,12 +99,6 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; 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/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/mcp/class-convertkit-mcp-ability-set-page-form.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.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'; From 1b4a5beef56efd82a3b093920cbc9ebe57fcb621 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 18:10:24 +0800 Subject: [PATCH 03/82] Remove block helpers --- includes/blocks/class-convertkit-block.php | 341 --------------------- 1 file changed, 341 deletions(-) diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index b2ed81923..12a82447e 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -531,345 +531,4 @@ public function is_block_visible( $atts ) { } - /** - * Returns the block's full Gutenberg name (e.g. `convertkit/form`). - * - * @since 3.4.0 - * - * @return string - */ - public function get_full_block_name() { - - return 'convertkit/' . $this->get_name(); - - } - - /** - * Returns JSON Schema properties derived from this block's get_attributes() - * and get_fields(), suitable for use as the `attrs` object in an Abilities - * API input schema. - * - * Structural/styling attributes injected by Gutenberg (align, style, - * backgroundColor, textColor, className, is_gutenberg_example) are excluded - * so the agent-facing schema only covers block-specific attributes. - * - * Where possible, the schema is enriched using get_fields(): the field's - * `label` becomes the property description, and `resource`-type fields - * become an enum of valid IDs drawn from the corresponding resource class. - * - * @since 3.4.0 - * - * @return array - */ - public function get_input_schema_properties() { - - $properties = array(); - $fields = is_array( $this->get_fields() ) ? $this->get_fields() : array(); - - // JSON Schema type for each Gutenberg attribute type. - $type_map = array( - 'string' => 'string', - 'number' => 'integer', - 'boolean' => 'boolean', - 'object' => 'object', - 'array' => 'array', - ); - - // Attributes that are either provided by Gutenberg's own block supports - // or are internal-only. These should not appear in the agent-facing schema. - $skip_attrs = array( - 'align', - 'style', - 'backgroundColor', - 'textColor', - 'className', - 'is_gutenberg_example', - ); - - foreach ( $this->get_attributes() as $name => $definition ) { - if ( in_array( $name, $skip_attrs, true ) ) { - continue; - } - - $type = isset( $definition['type'] ) ? $definition['type'] : 'string'; - $json_type = isset( $type_map[ $type ] ) ? $type_map[ $type ] : 'string'; - $properties[ $name ] = array( 'type' => $json_type ); - - // Enrich from the field definition, if one exists. - if ( ! isset( $fields[ $name ] ) ) { - continue; - } - - $field = $fields[ $name ]; - - if ( ! empty( $field['label'] ) ) { - $properties[ $name ]['description'] = (string) $field['label']; - } - - // For resource-type fields, narrow the schema to a concrete list of - // valid IDs. This prevents agents from passing IDs that don't exist. - if ( isset( $field['type'] ) && $field['type'] === 'resource' && ! empty( $field['values'] ) && is_array( $field['values'] ) ) { - $ids = array_keys( $field['values'] ); - if ( ! empty( $ids ) ) { - // The attribute is typed as string in Gutenberg, but IDs are - // naturally integers. Preserve whatever the attribute's - // declared type is, and just cast enum values to match. - $properties[ $name ]['enum'] = array_map( - function ( $id ) use ( $json_type ) { - return $json_type === 'string' ? (string) $id : (int) $id; - }, - $ids - ); - } - } - } - - return $properties; - - } - - /** - * Finds all top-level occurrences of this block in the given post's content. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @return array|WP_Error Array of ['index' => int, 'attrs' => array] entries, or WP_Error if the post is missing. - */ - public function find_blocks_in_post( $post_id ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - $full_name = $this->get_full_block_name(); - $found = array(); - - foreach ( $blocks as $index => $block ) { - if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { - continue; - } - - $found[] = array( - 'index' => (int) $index, - 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), - ); - } - - return $found; - - } - - /** - * Inserts this block into the given post's content at the specified position. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param array $attrs Block attributes to set on the inserted block. - * @param string $position One of 'append', 'prepend', 'at_index'. - * @param int $index Zero-based index when $position is 'at_index'. - * @return array|WP_Error ['block_count' => int, 'position_used' => string] on success. - */ - public function insert_into_post( $post_id, $attrs, $position = 'append', $index = 0 ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - - $new_block = array( - 'blockName' => $this->get_full_block_name(), - 'attrs' => (array) $attrs, - 'innerBlocks' => array(), - 'innerHTML' => '', - 'innerContent' => array(), - ); - - switch ( $position ) { - case 'prepend': - array_unshift( $blocks, $new_block ); - break; - - case 'at_index': - $index = max( 0, min( (int) $index, count( $blocks ) ) ); - array_splice( $blocks, $index, 0, array( $new_block ) ); - break; - - case 'append': - default: - $blocks[] = $new_block; - $position = 'append'; - break; - } - - $updated = wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => serialize_blocks( $blocks ), - ), - true - ); - - if ( is_wp_error( $updated ) ) { - return $updated; - } - - return array( - 'block_count' => count( $blocks ), - 'position_used' => $position, - ); - - } - - /** - * Replaces the attributes of a specific top-level occurrence of this block - * in the given post's content. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param int $target_index Zero-based index among this block's occurrences in the post (not the block-array index). - * @param array $attrs New attributes to apply. - * @param bool $merge If true, merge $attrs into the existing block attrs. If false, replace entirely. - * @return array|WP_Error - */ - public function replace_in_post( $post_id, $target_index, $attrs, $merge = true ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - $full_name = $this->get_full_block_name(); - $occurrence = 0; - $matched = false; - $final_attrs = array(); - - foreach ( $blocks as $key => $block ) { - if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { - continue; - } - - if ( $occurrence === (int) $target_index ) { - $existing = isset( $block['attrs'] ) ? (array) $block['attrs'] : array(); - $final_attrs = $merge ? array_merge( $existing, (array) $attrs ) : (array) $attrs; - $blocks[ $key ]['attrs'] = $final_attrs; - $matched = true; - break; - } - - ++$occurrence; - } - - if ( ! $matched ) { - return new WP_Error( - 'convertkit_block_occurrence_not_found', - /* translators: 1: block name, 2: target index, 3: post ID */ - sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) - ); - } - - $updated = wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => serialize_blocks( $blocks ), - ), - true - ); - - if ( is_wp_error( $updated ) ) { - return $updated; - } - - return array( - 'attrs' => $final_attrs, - ); - - } - - /** - * Deletes a specific top-level occurrence of this block from the given - * post's content. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param int $target_index Zero-based index among this block's occurrences in the post. - * @return array|WP_Error - */ - public function delete_from_post( $post_id, $target_index ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - $full_name = $this->get_full_block_name(); - $occurrence = 0; - $matched = false; - - foreach ( $blocks as $key => $block ) { - if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { - continue; - } - - if ( $occurrence === (int) $target_index ) { - unset( $blocks[ $key ] ); - $blocks = array_values( $blocks ); - $matched = true; - break; - } - - ++$occurrence; - } - - if ( ! $matched ) { - return new WP_Error( - 'convertkit_block_occurrence_not_found', - /* translators: 1: block name, 2: target index, 3: post ID */ - sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) - ); - } - - $updated = wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => serialize_blocks( $blocks ), - ), - true - ); - - if ( is_wp_error( $updated ) ) { - return $updated; - } - - return array( - 'block_count' => count( $blocks ), - ); - - } - } From fd2b5202a5e7d6e69eb6c69c3dd563bf45f292cb Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 18:14:43 +0800 Subject: [PATCH 04/82] Abillities API: Block Framework --- ...ss-convertkit-mcp-ability-block-delete.php | 189 ++++++++++++++ ...ss-convertkit-mcp-ability-block-insert.php | 224 +++++++++++++++++ ...lass-convertkit-mcp-ability-block-list.php | 194 +++++++++++++++ ...ss-convertkit-mcp-ability-block-update.php | 207 ++++++++++++++++ .../class-convertkit-mcp-ability-block.php | 233 ++++++++++++++++++ 5 files changed, 1047 insertions(+) create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php 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..87983b712 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -0,0 +1,189 @@ +-delete` (e.g. `kit/form-delete`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 a %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' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: destructive and not readonly; not idempotent, as repeated + * calls will attempt to delete sequential occurrences rather than a no-op. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, + ); + + } + + /** + * 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', 'target' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + ), + 'target' => $this->get_target_schema(), + ), + ); + + } + + /** + * 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', 'block', 'deleted_occurrence_index' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'deleted_occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the deleted block among this block\'s appearances in the post prior to deletion.', '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 target. + $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); + + // Resolve target. + $occurrence_index = $this->resolve_target( $post_id, $target ); + + // Bail if the target is not found. + if ( is_wp_error( $occurrence_index ) ) { + return $occurrence_index; + } + + // Delete block from post. + $result = $this->block->delete_from_post( $post_id, $occurrence_index ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'deleted_occurrence_index' => (int) $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..71dab6771 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -0,0 +1,224 @@ +-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 + */ + protected 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 occurrence by zero-based index.', 'convertkit' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: not readonly, not destructive, not idempotent + * (repeated calls insert additional blocks). + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ); + + } + + /** + * 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' => __( 'ID of the post to insert the block into.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Block attributes for the new occurrence.', 'convertkit' ), + 'properties' => $this->block->get_input_schema_properties(), + ), + 'position' => array( + 'type' => 'string', + 'enum' => array( 'append', 'prepend', 'at_index' ), + 'default' => 'append', + 'description' => __( 'Where to insert the new block. "at_index" requires the "index" property.', 'convertkit' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'When position is "at_index", the zero-based top-level block index at which to insert the new block.', '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', 'block', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the newly inserted block among this block\'s appearances in the post.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attributes of the newly inserted block.', '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 attributes. + $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. + $result = $this->block->insert_into_post( $post_id, $attrs, $position, $index ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Re-list occurrences to determine the newly inserted block's + // zero-based occurrence index among this block's appearances. + $occurrences = $this->block->find_blocks_in_post( $post_id ); + $occurrence_index = 0; + if ( is_array( $occurrences ) && count( $occurrences ) > 0 ) { + switch ( $position ) { + case 'prepend': + $occurrence_index = 0; + break; + + case 'at_index': + case 'append': + default: + // Find the first occurrence whose attrs match the just-inserted + // attrs; fall back to the last occurrence for 'append' and + // the first-after-$index for 'at_index'. + $occurrence_index = count( $occurrences ) - 1; + break; + } + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => $attrs, + ); + + } + +} 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..36c3f6358 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -0,0 +1,194 @@ +-list` (e.g. `kit/form-list`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: readonly + idempotent. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ); + + } + + /** + * 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', 'block', 'count', 'occurrences' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + '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 = $this->block->find_blocks_in_post( $post_id ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + '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..895c742ad --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -0,0 +1,207 @@ +-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 { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 a %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' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: not readonly, not destructive, idempotent + * (repeating the same update yields the same result). + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ); + + } + + /** + * 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', 'target', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + ), + 'target' => $this->get_target_schema(), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), + 'properties' => $this->block->get_input_schema_properties(), + ), + 'replace_all' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'If true, all existing attributes are replaced with the supplied set. If false (default), the supplied attributes are merged into the existing attributes.', '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', 'block', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the updated block.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attributes of the updated block.', '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 target. + $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); + $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); + $merge = ! ( isset( $input['replace_all'] ) && (bool) $input['replace_all'] ); + + // Resolve target. + $occurrence_index = $this->resolve_target( $post_id, $target ); + if ( is_wp_error( $occurrence_index ) ) { + return $occurrence_index; + } + + // Update block in post. + $result = $this->block->replace_in_post( $post_id, $occurrence_index, $attrs, $merge ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => isset( $result['attrs'] ) ? $result['attrs'] : $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..55b86ed62 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -0,0 +1,233 @@ +block = $block; + + } + + /** + * Returns the ability name, derived from the block's name and the verb + * returned by get_verb(). + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'kit/' . $this->block->get_name() . '-' . $this->get_verb(); + + } + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + abstract protected 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 JSON Schema fragment for a `target` object describing which + * occurrence of the block the ability should act on. Used by update/delete. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_target_schema() { + + return array( + 'type' => 'object', + 'description' => __( 'Identifies which occurrence of this block in the post to act on. Either by an attribute value match, or by zero-based occurrence index.', 'convertkit' ), + 'oneOf' => array( + array( + 'type' => 'object', + 'required' => array( 'by', 'attribute', 'value' ), + 'properties' => array( + 'by' => array( + 'type' => 'string', + 'enum' => array( 'attribute' ), + ), + 'attribute' => array( + 'type' => 'string', + 'description' => __( 'The block attribute name to match against (e.g. "form").', 'convertkit' ), + ), + 'value' => array( + 'description' => __( 'The value the attribute must match.', 'convertkit' ), + ), + ), + ), + array( + 'type' => 'object', + 'required' => array( 'by', 'index' ), + 'properties' => array( + 'by' => array( + 'type' => 'string', + 'enum' => array( 'index' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), + ), + ), + ), + ), + ); + + } + + /** + * Resolves a target descriptor into the zero-based occurrence index of the + * block in the post. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param array $target Target descriptor (see get_target_schema()). + * @return int|WP_Error Zero-based occurrence index, or WP_Error. + */ + protected function resolve_target( $post_id, $target ) { + + // Bail if target is not an array or does not have a 'by' key. + if ( ! is_array( $target ) || empty( $target['by'] ) ) { + return new WP_Error( + 'convertkit_mcp_invalid_target', + __( 'target.by is required.', 'convertkit' ) + ); + } + + // Find blocks in post. + $occurrences = $this->block->find_blocks_in_post( $post_id ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Bail if no blocks are found. + if ( empty( $occurrences ) ) { + return new WP_Error( + 'convertkit_mcp_no_block_occurrences', + /* translators: 1: block name, 2: post ID */ + sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), $this->block->get_full_block_name(), $post_id ) + ); + } + + // Resolve target. + switch ( $target['by'] ) { + case 'index': + $idx = isset( $target['index'] ) ? (int) $target['index'] : -1; + if ( $idx < 0 || $idx >= count( $occurrences ) ) { + return new WP_Error( + 'convertkit_mcp_target_index_out_of_range', + /* translators: 1: requested index, 2: number of occurrences */ + sprintf( __( 'Target index %1$d is out of range; post has %2$d occurrence(s).', 'convertkit' ), $idx, count( $occurrences ) ) + ); + } + return $idx; + + case 'attribute': + $attr = isset( $target['attribute'] ) ? (string) $target['attribute'] : ''; + $value = isset( $target['value'] ) ? $target['value'] : null; + if ( $attr === '' ) { + return new WP_Error( + 'convertkit_mcp_invalid_target', + __( 'target.attribute is required when target.by is "attribute".', 'convertkit' ) + ); + } + foreach ( $occurrences as $i => $occ ) { + if ( ! isset( $occ['attrs'][ $attr ] ) ) { + continue; + } + // Loose comparison so '123' == 123 resolves the same target, + // since Gutenberg attributes are often stringly typed. + if ( $occ['attrs'][ $attr ] == $value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return $i; + } + } + return new WP_Error( + 'convertkit_mcp_target_not_found', + /* translators: 1: attribute name, 2: value, 3: block name */ + sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), $this->block->get_full_block_name() ) + ); + + default: + return new WP_Error( + 'convertkit_mcp_invalid_target', + /* translators: %s: invalid 'by' value */ + sprintf( __( 'Unknown target.by value "%s". Expected "attribute" or "index".', 'convertkit' ), (string) $target['by'] ) + ); + } + + } + +} From a0ff1b07764c93342cef8f50f760d3c13fba3efd Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 18:17:52 +0800 Subject: [PATCH 05/82] Include block framework classes --- wp-convertkit.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wp-convertkit.php b/wp-convertkit.php index 1cbedbc10..7ceaeafdc 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -100,6 +100,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'; From f876f5d19b58f0f3508f3fb9c5270603c03ab805 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 18:24:58 +0800 Subject: [PATCH 06/82] Set `get_category` to not be abstract --- includes/mcp/class-convertkit-mcp-ability.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php index e06170df1..343f3ab59 100644 --- a/includes/mcp/class-convertkit-mcp-ability.php +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -73,7 +73,11 @@ abstract public function get_description(); * * @return string */ - abstract public function get_category(); + public function get_category() { + + return 'kit'; + + } /** * Returns the ability's input JSON Schema. From a4f501f99a42064f0e8ed0559be5e3faaa04d306 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 19:19:32 +0800 Subject: [PATCH 07/82] Fetch input schema from block fields/attributes --- ...ss-convertkit-mcp-ability-block-delete.php | 4 +- ...ss-convertkit-mcp-ability-block-insert.php | 62 +++++++++++++++++-- ...lass-convertkit-mcp-ability-block-list.php | 4 +- ...ss-convertkit-mcp-ability-block-update.php | 4 +- .../class-convertkit-mcp-ability-block.php | 4 +- 5 files changed, 66 insertions(+), 12 deletions(-) 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 index 87983b712..1a60415de 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -60,7 +60,7 @@ 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' ), - $this->block->get_full_block_name(), + 'convertkit/' . $this->block->get_name(), $this->block->get_title() ); @@ -180,7 +180,7 @@ public function execute_callback( $input ) { // Return result. return array( 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), + 'block' => 'convertkit/' . $this->block->get_name(), 'deleted_occurrence_index' => (int) $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 index 71dab6771..dc776a501 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -59,8 +59,8 @@ 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 occurrence by zero-based index.', 'convertkit' ), - $this->block->get_full_block_name(), + __( '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() ); @@ -106,7 +106,7 @@ public function get_input_schema() { 'attrs' => array( 'type' => 'object', 'description' => __( 'Block attributes for the new occurrence.', 'convertkit' ), - 'properties' => $this->block->get_input_schema_properties(), + 'properties' => $this->get_input_schema_properties(), ), 'position' => array( 'type' => 'string', @@ -124,6 +124,60 @@ public function get_input_schema() { } + /** + * Returns JSON Schema properties derived from the block's get_attributes() + * and get_fields(), suitable for use as the `attrs` object in an Abilities + * API input schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema_properties() { + + // Define properties. + $properties = array(); + + foreach ( $this->block->get_fields() as $field_name => $field ) { + + // Build JSON Schema property. + $properties[ $field_name ] = array( + 'description' => $field['label'], + 'type' => $this->get_input_schema_property_type( $field ), + ); + + } + + return $properties; + + } + + /** + * Returns the JSON Schema type for the given field. + * + * @since 3.4.0 + * + * @param array $field Field definition. + * @return string + */ + private function get_input_schema_property_type( $field ) { + + switch ( $field['type'] ) { + case 'resource': + return 'string'; + + case 'number': + return 'integer'; + + case 'toggle': + return 'boolean'; + + default: + return $field['type']; + } + + } + /** * Returns the ability's output JSON Schema. * @@ -214,7 +268,7 @@ public function execute_callback( $input ) { // Return result. return array( 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), + 'block' => 'convertkit/' . $this->block->get_name(), 'occurrence_index' => (int) $occurrence_index, 'attrs' => $attrs, ); 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 index 36c3f6358..4a761ee04 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -60,7 +60,7 @@ 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' ), - $this->block->get_full_block_name(), + 'convertkit/' . $this->block->get_name(), $this->block->get_title() ); @@ -184,7 +184,7 @@ public function execute_callback( $input ) { // Return result. return array( 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), + 'block' => 'convertkit/' . $this->block->get_name(), '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 index 895c742ad..32fab18d2 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -63,7 +63,7 @@ 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' ), - $this->block->get_full_block_name(), + 'convertkit/' . $this->block->get_name(), $this->block->get_title() ); @@ -197,7 +197,7 @@ public function execute_callback( $input ) { // Return result. return array( 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), + 'block' => 'convertkit/' . $this->block->get_name(), 'occurrence_index' => (int) $occurrence_index, 'attrs' => isset( $result['attrs'] ) ? $result['attrs'] : $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 index 55b86ed62..5e8ba19e6 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -178,7 +178,7 @@ protected function resolve_target( $post_id, $target ) { return new WP_Error( 'convertkit_mcp_no_block_occurrences', /* translators: 1: block name, 2: post ID */ - sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), $this->block->get_full_block_name(), $post_id ) + sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), 'convertkit/' . $this->block->get_name(), $post_id ) ); } @@ -217,7 +217,7 @@ protected function resolve_target( $post_id, $target ) { return new WP_Error( 'convertkit_mcp_target_not_found', /* translators: 1: attribute name, 2: value, 3: block name */ - sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), $this->block->get_full_block_name() ) + sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), 'convertkit/' . $this->block->get_name() ) ); default: From afa1e7eb00f8924adc4173d376f8878eba9e3012 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 19:19:44 +0800 Subject: [PATCH 08/82] Register Form Insertion Ability --- .../blocks/class-convertkit-block-form.php | 3 +++ includes/blocks/class-convertkit-block.php | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/includes/blocks/class-convertkit-block-form.php b/includes/blocks/class-convertkit-block-form.php index 964dcf485..b22afcc97 100644 --- a/includes/blocks/class-convertkit-block-form.php +++ b/includes/blocks/class-convertkit-block-form.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts for this Gutenberg Block in the editor view. add_action( 'convertkit_gutenberg_enqueue_scripts', array( $this, 'enqueue_scripts_editor' ) ); diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 12a82447e..6bd6b64f5 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -55,6 +55,25 @@ 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_Insert( $this ), + ) + ); + + } + /** * Returns this block's programmatic name, excluding the convertkit- prefix. * From 1c5b9c3fa940ebfd0ba7a95c080db6863d8150b5 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 21:25:39 +0800 Subject: [PATCH 09/82] Abilities: Prefix with `block-` --- .../abilities/blocks/class-convertkit-mcp-ability-block.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php index 5e8ba19e6..6e9ea76b6 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -43,6 +43,8 @@ public function __construct( $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 * @@ -50,7 +52,7 @@ public function __construct( $block ) { */ public function get_name() { - return 'kit/' . $this->block->get_name() . '-' . $this->get_verb(); + return 'kit/' . $this->block->get_name() . '-block-' . $this->get_verb(); } From db3eec1ca704d532e940a0abe2331ea3c41eb928 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 21:26:44 +0800 Subject: [PATCH 10/82] Insert Block: Use `index` --- ...ss-convertkit-mcp-ability-block-insert.php | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) 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 index dc776a501..81167686d 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -101,23 +101,23 @@ public function get_input_schema() { 'post_id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'ID of the post to insert the block into.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Block attributes for the new occurrence.', 'convertkit' ), - 'properties' => $this->get_input_schema_properties(), + 'description' => __( 'Page / Post / Custom Post Type ID to insert the block into.', 'convertkit' ), ), 'position' => array( 'type' => 'string', - 'enum' => array( 'append', 'prepend', 'at_index' ), + 'enum' => array( 'append', 'prepend', 'index' ), 'default' => 'append', - 'description' => __( 'Where to insert the new block. "at_index" requires the "index" property.', 'convertkit' ), + 'description' => __( 'Where to insert the new block. "index" requires the "index" property.', 'convertkit' ), ), 'index' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => __( 'When position is "at_index", the zero-based top-level block index at which to insert the new block.', 'convertkit' ), + '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(), ), ), ); @@ -254,12 +254,10 @@ public function execute_callback( $input ) { $occurrence_index = 0; break; - case 'at_index': - case 'append': default: // Find the first occurrence whose attrs match the just-inserted // attrs; fall back to the last occurrence for 'append' and - // the first-after-$index for 'at_index'. + // the first-after-$index for 'block_index'. $occurrence_index = count( $occurrences ) - 1; break; } From bf522cce2b9d9116c2361328eb6fe614cd553ce9 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 11:30:55 +0800 Subject: [PATCH 11/82] Use ConvertKit_Block_Post_Helper class --- .../class-convertkit-block-post-helper.php | 261 ++++++++++++++++++ ...ss-convertkit-mcp-ability-block-delete.php | 4 +- ...ss-convertkit-mcp-ability-block-insert.php | 80 +----- ...lass-convertkit-mcp-ability-block-list.php | 4 +- ...ss-convertkit-mcp-ability-block-update.php | 6 +- .../class-convertkit-mcp-ability-block.php | 70 ++++- wp-convertkit.php | 1 + 7 files changed, 337 insertions(+), 89 deletions(-) create mode 100644 includes/blocks/class-convertkit-block-post-helper.php diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php new file mode 100644 index 000000000..9ce3892b1 --- /dev/null +++ b/includes/blocks/class-convertkit-block-post-helper.php @@ -0,0 +1,261 @@ + , 'attrs' => ] + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $block_name Full block name, e.g. "convertkit/form". + * @return array|WP_Error Array of occurrences, or WP_Error if the post is missing. + */ + static public function find( $post_id, $block_name ) { + + // Get post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_helper_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 ); + $found = array(); + + foreach ( $blocks as $idx => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + $found[] = array( + 'index' => (int) $idx, + 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), + ); + } + + return $found; + + } + + /** + * Inserts a new occurrence of the given block into a 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 int $index Position to insert block. + * @return int|WP_Error + */ + static public function insert( $post_id, $block_name, $attrs, $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(), + ); + + // Determine where the new block will be inserted. + $insert_at = max( 0, min( (int) $index, count( $blocks ) ) ); + array_splice( $blocks, $insert_at, 0, array( $new_block ) ); + + // Count how many matching occurrences precede the insertion point — + // that's the new block's zero-based occurrence index. + $occurrence_index = 0; + for ( $i = 0; $i < $insert_at; $i++ ) { + if ( isset( $blocks[ $i ]['blockName'] ) && $blocks[ $i ]['blockName'] === $block_name ) { + ++$occurrence_index; + } + } + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + // Bail if an error occurred. + if ( is_wp_error( $updated ) ) { + return $updated; + } + + // Return the occurrence index. + return $occurrence_index; + + } + + /** + * Updates the attributes of a specific top-level occurrence of the given + * block in a 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 array|WP_Error + */ + static public 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 ); + $occurrence = 0; + $matched = false; + $final_attrs = array(); + + foreach ( $blocks as $key => $block ) { + // 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 ( $occurrence === (int) $occurrence_index ) { + $existing = isset( $block['attrs'] ) ? (array) $block['attrs'] : array(); + $final_attrs = $merge ? array_merge( $existing, (array) $attrs ) : (array) $attrs; + $blocks[ $key ]['attrs'] = $final_attrs; + $matched = true; + break; + } + + ++$occurrence; + } + + // 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. + return wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + } + + /** + * Deletes a specific top-level occurrence of the given block from a 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 array|WP_Error + */ + static public 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_delete_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 ); + $occurrence = 0; + $matched = false; + + foreach ( $blocks as $key => $block ) { + // Skip if the block name does not match. + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + // Delete the block if the occurrence index matches. + if ( $occurrence === (int) $occurrence_index ) { + unset( $blocks[ $key ] ); + $blocks = array_values( $blocks ); + $matched = true; + break; + } + + ++$occurrence; + } + + // Bail if the block was not found. + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_post_helper_delete_block_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. + return wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + } + +} 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 index 1a60415de..84be4e0c1 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -25,7 +25,7 @@ class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { * * @return string */ - protected function get_verb() { + public function get_verb() { return 'delete'; @@ -172,7 +172,7 @@ public function execute_callback( $input ) { } // Delete block from post. - $result = $this->block->delete_from_post( $post_id, $occurrence_index ); + $result = ConvertKit_Block_Post_Helper::delete( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index ); if ( is_wp_error( $result ) ) { return $result; } 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 index 81167686d..9ce95fe7d 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -25,7 +25,7 @@ class ConvertKit_MCP_Ability_Block_Insert extends ConvertKit_MCP_Ability_Block { * * @return string */ - protected function get_verb() { + public function get_verb() { return 'insert'; @@ -124,60 +124,6 @@ public function get_input_schema() { } - /** - * Returns JSON Schema properties derived from the block's get_attributes() - * and get_fields(), suitable for use as the `attrs` object in an Abilities - * API input schema. - * - * @since 3.4.0 - * - * @return array - */ - public function get_input_schema_properties() { - - // Define properties. - $properties = array(); - - foreach ( $this->block->get_fields() as $field_name => $field ) { - - // Build JSON Schema property. - $properties[ $field_name ] = array( - 'description' => $field['label'], - 'type' => $this->get_input_schema_property_type( $field ), - ); - - } - - return $properties; - - } - - /** - * Returns the JSON Schema type for the given field. - * - * @since 3.4.0 - * - * @param array $field Field definition. - * @return string - */ - private function get_input_schema_property_type( $field ) { - - switch ( $field['type'] ) { - case 'resource': - return 'string'; - - case 'number': - return 'integer'; - - case 'toggle': - return 'boolean'; - - default: - return $field['type']; - } - - } - /** * Returns the ability's output JSON Schema. * @@ -235,39 +181,19 @@ public function execute_callback( $input ) { // Get attributes. $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. - $result = $this->block->insert_into_post( $post_id, $attrs, $position, $index ); + $result = ConvertKit_Block_Post_Helper::insert( $post_id, 'convertkit/' . $this->block->get_name(), $attrs, $index ); if ( is_wp_error( $result ) ) { return $result; } - // Re-list occurrences to determine the newly inserted block's - // zero-based occurrence index among this block's appearances. - $occurrences = $this->block->find_blocks_in_post( $post_id ); - $occurrence_index = 0; - if ( is_array( $occurrences ) && count( $occurrences ) > 0 ) { - switch ( $position ) { - case 'prepend': - $occurrence_index = 0; - break; - - default: - // Find the first occurrence whose attrs match the just-inserted - // attrs; fall back to the last occurrence for 'append' and - // the first-after-$index for 'block_index'. - $occurrence_index = count( $occurrences ) - 1; - break; - } - } - // Return result. return array( 'post_id' => $post_id, 'block' => 'convertkit/' . $this->block->get_name(), - 'occurrence_index' => (int) $occurrence_index, + 'occurrence_index' => $result, 'attrs' => $attrs, ); 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 index 4a761ee04..b790e5081 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -25,7 +25,7 @@ class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { * * @return string */ - protected function get_verb() { + public function get_verb() { return 'list'; @@ -176,7 +176,7 @@ public function execute_callback( $input ) { } // Find blocks in post. - $occurrences = $this->block->find_blocks_in_post( $post_id ); + $occurrences = ConvertKit_Block_Post_Helper::find( $post_id, 'convertkit/' . $this->block->get_name() ); if ( is_wp_error( $occurrences ) ) { return $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 index 32fab18d2..148338f08 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -28,7 +28,7 @@ class ConvertKit_MCP_Ability_Block_Update extends ConvertKit_MCP_Ability_Block { * * @return string */ - protected function get_verb() { + public function get_verb() { return 'update'; @@ -110,7 +110,7 @@ public function get_input_schema() { 'attrs' => array( 'type' => 'object', 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), - 'properties' => $this->block->get_input_schema_properties(), + 'properties' => $this->get_input_schema_properties(), ), 'replace_all' => array( 'type' => 'boolean', @@ -189,7 +189,7 @@ public function execute_callback( $input ) { } // Update block in post. - $result = $this->block->replace_in_post( $post_id, $occurrence_index, $attrs, $merge ); + $result = ConvertKit_Block_Post_Helper::update( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index, $attrs, $merge ); if ( is_wp_error( $result ) ) { return $result; } diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php index 6e9ea76b6..6979ab5da 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -43,7 +43,7 @@ public function __construct( $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 @@ -63,7 +63,7 @@ public function get_name() { * * @return string */ - abstract protected function get_verb(); + abstract public function get_verb(); /** * Only permit an ability to be executed if the current user can edit the given post. @@ -106,7 +106,7 @@ public function permission_callback( $input ) { * * @return array */ - protected function get_target_schema() { + public function get_target_schema() { return array( 'type' => 'object', @@ -159,7 +159,7 @@ protected function get_target_schema() { * @param array $target Target descriptor (see get_target_schema()). * @return int|WP_Error Zero-based occurrence index, or WP_Error. */ - protected function resolve_target( $post_id, $target ) { + public function resolve_target( $post_id, $target ) { // Bail if target is not an array or does not have a 'by' key. if ( ! is_array( $target ) || empty( $target['by'] ) ) { @@ -170,7 +170,7 @@ protected function resolve_target( $post_id, $target ) { } // Find blocks in post. - $occurrences = $this->block->find_blocks_in_post( $post_id ); + $occurrences = ConvertKit_Block_Post_Helper::find( $post_id, 'convertkit/' . $this->block->get_name() ); if ( is_wp_error( $occurrences ) ) { return $occurrences; } @@ -232,4 +232,64 @@ protected function resolve_target( $post_id, $target ) { } + /** + * 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(); + + if ( ! is_array( $fields ) ) { + return $properties; + } + + 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/wp-convertkit.php b/wp-convertkit.php index 28ffb9190..52a4fa66a 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -85,6 +85,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-user.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-widgets.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-post-helper.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-broadcasts.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-form-trigger.php'; From 3ed544607011e6f930217e672d0617ff53ae327e Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 11:39:38 +0800 Subject: [PATCH 12/82] Fix static method arguments --- .../class-convertkit-block-post-helper.php | 67 +++++++++++++++---- ...ss-convertkit-mcp-ability-block-insert.php | 3 +- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php index 9ce3892b1..1ed545f76 100644 --- a/includes/blocks/class-convertkit-block-post-helper.php +++ b/includes/blocks/class-convertkit-block-post-helper.php @@ -26,7 +26,7 @@ class ConvertKit_Block_Post_Helper { * @param string $block_name Full block name, e.g. "convertkit/form". * @return array|WP_Error Array of occurrences, or WP_Error if the post is missing. */ - static public function find( $post_id, $block_name ) { + public static function find( $post_id, $block_name ) { // Get post. $post = get_post( $post_id ); @@ -61,15 +61,21 @@ static public function find( $post_id, $block_name ) { * Inserts a new occurrence of the given block into a post's content at the * specified position. * + * $position can be one of: + * - 'prepend' : insert as the first top-level block. + * - 'append' : insert as the last top-level block (default). + * - 'index' : insert at the zero-based top-level block index given by $index. + * * @since 3.4.0 * * @param int $post_id Post ID. * @param string $block_name Programmatic Block Name. * @param array $attrs Block Attributes. - * @param int $index Position to insert block. - * @return int|WP_Error + * @param string $position One of 'prepend', 'append', 'index'. + * @param int $index Zero-based top-level block index; only used when $position is 'index'. + * @return int|WP_Error Zero-based occurrence index of the newly inserted block, or WP_Error on failure. */ - static public function insert( $post_id, $block_name, $attrs, $index = 0 ) { + public static function insert( $post_id, $block_name, $attrs, $position = 'append', $index = 0 ) { // Get Post. $post = get_post( $post_id ); @@ -93,8 +99,24 @@ static public function insert( $post_id, $block_name, $attrs, $index = 0 ) { 'innerContent' => array(), ); - // Determine where the new block will be inserted. - $insert_at = max( 0, min( (int) $index, count( $blocks ) ) ); + // 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 ) ); // Count how many matching occurrences precede the insertion point — @@ -116,8 +138,8 @@ static public function insert( $post_id, $block_name, $attrs, $index = 0 ) { ); // Bail if an error occurred. - if ( is_wp_error( $updated ) ) { - return $updated; + if ( is_wp_error( $result ) ) { + return $result; } // Return the occurrence index. @@ -135,9 +157,10 @@ static public function insert( $post_id, $block_name, $attrs, $index = 0 ) { * @param string $block_name Programmatic Block Name. * @param int $occurrence_index Position to update block. * @param array $attrs Block Attributes. + * @param bool $merge If true, merge $attrs into existing attributes; if false, replace all. * @return array|WP_Error */ - static public function update( $post_id, $block_name, $occurrence_index, $attrs ) { + public static function update( $post_id, $block_name, $occurrence_index, $attrs, $merge = true ) { // Get Post. $post = get_post( $post_id ); @@ -183,7 +206,7 @@ static public function update( $post_id, $block_name, $occurrence_index, $attrs } // Update Post. - return wp_update_post( + $result = wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -191,6 +214,16 @@ static public function update( $post_id, $block_name, $occurrence_index, $attrs true ); + // Bail if an error occurred. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the final attributes applied to the block. + return array( + 'attrs' => $final_attrs, + ); + } /** @@ -204,7 +237,7 @@ static public function update( $post_id, $block_name, $occurrence_index, $attrs * @param int $occurrence_index Zero-based index among this block's occurrences in the post. * @return array|WP_Error */ - static public function delete( $post_id, $block_name, $occurrence_index ) { + public static function delete( $post_id, $block_name, $occurrence_index ) { // Get Post. $post = get_post( $post_id ); @@ -248,7 +281,7 @@ static public function delete( $post_id, $block_name, $occurrence_index ) { } // Update Post. - return wp_update_post( + $result = wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -256,6 +289,16 @@ static public function delete( $post_id, $block_name, $occurrence_index ) { true ); + // Bail if an error occurred. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the remaining block count. + return array( + 'block_count' => count( $blocks ), + ); + } } 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 index 9ce95fe7d..2811dd753 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -181,10 +181,11 @@ public function execute_callback( $input ) { // Get attributes. $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. - $result = ConvertKit_Block_Post_Helper::insert( $post_id, 'convertkit/' . $this->block->get_name(), $attrs, $index ); + $result = ConvertKit_Block_Post_Helper::insert( $post_id, 'convertkit/' . $this->block->get_name(), $attrs, $position, $index ); if ( is_wp_error( $result ) ) { return $result; } From fc4d1eddcaaa5c9d9fd155545881af1d81011fc2 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 11:59:09 +0800 Subject: [PATCH 13/82] Simplify block post helper method --- .../class-convertkit-block-post-helper.php | 80 ++++--------------- 1 file changed, 17 insertions(+), 63 deletions(-) diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php index 1ed545f76..123566e57 100644 --- a/includes/blocks/class-convertkit-block-post-helper.php +++ b/includes/blocks/class-convertkit-block-post-helper.php @@ -23,8 +23,8 @@ class ConvertKit_Block_Post_Helper { * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $block_name Full block name, e.g. "convertkit/form". - * @return array|WP_Error Array of occurrences, or WP_Error if the post is missing. + * @param string $block_name Programmatic Block Name. + * @return array|WP_Error */ public static function find( $post_id, $block_name ) { @@ -42,13 +42,13 @@ public static function find( $post_id, $block_name ) { $blocks = parse_blocks( $post->post_content ); $found = array(); - foreach ( $blocks as $idx => $block ) { + foreach ( $blocks as $index => $block ) { if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { continue; } $found[] = array( - 'index' => (int) $idx, + 'index' => (int) $index, 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), ); } @@ -61,11 +61,6 @@ public static function find( $post_id, $block_name ) { * Inserts a new occurrence of the given block into a post's content at the * specified position. * - * $position can be one of: - * - 'prepend' : insert as the first top-level block. - * - 'append' : insert as the last top-level block (default). - * - 'index' : insert at the zero-based top-level block index given by $index. - * * @since 3.4.0 * * @param int $post_id Post ID. @@ -73,7 +68,7 @@ public static function find( $post_id, $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 int|WP_Error Zero-based occurrence index of the newly inserted block, or WP_Error on failure. + * @return int|WP_Error */ public static function insert( $post_id, $block_name, $attrs, $position = 'append', $index = 0 ) { @@ -119,17 +114,8 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen // Splice in the new block. array_splice( $blocks, $insert_at, 0, array( $new_block ) ); - // Count how many matching occurrences precede the insertion point — - // that's the new block's zero-based occurrence index. - $occurrence_index = 0; - for ( $i = 0; $i < $insert_at; $i++ ) { - if ( isset( $blocks[ $i ]['blockName'] ) && $blocks[ $i ]['blockName'] === $block_name ) { - ++$occurrence_index; - } - } - // Update Post. - $result = wp_update_post( + return wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -137,14 +123,6 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen true ); - // Bail if an error occurred. - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return the occurrence index. - return $occurrence_index; - } /** @@ -157,10 +135,9 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen * @param string $block_name Programmatic Block Name. * @param int $occurrence_index Position to update block. * @param array $attrs Block Attributes. - * @param bool $merge If true, merge $attrs into existing attributes; if false, replace all. - * @return array|WP_Error + * @return int|WP_Error */ - public static function update( $post_id, $block_name, $occurrence_index, $attrs, $merge = true ) { + public static function update( $post_id, $block_name, $occurrence_index, $attrs ) { // Get Post. $post = get_post( $post_id ); @@ -173,10 +150,9 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs, } // Parse blocks. - $blocks = parse_blocks( $post->post_content ); - $occurrence = 0; - $matched = false; - $final_attrs = array(); + $blocks = parse_blocks( $post->post_content ); + $occurrence = 0; + $matched = false; foreach ( $blocks as $key => $block ) { // Skip if the block name does not match. @@ -186,9 +162,7 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs, // Update the block if the occurrence index matches. if ( $occurrence === (int) $occurrence_index ) { - $existing = isset( $block['attrs'] ) ? (array) $block['attrs'] : array(); - $final_attrs = $merge ? array_merge( $existing, (array) $attrs ) : (array) $attrs; - $blocks[ $key ]['attrs'] = $final_attrs; + $blocks[ $key ]['attrs'] = array_merge( (array) $block['attrs'], (array) $attrs ); $matched = true; break; } @@ -206,7 +180,7 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs, } // Update Post. - $result = wp_update_post( + return wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -214,16 +188,6 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs, true ); - // Bail if an error occurred. - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return the final attributes applied to the block. - return array( - 'attrs' => $final_attrs, - ); - } /** @@ -235,7 +199,7 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs, * @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 array|WP_Error + * @return int|WP_Error */ public static function delete( $post_id, $block_name, $occurrence_index ) { @@ -244,7 +208,7 @@ public static function delete( $post_id, $block_name, $occurrence_index ) { if ( ! $post ) { return new WP_Error( 'convertkit_block_post_helper_delete_block_post_not_found', - /* translators: %d: post ID */ + /* translators: %d: Post ID */ sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id ) ); } @@ -275,13 +239,13 @@ public static function delete( $post_id, $block_name, $occurrence_index ) { if ( ! $matched ) { return new WP_Error( 'convertkit_block_post_helper_delete_block_occurrence_not_found', - /* translators: 1: block name, 2: occurrence index, 3: post ID */ + /* 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( + return wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -289,16 +253,6 @@ public static function delete( $post_id, $block_name, $occurrence_index ) { true ); - // Bail if an error occurred. - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return the remaining block count. - return array( - 'block_count' => count( $blocks ), - ); - } } From 63042f105fb0c4e5dbd30a76fc07c10fb79789b3 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 12:52:37 +0800 Subject: [PATCH 14/82] Move annotations to higher classes --- .../class-convertkit-block-post-helper.php | 3 -- includes/blocks/class-convertkit-block.php | 1 + ...ss-convertkit-mcp-ability-block-delete.php | 30 +++++--------- ...ss-convertkit-mcp-ability-block-insert.php | 19 --------- ...lass-convertkit-mcp-ability-block-list.php | 36 ++++++++-------- ...ss-convertkit-mcp-ability-block-update.php | 41 ++++++------------- includes/mcp/class-convertkit-mcp-ability.php | 38 +++++++++++++---- 7 files changed, 73 insertions(+), 95 deletions(-) diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php index 123566e57..618fee311 100644 --- a/includes/blocks/class-convertkit-block-post-helper.php +++ b/includes/blocks/class-convertkit-block-post-helper.php @@ -17,9 +17,6 @@ class ConvertKit_Block_Post_Helper { /** * Finds all top-level occurrences of the given block in a post's content. * - * Returns an array of occurrences in document order, each of the form: - * [ 'index' => , 'attrs' => ] - * * @since 3.4.0 * * @param int $post_id Post ID. diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 6bd6b64f5..1636922fa 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -69,6 +69,7 @@ public function register_abilities( $abilities ) { $abilities, array( new ConvertKit_MCP_Ability_Block_Insert( $this ), + new ConvertKit_MCP_Ability_Block_Update( $this ), ) ); 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 index 84be4e0c1..851d99fed 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -18,6 +18,15 @@ */ 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; + /** * Returns the verb this ability represents. * @@ -42,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Delete a %s block from a post', 'convertkit' ), + __( 'Delete an existing %s block from a post', 'convertkit' ), $this->block->get_title() ); @@ -66,25 +75,6 @@ public function get_description() { } - /** - * MCP annotations: destructive and not readonly; not idempotent, as repeated - * calls will attempt to delete sequential occurrences rather than a no-op. - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => true, - 'idempotent' => false, - ); - - } - /** * Returns the ability's input JSON Schema. * 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 index 2811dd753..70e0646e8 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -66,25 +66,6 @@ public function get_description() { } - /** - * MCP annotations: not readonly, not destructive, not idempotent - * (repeated calls insert additional blocks). - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ); - - } - /** * Returns the ability's input JSON Schema. * 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 index b790e5081..0ee9c2380 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -18,6 +18,24 @@ */ 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; + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; + /** * Returns the verb this ability represents. * @@ -66,24 +84,6 @@ public function get_description() { } - /** - * MCP annotations: readonly + idempotent. - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ); - - } - /** * Returns the ability's input JSON Schema. * 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 index 148338f08..533fb5f28 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -21,6 +21,15 @@ */ 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; + /** * Returns the verb this ability represents. * @@ -45,7 +54,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Update a %s block in a post', 'convertkit' ), + __( 'Update an existing %s block in a post', 'convertkit' ), $this->block->get_title() ); @@ -69,25 +78,6 @@ public function get_description() { } - /** - * MCP annotations: not readonly, not destructive, idempotent - * (repeating the same update yields the same result). - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ); - - } - /** * Returns the ability's input JSON Schema. * @@ -101,22 +91,17 @@ public function get_input_schema() { 'type' => 'object', 'required' => array( 'post_id', 'target', 'attrs' ), 'properties' => array( - 'post_id' => array( + 'post_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the post containing the block.', 'convertkit' ), ), - 'target' => $this->get_target_schema(), - 'attrs' => array( + 'target' => $this->get_target_schema(), + 'attrs' => array( 'type' => 'object', 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), 'properties' => $this->get_input_schema_properties(), ), - 'replace_all' => array( - 'type' => 'boolean', - 'default' => false, - 'description' => __( 'If true, all existing attributes are replaced with the supplied set. If false (default), the supplied attributes are merged into the existing attributes.', 'convertkit' ), - ), ), ); diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php index 343f3ab59..4cbf26ee6 100644 --- a/includes/mcp/class-convertkit-mcp-ability.php +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -15,6 +15,33 @@ */ abstract class ConvertKit_MCP_Ability { + /** + * Sets whether the ability is readonly. + * + * @since 3.4.0 + * + * @var bool + */ + private $readonly = false; + + /** + * Sets whether the ability is destructive. + * + * @since 3.4.0 + * + * @var bool + */ + private $destructive = false; + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = false; + /** * Returns the ability name, prefixed with `kit/` (e.g. `kit/form-insert`). * @@ -98,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 * @@ -111,9 +135,9 @@ public function get_annotations() { return array( 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, + 'readonly' => $this->readonly, + 'destructive' => $this->destructive, + 'idempotent' => $this->idempotent, ); } From a3d689ee15c231876aaae944099ed913f2d71f6e Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 14:35:32 +0800 Subject: [PATCH 15/82] Simplify input/output schema --- .../class-convertkit-block-post-helper.php | 10 +- ...ss-convertkit-mcp-ability-block-delete.php | 1 - ...ss-convertkit-mcp-ability-block-insert.php | 45 +----- ...ss-convertkit-mcp-ability-block-update.php | 68 ++------- .../class-convertkit-mcp-ability-block.php | 130 ++---------------- 5 files changed, 32 insertions(+), 222 deletions(-) diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php index 618fee311..cb814eae3 100644 --- a/includes/blocks/class-convertkit-block-post-helper.php +++ b/includes/blocks/class-convertkit-block-post-helper.php @@ -147,9 +147,9 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs } // Parse blocks. - $blocks = parse_blocks( $post->post_content ); - $occurrence = 0; - $matched = false; + $blocks = parse_blocks( $post->post_content ); + $index = 0; + $matched = false; foreach ( $blocks as $key => $block ) { // Skip if the block name does not match. @@ -158,13 +158,13 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs } // Update the block if the occurrence index matches. - if ( $occurrence === (int) $occurrence_index ) { + if ( $index === (int) $occurrence_index ) { $blocks[ $key ]['attrs'] = array_merge( (array) $block['attrs'], (array) $attrs ); $matched = true; break; } - ++$occurrence; + ++$index; } // Bail if the block was not found. 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 index 851d99fed..4581285b9 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -93,7 +93,6 @@ public function get_input_schema() { 'minimum' => 1, 'description' => __( 'ID of the post containing the block.', 'convertkit' ), ), - 'target' => $this->get_target_schema(), ), ); 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 index 70e0646e8..e87602fe9 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -105,40 +105,6 @@ public function get_input_schema() { } - /** - * 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', 'block', 'occurrence_index', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'occurrence_index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the newly inserted block among this block\'s appearances in the post.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attributes of the newly inserted block.', 'convertkit' ), - ), - ), - ); - - } - /** * Executes the ability. * @@ -160,23 +126,18 @@ public function execute_callback( $input ) { ); } - // Get attributes. + // 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. $result = ConvertKit_Block_Post_Helper::insert( $post_id, 'convertkit/' . $this->block->get_name(), $attrs, $position, $index ); - if ( is_wp_error( $result ) ) { - return $result; - } // Return result. return array( - 'post_id' => $post_id, - 'block' => 'convertkit/' . $this->block->get_name(), - 'occurrence_index' => $result, - 'attrs' => $attrs, + 'post_id' => $post_id, + 'result' => $result, ); } 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 index 533fb5f28..59b62409a 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -89,52 +89,22 @@ public function get_input_schema() { return array( 'type' => 'object', - 'required' => array( 'post_id', 'target', 'attrs' ), + 'required' => array( 'post_id', 'occurrence_index', 'attrs' ), 'properties' => array( - 'post_id' => array( + 'post_id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'ID of the post containing the block.', 'convertkit' ), - ), - 'target' => $this->get_target_schema(), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), - 'properties' => $this->get_input_schema_properties(), - ), - ), - ); - - } - - /** - * 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', 'block', 'occurrence_index', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + 'description' => __( 'Page / Post / Custom Post Type ID containing the existing block.', 'convertkit' ), ), 'occurrence_index' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the updated block.', 'convertkit' ), + 'description' => __( 'The zero-based occurrence index of the block to update.', 'convertkit' ), ), 'attrs' => array( 'type' => 'object', - 'description' => __( 'Attributes of the updated block.', 'convertkit' ), + 'description' => __( 'Block attributes to update. Any attributes not provided will be left unchanged.', 'convertkit' ), + 'properties' => $this->get_input_schema_properties(), ), ), ); @@ -162,29 +132,17 @@ public function execute_callback( $input ) { ); } - // Get target. - $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); - $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); - $merge = ! ( isset( $input['replace_all'] ) && (bool) $input['replace_all'] ); + // 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; - // Resolve target. - $occurrence_index = $this->resolve_target( $post_id, $target ); - if ( is_wp_error( $occurrence_index ) ) { - return $occurrence_index; - } - - // Update block in post. - $result = ConvertKit_Block_Post_Helper::update( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index, $attrs, $merge ); - if ( is_wp_error( $result ) ) { - return $result; - } + // Update block into post. + $result = ConvertKit_Block_Post_Helper::update( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index, $attrs ); // Return result. return array( - 'post_id' => $post_id, - 'block' => 'convertkit/' . $this->block->get_name(), - 'occurrence_index' => (int) $occurrence_index, - 'attrs' => isset( $result['attrs'] ) ? $result['attrs'] : $attrs, + 'post_id' => $post_id, + 'result' => $result, ); } diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php index 6979ab5da..a091702ed 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -99,139 +99,31 @@ public function permission_callback( $input ) { } /** - * Returns the JSON Schema fragment for a `target` object describing which - * occurrence of the block the ability should act on. Used by update/delete. + * Returns the ability's output JSON Schema. * * @since 3.4.0 * * @return array */ - public function get_target_schema() { + public function get_output_schema() { return array( - 'type' => 'object', - 'description' => __( 'Identifies which occurrence of this block in the post to act on. Either by an attribute value match, or by zero-based occurrence index.', 'convertkit' ), - 'oneOf' => array( - array( - 'type' => 'object', - 'required' => array( 'by', 'attribute', 'value' ), - 'properties' => array( - 'by' => array( - 'type' => 'string', - 'enum' => array( 'attribute' ), - ), - 'attribute' => array( - 'type' => 'string', - 'description' => __( 'The block attribute name to match against (e.g. "form").', 'convertkit' ), - ), - 'value' => array( - 'description' => __( 'The value the attribute must match.', 'convertkit' ), - ), - ), + 'type' => 'object', + 'required' => array( 'post_id', 'result' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'description' => __( 'The Post/Page/Custom Post Type ID.', 'convertkit' ), ), - array( - 'type' => 'object', - 'required' => array( 'by', 'index' ), - 'properties' => array( - 'by' => array( - 'type' => 'string', - 'enum' => array( 'index' ), - ), - 'index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), - ), - ), + 'result' => array( + 'type' => 'integer', + 'description' => __( 'The wp_update_post() result.', 'convertkit' ), ), ), ); } - /** - * Resolves a target descriptor into the zero-based occurrence index of the - * block in the post. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param array $target Target descriptor (see get_target_schema()). - * @return int|WP_Error Zero-based occurrence index, or WP_Error. - */ - public function resolve_target( $post_id, $target ) { - - // Bail if target is not an array or does not have a 'by' key. - if ( ! is_array( $target ) || empty( $target['by'] ) ) { - return new WP_Error( - 'convertkit_mcp_invalid_target', - __( 'target.by 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; - } - - // Bail if no blocks are found. - if ( empty( $occurrences ) ) { - return new WP_Error( - 'convertkit_mcp_no_block_occurrences', - /* translators: 1: block name, 2: post ID */ - sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), 'convertkit/' . $this->block->get_name(), $post_id ) - ); - } - - // Resolve target. - switch ( $target['by'] ) { - case 'index': - $idx = isset( $target['index'] ) ? (int) $target['index'] : -1; - if ( $idx < 0 || $idx >= count( $occurrences ) ) { - return new WP_Error( - 'convertkit_mcp_target_index_out_of_range', - /* translators: 1: requested index, 2: number of occurrences */ - sprintf( __( 'Target index %1$d is out of range; post has %2$d occurrence(s).', 'convertkit' ), $idx, count( $occurrences ) ) - ); - } - return $idx; - - case 'attribute': - $attr = isset( $target['attribute'] ) ? (string) $target['attribute'] : ''; - $value = isset( $target['value'] ) ? $target['value'] : null; - if ( $attr === '' ) { - return new WP_Error( - 'convertkit_mcp_invalid_target', - __( 'target.attribute is required when target.by is "attribute".', 'convertkit' ) - ); - } - foreach ( $occurrences as $i => $occ ) { - if ( ! isset( $occ['attrs'][ $attr ] ) ) { - continue; - } - // Loose comparison so '123' == 123 resolves the same target, - // since Gutenberg attributes are often stringly typed. - if ( $occ['attrs'][ $attr ] == $value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual - return $i; - } - } - return new WP_Error( - 'convertkit_mcp_target_not_found', - /* translators: 1: attribute name, 2: value, 3: block name */ - sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), 'convertkit/' . $this->block->get_name() ) - ); - - default: - return new WP_Error( - 'convertkit_mcp_invalid_target', - /* translators: %s: invalid 'by' value */ - sprintf( __( 'Unknown target.by value "%s". Expected "attribute" or "index".', 'convertkit' ), (string) $target['by'] ) - ); - } - - } - /** * Returns JSON Schema properties derived from the block's get_fields(), * suitable for use as the `attrs` object in an Abilities API input schema. From 81b58e3a9882e8cf06e2af36d8109e9fe2f10b97 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 15:56:36 +0800 Subject: [PATCH 16/82] Register all block abilities and set correct parameters --- .../class-convertkit-block-post-helper.php | 107 ++++++++++++------ includes/blocks/class-convertkit-block.php | 2 + ...ss-convertkit-mcp-ability-block-delete.php | 57 ++-------- ...ss-convertkit-mcp-ability-block-insert.php | 8 +- ...lass-convertkit-mcp-ability-block-list.php | 7 +- ...ss-convertkit-mcp-ability-block-update.php | 8 +- .../class-convertkit-mcp-ability-block.php | 12 +- 7 files changed, 95 insertions(+), 106 deletions(-) diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php index cb814eae3..9c692f6c5 100644 --- a/includes/blocks/class-convertkit-block-post-helper.php +++ b/includes/blocks/class-convertkit-block-post-helper.php @@ -15,7 +15,7 @@ class ConvertKit_Block_Post_Helper { /** - * Finds all top-level occurrences of the given block in a post's content. + * Finds all blocks matching the given block name in a Post's content. * * @since 3.4.0 * @@ -36,8 +36,9 @@ public static function find( $post_id, $block_name ) { } // Parse blocks. - $blocks = parse_blocks( $post->post_content ); - $found = array(); + $blocks = parse_blocks( $post->post_content ); + $found = array(); + $occurrence_index = 0; foreach ( $blocks as $index => $block ) { if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { @@ -45,9 +46,12 @@ public static function find( $post_id, $block_name ) { } $found[] = array( - 'index' => (int) $index, - 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), + 'index' => (int) $index, + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), ); + + ++$occurrence_index; } return $found; @@ -55,8 +59,7 @@ public static function find( $post_id, $block_name ) { } /** - * Inserts a new occurrence of the given block into a post's content at the - * specified position. + * Inserts a new block into the Post's content at the specified position. * * @since 3.4.0 * @@ -99,12 +102,12 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen break; case 'index': - $insert_at = max( 0, min( (int) $index, count( $blocks ) ) ); + $insert_at = max( 0, min( (int) $index, ( count( $blocks ) - 1 ) ) ); break; case 'append': default: - $insert_at = count( $blocks ); + $insert_at = ( count( $blocks ) - 1 ); break; } @@ -112,7 +115,7 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen array_splice( $blocks, $insert_at, 0, array( $new_block ) ); // Update Post. - return wp_update_post( + $result = wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -120,11 +123,22 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen 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, + 'occurrence_index' => 0, // @TODO. + ); + } /** - * Updates the attributes of a specific top-level occurrence of the given - * block in a post's content. + * Updates the attributes of an existing block in the Post's content. * * @since 3.4.0 * @@ -147,24 +161,27 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs } // Parse blocks. - $blocks = parse_blocks( $post->post_content ); - $index = 0; - $matched = false; + $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 ( $index === (int) $occurrence_index ) { + if ( $block_index === (int) $occurrence_index ) { $blocks[ $key ]['attrs'] = array_merge( (array) $block['attrs'], (array) $attrs ); $matched = true; break; } - ++$index; + ++$block_index; } // Bail if the block was not found. @@ -177,7 +194,7 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs } // Update Post. - return wp_update_post( + $result = wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -185,11 +202,22 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs 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, + 'occurrence_index' => $occurrence_index, + ); + } /** - * Deletes a specific top-level occurrence of the given block from a post's - * content. + * Deletes a specific block from the Post's content. * * @since 3.4.0 * @@ -204,45 +232,48 @@ public static function delete( $post_id, $block_name, $occurrence_index ) { $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( - 'convertkit_block_post_helper_delete_block_post_not_found', - /* translators: %d: Post ID */ + '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 ); - $occurrence = 0; - $matched = false; + $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; } - // Delete the block if the occurrence index matches. - if ( $occurrence === (int) $occurrence_index ) { + // Update the block if the occurrence index matches. + if ( $block_index === (int) $occurrence_index ) { unset( $blocks[ $key ] ); $blocks = array_values( $blocks ); $matched = true; break; } - ++$occurrence; + ++$block_index; } // Bail if the block was not found. if ( ! $matched ) { return new WP_Error( - 'convertkit_block_post_helper_delete_block_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 ) + '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. - return wp_update_post( + $result = wp_update_post( array( 'ID' => $post_id, 'post_content' => serialize_blocks( $blocks ), @@ -250,6 +281,18 @@ public static function delete( $post_id, $block_name, $occurrence_index ) { 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, + 'occurrence_index' => $occurrence_index, + ); + } } diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 1636922fa..1426f5e42 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -68,8 +68,10 @@ 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 ), ) ); 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 index 4581285b9..75fc27548 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -86,42 +86,17 @@ public function get_input_schema() { return array( 'type' => 'object', - 'required' => array( 'post_id', 'target' ), + 'required' => array( 'post_id', 'occurrence_index' ), 'properties' => array( - 'post_id' => array( + 'post_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the post containing the block.', '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', 'block', 'deleted_occurrence_index' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'deleted_occurrence_index' => array( + 'occurrence_index' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the deleted block among this block\'s appearances in the post prior to deletion.', 'convertkit' ), + 'description' => __( 'The zero-based occurrence index of the block to delete.', 'convertkit' ), ), ), ); @@ -149,29 +124,11 @@ public function execute_callback( $input ) { ); } - // Get target. - $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); - - // Resolve target. - $occurrence_index = $this->resolve_target( $post_id, $target ); - - // Bail if the target is not found. - if ( is_wp_error( $occurrence_index ) ) { - return $occurrence_index; - } + // Get occurrence index. + $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0; // Delete block from post. - $result = ConvertKit_Block_Post_Helper::delete( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index ); - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => 'convertkit/' . $this->block->get_name(), - 'deleted_occurrence_index' => (int) $occurrence_index, - ); + 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 index e87602fe9..c76b30edd 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -132,13 +132,7 @@ public function execute_callback( $input ) { $index = isset( $input['index'] ) ? (int) $input['index'] : 0; // Insert block into post. - $result = ConvertKit_Block_Post_Helper::insert( $post_id, 'convertkit/' . $this->block->get_name(), $attrs, $position, $index ); - - // Return result. - return array( - 'post_id' => $post_id, - 'result' => $result, - ); + 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 index 0ee9c2380..3f516a3ba 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -118,15 +118,11 @@ public function get_output_schema() { return array( 'type' => 'object', - 'required' => array( 'post_id', 'block', 'count', 'occurrences' ), + 'required' => array( 'post_id', 'count', 'occurrences' ), 'properties' => array( 'post_id' => array( 'type' => 'integer', ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), 'count' => array( 'type' => 'integer', 'minimum' => 0, @@ -184,7 +180,6 @@ public function execute_callback( $input ) { // Return result. return array( 'post_id' => $post_id, - 'block' => 'convertkit/' . $this->block->get_name(), '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 index 59b62409a..0c939e8ab 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -137,13 +137,7 @@ public function execute_callback( $input ) { $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0; // Update block into post. - $result = ConvertKit_Block_Post_Helper::update( $post_id, 'convertkit/' . $this->block->get_name(), $occurrence_index, $attrs ); - - // Return result. - return array( - 'post_id' => $post_id, - 'result' => $result, - ); + 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 index a091702ed..c1d1586ba 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -109,15 +109,19 @@ public function get_output_schema() { return array( 'type' => 'object', - 'required' => array( 'post_id', 'result' ), + 'required' => array( 'post_id', 'occurrence_index', 'index' ), 'properties' => array( - 'post_id' => array( + 'post_id' => array( 'type' => 'integer', 'description' => __( 'The Post/Page/Custom Post Type ID.', 'convertkit' ), ), - 'result' => array( + 'occurrence_index' => array( 'type' => 'integer', - 'description' => __( 'The wp_update_post() result.', 'convertkit' ), + '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' ), ), ), ); From 0fadf1c962853a6b54ac8866ded83b9f98dfc3b5 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 17:53:06 +0800 Subject: [PATCH 17/82] Tidy up comments --- includes/blocks/class-convertkit-block-post-helper.php | 4 ++-- .../blocks/class-convertkit-mcp-ability-block-delete.php | 6 +++--- .../blocks/class-convertkit-mcp-ability-block-list.php | 2 +- .../blocks/class-convertkit-mcp-ability-block-update.php | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php index 9c692f6c5..6d659583b 100644 --- a/includes/blocks/class-convertkit-block-post-helper.php +++ b/includes/blocks/class-convertkit-block-post-helper.php @@ -102,12 +102,12 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen break; case 'index': - $insert_at = max( 0, min( (int) $index, ( count( $blocks ) - 1 ) ) ); + $insert_at = max( 0, min( (int) $index, count( $blocks ) ) ); break; case 'append': default: - $insert_at = ( count( $blocks ) - 1 ); + $insert_at = count( $blocks ); break; } 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 index 75fc27548..6973f0ceb 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -1,14 +1,14 @@ -delete` (e.g. `kit/form-delete`). 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 index 3f516a3ba..ef95f78cc 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -1,6 +1,6 @@ Date: Thu, 23 Apr 2026 17:54:14 +0800 Subject: [PATCH 18/82] Add annotations as class properties --- includes/mcp/class-convertkit-mcp-ability.php | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php index e06170df1..017b53c41 100644 --- a/includes/mcp/class-convertkit-mcp-ability.php +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -15,6 +15,33 @@ */ abstract class ConvertKit_MCP_Ability { + /** + * Sets whether the ability is readonly. + * + * @since 3.4.0 + * + * @var bool + */ + private $readonly = false; + + /** + * Sets whether the ability is destructive. + * + * @since 3.4.0 + * + * @var bool + */ + private $destructive = false; + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = false; + /** * Returns the ability name, prefixed with `kit/` (e.g. `kit/form-insert`). * @@ -73,7 +100,11 @@ abstract public function get_description(); * * @return string */ - abstract public function get_category(); + public function get_category() { + + return 'kit'; + + } /** * Returns the ability's input JSON Schema. @@ -107,9 +138,9 @@ public function get_annotations() { return array( 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, + 'readonly' => $this->readonly, + 'destructive' => $this->destructive, + 'idempotent' => $this->idempotent, ); } From 5f362a2a7dd8da8731a92b389af24d038d0f4f48 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 16:31:38 +0800 Subject: [PATCH 19/82] Added MCP Server Tests --- .github/workflows/tests.yml | 2 +- includes/mcp/class-convertkit-mcp.php | 5 -- tests/Integration/MCPTest.php | 111 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 tests/Integration/MCPTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b72cd3642..62c9b173f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: WORDPRESS_V3_BLOCK_EDITOR_ENABLED: true WORDPRESS_DB_SQL_DUMP_FILE: tests/Support/Data/dump.sql INSTALL_PLUGINS: "admin-menu-editor autoptimize beaver-builder-lite-version block-visibility contact-form-7 classic-editor custom-post-type-ui debloat elementor forminator jetpack-boost mailchimp-for-wp rocket-lazy-load woocommerce wordpress-seo wpforms-lite litespeed-cache wp-crontrol wp-super-cache w3-total-cache wp-fastest-cache wp-optimize sg-cachepress" # Don't include this repository's Plugin here. - INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip" # URLs to specific third party Plugins + INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip https://github.com/WordPress/mcp-adapter/releases/download/v0.5.0/mcp-adapter.zip" # URLs to specific third party Plugins INSTALL_THEMES_URLS: "http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/03/Divi_5.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/01/impeka.zip" CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} # ConvertKit API Key, stored in the repository's Settings > Secrets CONVERTKIT_API_SECRET: ${{ secrets.CONVERTKIT_API_SECRET }} # ConvertKit API Secret, stored in the repository's Settings > Secrets diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index e7ad2a6f5..9a744ec54 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -144,11 +144,6 @@ public function register_mcp_server( $adapter ) { // Get abilities. $abilities = convertkit_get_abilities(); - // Bail if no abilities are available. - if ( ! count( $abilities ) ) { - return; - } - // Build array of ability names. $ability_names = array(); foreach ( $abilities as $ability ) { diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php new file mode 100644 index 000000000..360178873 --- /dev/null +++ b/tests/Integration/MCPTest.php @@ -0,0 +1,111 @@ +dispatch( $request ); + + // Assert response is unsuccessful. + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Test that the Kit MCP server is registered with the MCP Adapter and + * exposes its discovery endpoint at /wp-json/kit-mcp/v1. + * + * @since 3.4.0 + */ + public function testKitMCPServerCreated() + { + // Create and become administrator. + $this->actAsAdministrator(); + + // Make request. + $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); + $request->set_header('Content-Type', 'application/json'); + $request->set_body( + wp_json_encode( + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => new \stdClass(), + 'clientInfo' => [ + 'name' => 'test', + 'version' => '1.0', + ], + ], + ] + ) + ); + $response = rest_get_server()->dispatch($request); + + // Assert the discovery endpoint is registered and responds successfully. + $this->assertSame(200, $response->get_status()); + + // Assert the response identifies itself as the Kit MCP server. + $data = $response->get_data(); + $this->assertSame('Kit MCP', $data['result']->serverInfo['name'] ?? null); + } + + /** + * Act as an administrator user. + * + * @since 3.4.0 + */ + private function actAsAdministrator() + { + $administrator_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $administrator_id ); + } +} From 8e37e2e7990d4d9aec2562102114f35d818c0739 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:07:29 +0800 Subject: [PATCH 20/82] Load MCP Adapter as Composer Dependency --- .distignore | 2 -- .github/workflows/deploy.yml | 2 ++ .github/workflows/tests-backward-compat.yml | 2 ++ .github/workflows/tests.yml | 4 +++- .scripts/create-plugin-zip.sh | 2 -- composer.json | 3 ++- tests/Integration/MCPTest.php | 2 -- wp-convertkit.php | 7 +++++++ 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.distignore b/.distignore index edab61b68..b583cbafc 100644 --- a/.distignore +++ b/.distignore @@ -7,8 +7,6 @@ /node_modules /resources/frontend/css/*.map /tests -/vendor/autoload.php -/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0f6bfecbf..a75769b66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,11 +43,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.github/workflows/tests-backward-compat.yml b/.github/workflows/tests-backward-compat.yml index 97e7a5170..97e7d7b2a 100644 --- a/.github/workflows/tests-backward-compat.yml +++ b/.github/workflows/tests-backward-compat.yml @@ -311,11 +311,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 62c9b173f..826e243d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: WORDPRESS_V3_BLOCK_EDITOR_ENABLED: true WORDPRESS_DB_SQL_DUMP_FILE: tests/Support/Data/dump.sql INSTALL_PLUGINS: "admin-menu-editor autoptimize beaver-builder-lite-version block-visibility contact-form-7 classic-editor custom-post-type-ui debloat elementor forminator jetpack-boost mailchimp-for-wp rocket-lazy-load woocommerce wordpress-seo wpforms-lite litespeed-cache wp-crontrol wp-super-cache w3-total-cache wp-fastest-cache wp-optimize sg-cachepress" # Don't include this repository's Plugin here. - INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip https://github.com/WordPress/mcp-adapter/releases/download/v0.5.0/mcp-adapter.zip" # URLs to specific third party Plugins + INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip" # URLs to specific third party Plugins INSTALL_THEMES_URLS: "http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/03/Divi_5.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/01/impeka.zip" CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} # ConvertKit API Key, stored in the repository's Settings > Secrets CONVERTKIT_API_SECRET: ${{ secrets.CONVERTKIT_API_SECRET }} # ConvertKit API Secret, stored in the repository's Settings > Secrets @@ -350,11 +350,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.scripts/create-plugin-zip.sh b/.scripts/create-plugin-zip.sh index c7d170886..4de10c744 100644 --- a/.scripts/create-plugin-zip.sh +++ b/.scripts/create-plugin-zip.sh @@ -14,11 +14,9 @@ zip -r convertkit.zip . \ -x ".wordpress-org/*" \ -x "log/*" \ -x "tests/*" \ --x "vendor/composer/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/.github" \ -x "vendor/convertkit/convertkit-wordpress-libraries/tests/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/composer.json" \ --x "vendor/autoload.php" \ -x "*.distignore" \ -x "*.env.*" \ -x ".gitignore" \ diff --git a/composer.json b/composer.json index 37be0f728..3ff418d75 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "type": "project", "license": "GPLv3", "require": { - "convertkit/convertkit-wordpress-libraries": "2.1.6" + "convertkit/convertkit-wordpress-libraries": "2.1.6", + "wordpress/mcp-adapter": "^0.5.0" }, "require-dev": { "php-webdriver/webdriver": "^1.0", diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php index 360178873..3f79ea1c5 100644 --- a/tests/Integration/MCPTest.php +++ b/tests/Integration/MCPTest.php @@ -27,7 +27,6 @@ public function setUp(): void { parent::setUp(); activate_plugins('convertkit/wp-convertkit.php'); - activate_plugins('mcp-adapter/mcp-adapter.php'); } /** @@ -37,7 +36,6 @@ public function setUp(): void */ public function tearDown(): void { - deactivate_plugins('mcp-adapter/mcp-adapter.php'); deactivate_plugins('convertkit/wp-convertkit.php'); parent::tearDown(); } diff --git a/wp-convertkit.php b/wp-convertkit.php index 3ba3f3e99..35e5bc708 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,6 +31,13 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); +// Load the WordPress MCP Adapter via composer's autoload. +// Only load on PHP 7.4+, since the MCP Adapter requires it. +if ( version_compare( PHP_VERSION, '7.4', '>=' ) + && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; +} + // Load shared classes, if they have not been included by another Kit Plugin. if ( ! trait_exists( 'ConvertKit_API_Traits' ) && ! trait_exists( 'ConvertKit_API\ConvertKit_API_Traits' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php'; From d1ecb12881e47e8ad8289786807f4e143b2a7472 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:15:23 +0800 Subject: [PATCH 21/82] Load WordPress MCP Adapter directly --- .distignore | 2 ++ .github/workflows/deploy.yml | 1 - .github/workflows/tests-backward-compat.yml | 1 - .github/workflows/tests.yml | 1 - wp-convertkit.php | 8 ++++---- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.distignore b/.distignore index b583cbafc..edab61b68 100644 --- a/.distignore +++ b/.distignore @@ -7,6 +7,8 @@ /node_modules /resources/frontend/css/*.map /tests +/vendor/autoload.php +/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a75769b66..ec195ab3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,7 +43,6 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" - "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests-backward-compat.yml b/.github/workflows/tests-backward-compat.yml index 97e7d7b2a..9c98cb79b 100644 --- a/.github/workflows/tests-backward-compat.yml +++ b/.github/workflows/tests-backward-compat.yml @@ -311,7 +311,6 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" - "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 826e243d1..a08ee7ca5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -350,7 +350,6 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" - "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/wp-convertkit.php b/wp-convertkit.php index 35e5bc708..cb7b4d9d0 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,11 +31,11 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); -// Load the WordPress MCP Adapter via composer's autoload. -// Only load on PHP 7.4+, since the MCP Adapter requires it. +// Load the WordPress MCP Adapter directly. PHP 7.4+ required. if ( version_compare( PHP_VERSION, '7.4', '>=' ) - && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { - require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; + && ! class_exists( 'WP\\MCP\\Plugin' ) + && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php' ) ) { + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php'; } // Load shared classes, if they have not been included by another Kit Plugin. From f9e6af223883b3ca437f6221331afe3899ac89ce Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:23:01 +0800 Subject: [PATCH 22/82] Initialize MCP Adapter --- .distignore | 2 -- .github/workflows/deploy.yml | 1 + .github/workflows/tests-backward-compat.yml | 1 + .github/workflows/tests.yml | 1 + wp-convertkit.php | 12 ++++++------ 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.distignore b/.distignore index edab61b68..b583cbafc 100644 --- a/.distignore +++ b/.distignore @@ -7,8 +7,6 @@ /node_modules /resources/frontend/css/*.map /tests -/vendor/autoload.php -/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ec195ab3b..a75769b66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,7 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests-backward-compat.yml b/.github/workflows/tests-backward-compat.yml index 9c98cb79b..97e7d7b2a 100644 --- a/.github/workflows/tests-backward-compat.yml +++ b/.github/workflows/tests-backward-compat.yml @@ -311,6 +311,7 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a08ee7ca5..826e243d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -350,6 +350,7 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/wp-convertkit.php b/wp-convertkit.php index cb7b4d9d0..eeccab08e 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,13 +31,13 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); -// Load the WordPress MCP Adapter directly. PHP 7.4+ required. -if ( version_compare( PHP_VERSION, '7.4', '>=' ) - && ! class_exists( 'WP\\MCP\\Plugin' ) - && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php' ) ) { - require_once CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php'; +// Load Composer autoloader. Provides the WordPress MCP Adapter classes. +if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; + if ( class_exists( 'WP\\MCP\\Plugin' ) ) { + \WP\MCP\Plugin::instance(); + } } - // Load shared classes, if they have not been included by another Kit Plugin. if ( ! trait_exists( 'ConvertKit_API_Traits' ) && ! trait_exists( 'ConvertKit_API\ConvertKit_API_Traits' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php'; From bab16d0b562b5e425f78be4e09616fb64af6f5ef Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:32:35 +0800 Subject: [PATCH 23/82] Fix Coding Standards + MCP Adapter on PHP 7.2 + 7.3 --- .github/workflows/coding-standards.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 2da178f5d..ba8567d6f 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,9 +144,18 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. + # On PHP 7.2 / 7.3, ignore the platform PHP requirement so dependencies + # like wordpress/mcp-adapter (which requires PHP 7.4+) can still install. + # We're only running coding standards / static analysis here, not + # executing the MCP Adapter code, so this is safe. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: composer update + run: | + if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then + composer update --ignore-platform-req=php + else + composer update + fi - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }} From 94830d8b91af1c01201867244c48f557631718aa Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:33:28 +0800 Subject: [PATCH 24/82] Use McpAdapter::instance() to initialize the MCP Adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin::instance() is wrong, as it’ll error on WordPress versions < 6.4 due to wp_admin_notice() --- wp-convertkit.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wp-convertkit.php b/wp-convertkit.php index eeccab08e..6444631de 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -34,8 +34,13 @@ // Load Composer autoloader. Provides the WordPress MCP Adapter classes. if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; - if ( class_exists( 'WP\\MCP\\Plugin' ) ) { - \WP\MCP\Plugin::instance(); + + // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended + // integration pattern. + // + // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin + if ( class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { + \WP\MCP\Core\McpAdapter::instance(); } } // Load shared classes, if they have not been included by another Kit Plugin. From 4c9682fb859d620b7feb9b9a7d29c074a2354cd2 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:39:28 +0800 Subject: [PATCH 25/82] Coding standards --- wp-convertkit.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/wp-convertkit.php b/wp-convertkit.php index 6444631de..0bb861422 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -37,8 +37,7 @@ // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended // integration pattern. - // - // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin + // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin. if ( class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { \WP\MCP\Core\McpAdapter::instance(); } From 7799dc43768b90f9e190256be94750348729f529 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:59:49 +0800 Subject: [PATCH 26/82] MCP Adapter: Only load if Abilities API exists and PHP 7.4+ is used --- wp-convertkit.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wp-convertkit.php b/wp-convertkit.php index 0bb861422..5e2600c64 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,8 +31,9 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); -// Load Composer autoloader. Provides the WordPress MCP Adapter classes. -if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { +// Load WordPress MCP Adapter if the Abilities API is available (WordPress 6.9+) +// and PHP 7.4+ is installed. +if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) && function_exists( 'wp_register_ability' ) && version_compare( PHP_VERSION, '7.4', '>=' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended From 1caa36b8c499cc544f2e9c2ec0f59c03e53a524c Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 18:01:02 +0800 Subject: [PATCH 27/82] =?UTF-8?q?Coding=20Standards:=20Don=E2=80=99t=20gen?= =?UTF-8?q?erate=20`vendor/composer/platform=5Fcheck.php`,=20so=207.2=20an?= =?UTF-8?q?d=207.3=20coding=20standards=20can=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3ff418d75..763a127da 100644 --- a/composer.json +++ b/composer.json @@ -96,7 +96,8 @@ "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true - } + }, + "platform-check": false }, "repositories": [ { From b3208cc1b0217a9a7d88a17a7ee002a29a83afd8 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 18:12:26 +0800 Subject: [PATCH 28/82] Revert platform-check changes --- .github/workflows/coding-standards.yml | 11 +---------- .github/workflows/tests.yml | 8 ++++++++ composer.json | 3 +-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index ba8567d6f..2da178f5d 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,18 +144,9 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. - # On PHP 7.2 / 7.3, ignore the platform PHP requirement so dependencies - # like wordpress/mcp-adapter (which requires PHP 7.4+) can still install. - # We're only running coding standards / static analysis here, not - # executing the MCP Adapter code, so this is safe. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: | - if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then - composer update --ignore-platform-req=php - else - composer update - fi + run: composer update - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 826e243d1..a7fb90e13 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -332,6 +332,14 @@ jobs: working-directory: ${{ env.PLUGIN_DIR }} run: composer update + # Install the WordPress MCP Adapter, which requires PHP 7.4+. We don't + # include it in composer.json because the Plugin must support PHP 7.1+, + # and Composer cannot conditionally require packages by PHP version. + # All test matrix entries run on PHP 7.4+, so this is always safe here. + - name: Install MCP Adapter + working-directory: ${{ env.PLUGIN_DIR }} + run: composer require wordpress/mcp-adapter:^0.5.0 + # Build the frontend CSS and JS assets - name: Run npm working-directory: ${{ env.PLUGIN_DIR }} diff --git a/composer.json b/composer.json index 763a127da..3ff418d75 100644 --- a/composer.json +++ b/composer.json @@ -96,8 +96,7 @@ "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true - }, - "platform-check": false + } }, "repositories": [ { From e462e246ed5895381296719f7c52a398792d2399 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 18:54:28 +0800 Subject: [PATCH 29/82] Coding Standards: Remove `wordpress/mcp-adapter` on PHP 7.2 and 7.3 --- .github/workflows/coding-standards.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 2da178f5d..51e91270c 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,9 +144,22 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. + # --ignore-platform-req=php is required as wordpress/mcp-adapter otherwise won't install + # on PHP 7.2 and 7.3, resulting in composer errors. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: composer update + run: | + if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then + composer update --ignore-platform-req=php + else + composer update + fi + + # Remove the wordpress/mcp-adapter package. We don't need it for coding standards, and composer + # commands will fail if it's installed and using PHP 7.2 or 7.3. + - name: Remove wordpress/mcp-adapter + working-directory: ${{ env.PLUGIN_DIR }} + run: composer remove wordpress/mcp-adapter --no-update - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }} From 4798b1e0d5daab1442dacbaf8ef025df0e51d71e Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 11 May 2026 14:55:13 +0800 Subject: [PATCH 30/82] Coding standards: Remove wordpress/mcp-adapter on PHP 7.2 + 7.3 --- .github/workflows/coding-standards.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 51e91270c..1b78682b4 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -155,11 +155,11 @@ jobs: composer update fi - # Remove the wordpress/mcp-adapter package. We don't need it for coding standards, and composer - # commands will fail if it's installed and using PHP 7.2 or 7.3. + # Remove the wordpress/mcp-adapter package on PHP 7.2 or 7.3, as it requires PHP 7.4+. - name: Remove wordpress/mcp-adapter + if: ${{ matrix.php-versions == '7.2' || matrix.php-versions == '7.3' }} working-directory: ${{ env.PLUGIN_DIR }} - run: composer remove wordpress/mcp-adapter --no-update + run: composer remove wordpress/mcp-adapter --ignore-platform-req=php - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }} From 2b937681e649fc55ec4f5e90455f8a2e7580f4be Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 11 May 2026 15:14:24 +0800 Subject: [PATCH 31/82] Coding standards: Use `jq` to remove wordpress/mcp-adapter --- .github/workflows/coding-standards.yml | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 1b78682b4..346a74793 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,26 +144,12 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. - # --ignore-platform-req=php is required as wordpress/mcp-adapter otherwise won't install - # on PHP 7.2 and 7.3, resulting in composer errors. + # jq is used to remove the wordpress/mcp-adapter package from composer.json, as it requires PHP 7.4+. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} run: | - if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then - composer update --ignore-platform-req=php - else - composer update - fi - - # Remove the wordpress/mcp-adapter package on PHP 7.2 or 7.3, as it requires PHP 7.4+. - - name: Remove wordpress/mcp-adapter - if: ${{ matrix.php-versions == '7.2' || matrix.php-versions == '7.3' }} - working-directory: ${{ env.PLUGIN_DIR }} - run: composer remove wordpress/mcp-adapter --ignore-platform-req=php - - - name: Build PHP Autoloader - working-directory: ${{ env.PLUGIN_DIR }} - run: composer dump-autoload + jq 'del(.require."wordpress/mcp-adapter")' composer.json > composer.json.tmp && mv composer.json.tmp composer.json + composer update # Installs WordPress scripts for CSS and JS Coding Standards. - name: Run npm install From dc1cadff434209f30489602fe9b22504ad86b6ae Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 11 May 2026 15:26:24 +0800 Subject: [PATCH 32/82] PHPStan compat. --- includes/mcp/class-convertkit-mcp.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 9a744ec54..05e3dd3df 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -136,11 +136,6 @@ public function register_abilities() { */ public function register_mcp_server( $adapter ) { - // Bail if the adapter is not an object or does not have the create_server method. - if ( ! is_object( $adapter ) || ! method_exists( $adapter, 'create_server' ) ) { - return; - } - // Get abilities. $abilities = convertkit_get_abilities(); From fb9e790992305acac281f1fc65e6dc4cf55236f3 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 12 May 2026 10:25:08 +0800 Subject: [PATCH 33/82] Reinstate jq command --- .github/workflows/coding-standards.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 6d75fd766..af6660d57 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -118,9 +118,12 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. + # jq is used to remove the wordpress/mcp-adapter package from composer.json, as it requires PHP 7.4+. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: composer update + run: | + jq 'del(.require."wordpress/mcp-adapter")' composer.json > composer.json.tmp && mv composer.json.tmp composer.json + composer update # Installs WordPress scripts for CSS and JS Coding Standards. - name: Run npm install From f0ab76ba9a27e844e0460cb273b33341363d6fd4 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 15 May 2026 16:25:39 +0800 Subject: [PATCH 34/82] Abilites API: Block Post Helper A class to insert, update and delete a block from an existing WordPress Post --- .../class-convertkit-block-post-helper.php | 301 ++++++++++++++++++ wp-convertkit.php | 1 + 2 files changed, 302 insertions(+) create mode 100644 includes/blocks/helpers/class-convertkit-block-post-helper.php 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..9f7426825 --- /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' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), + ); + + ++$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 int|WP_Error + */ + 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 int|WP_Error + */ + 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 int|WP_Error + */ + 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/wp-convertkit.php b/wp-convertkit.php index 0e5f7187f..55641a47a 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -107,6 +107,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-form-builder-field-name.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-form-builder-field-custom.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-product.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-block-post-helper.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-product-link.php'; From 246fe7cbdb3d2c12c9b1297ce7c246394ade0283 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 15 May 2026 16:25:43 +0800 Subject: [PATCH 35/82] Started tests --- tests/Integration/BlockPostHelperTest.php | 341 ++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 tests/Integration/BlockPostHelperTest.php diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php new file mode 100644 index 000000000..46da650f6 --- /dev/null +++ b/tests/Integration/BlockPostHelperTest.php @@ -0,0 +1,341 @@ +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'] ); + } + + public function testFindWhenNoBlocksMatch() + { + $this->assertFalse(\ConvertKit_Block_Post_Helper::find( $this->postID, 'fake/block' )); + } + + public function testFindWhenPostDoesNotExist() + { + $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Block_Post_Helper::find( 999999, 'convertkit/form' )); + } + + 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'] ); + } + + 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'] ); + } + + 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'] ); + } + + 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'] ); + } + + 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'] ); + } + + 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 ); + } + + 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'] ); + } + + 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 ); + } + + 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 ); + } + + public function testDelete() + { + $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'] ); + + $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'] ); + + } + + /** + * 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 #1

+ + + +

Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium

+ + + +
Image #1
+ + + +

Item #2

+ + + + + +

Item #3

+ + + +
Image #2
+ + + + + +

Item #1

+ + + +

Item #4

+ + + +

Item #1

+ + + +

Item #5

+ + + +

Item #2

+ + + +

Item #2

+', + ] + ); + } +} From 4dab46658ebd5b8457832cd24807e0d95b4dc4a8 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 15 May 2026 16:44:47 +0800 Subject: [PATCH 36/82] Completed tests --- tests/Integration/BlockPostHelperTest.php | 561 +++++++++++++--------- 1 file changed, 327 insertions(+), 234 deletions(-) diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php index 46da650f6..ac3e0ef9a 100644 --- a/tests/Integration/BlockPostHelperTest.php +++ b/tests/Integration/BlockPostHelperTest.php @@ -18,37 +18,37 @@ class BlockPostHelperTest extends WPTestCase */ protected $tester; - /** - * Holds the ConvertKit Block Post Helper class. - * - * @since 3.4.0 - * - * @var ConvertKit_Block_Post_Helper - */ - private $block_post_helper; - - /** - * Holds the Post ID. - * - * @since 3.4.0 - * - * @var int - */ - private $postID; - - /** - * Holds the indicides of the existing Form blocks in the Post. - * - * @since 3.4.0 - * - * @var array - */ - private $formBlockIndices = [ - 10, - 16, - ]; - - private $totalBlocks = 28; + /** + * Holds the ConvertKit Block Post Helper class. + * + * @since 3.4.0 + * + * @var ConvertKit_Block_Post_Helper + */ + private $block_post_helper; + + /** + * Holds the Post ID. + * + * @since 3.4.0 + * + * @var int + */ + private $postID; + + /** + * Holds the indicides of the existing Form blocks in the Post. + * + * @since 3.4.0 + * + * @var array + */ + private $formBlockIndices = [ + 10, + 16, + ]; + + private $totalBlocks = 28; /** * Performs actions before each test. @@ -62,8 +62,8 @@ public function setUp(): void // Activate Plugin. activate_plugins('convertkit/wp-convertkit.php'); - // Create Post. - $this->postID = $this->createPost(); + // Create Post. + $this->postID = $this->createPost(); } /** @@ -79,204 +79,297 @@ public function tearDown(): void 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'] ); - } - - public function testFindWhenNoBlocksMatch() - { - $this->assertFalse(\ConvertKit_Block_Post_Helper::find( $this->postID, 'fake/block' )); - } - - public function testFindWhenPostDoesNotExist() - { - $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Block_Post_Helper::find( 999999, 'convertkit/form' )); - } - - 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'] ); - } - - 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'] ); - } - - 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'] ); - } - - 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'] ); - } - - 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'] ); - } - - 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 ); - } - - 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'] ); - } - - 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 ); - } - - 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 ); - } - - public function testDelete() - { - $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'] ); - - $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'] ); - - } - - /** - * 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( - [ + /** + * 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', @@ -335,7 +428,7 @@ private function createPost()

Item #2

', - ] - ); - } + ] + ); + } } From a8a442b88e326992b6f866a2e7ff0962c94992eb Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 15 May 2026 16:59:35 +0800 Subject: [PATCH 37/82] PHPStan compat. --- .../helpers/class-convertkit-block-post-helper.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php index 9f7426825..3fcaaab40 100644 --- a/includes/blocks/helpers/class-convertkit-block-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-block-post-helper.php @@ -36,8 +36,8 @@ public static function find( $post_id, $block_name ) { } // Parse blocks. - $blocks = parse_blocks( $post->post_content ); - $found = array(); + $blocks = parse_blocks( $post->post_content ); + $found = array(); $occurrence_index = 0; @@ -49,7 +49,7 @@ public static function find( $post_id, $block_name ) { $found[] = array( 'index' => (int) $index, 'occurrence_index' => (int) $occurrence_index, - 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), + 'attrs' => $block['attrs'], ); ++$occurrence_index; @@ -74,7 +74,7 @@ public static function find( $post_id, $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 int|WP_Error + * @return WP_Error|array */ public static function insert( $post_id, $block_name, $attrs, $position = 'append', $index = 0 ) { @@ -151,7 +151,7 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen * @param string $block_name Programmatic Block Name. * @param int $occurrence_index Position to update block. * @param array $attrs Block Attributes. - * @return int|WP_Error + * @return WP_Error|array */ public static function update( $post_id, $block_name, $occurrence_index, $attrs ) { @@ -228,7 +228,7 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs * @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 int|WP_Error + * @return WP_Error|array */ public static function delete( $post_id, $block_name, $occurrence_index ) { From fcd156fa737f0bec040b8fcc72684e82c273b679 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 15 May 2026 17:01:37 +0800 Subject: [PATCH 38/82] Remove block post helper class --- .../class-convertkit-block-post-helper.php | 298 ------------------ wp-convertkit.php | 1 - 2 files changed, 299 deletions(-) delete mode 100644 includes/blocks/class-convertkit-block-post-helper.php diff --git a/includes/blocks/class-convertkit-block-post-helper.php b/includes/blocks/class-convertkit-block-post-helper.php deleted file mode 100644 index 6d659583b..000000000 --- a/includes/blocks/class-convertkit-block-post-helper.php +++ /dev/null @@ -1,298 +0,0 @@ -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' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), - ); - - ++$occurrence_index; - } - - 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 int|WP_Error - */ - 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, - 'occurrence_index' => 0, // @TODO. - ); - - } - - /** - * 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 int|WP_Error - */ - 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, - 'occurrence_index' => $occurrence_index, - ); - - } - - /** - * 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 int|WP_Error - */ - 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, - 'occurrence_index' => $occurrence_index, - ); - - } - -} diff --git a/wp-convertkit.php b/wp-convertkit.php index 2d3092183..43a8d9713 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -97,7 +97,6 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-user.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-widgets.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block.php'; -require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-post-helper.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-broadcasts.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-form-trigger.php'; From c618f4f28cc1b961f933a773bf778b82fa3013dd Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 15 May 2026 17:15:52 +0800 Subject: [PATCH 39/82] PHPStan compat. --- includes/blocks/class-convertkit-block-form.php | 3 --- .../blocks/class-convertkit-mcp-ability-block-delete.php | 2 +- .../blocks/class-convertkit-mcp-ability-block-list.php | 4 ++-- .../blocks/class-convertkit-mcp-ability-block-update.php | 2 +- .../abilities/blocks/class-convertkit-mcp-ability-block.php | 4 ---- 5 files changed, 4 insertions(+), 11 deletions(-) diff --git a/includes/blocks/class-convertkit-block-form.php b/includes/blocks/class-convertkit-block-form.php index f91f111b4..f5e121ba8 100644 --- a/includes/blocks/class-convertkit-block-form.php +++ b/includes/blocks/class-convertkit-block-form.php @@ -27,9 +27,6 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); - // Register this block's MCP abilities. - add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); - // Enqueue scripts for this Gutenberg Block in the editor view. add_action( 'convertkit_gutenberg_enqueue_scripts', array( $this, 'enqueue_scripts_editor' ) ); 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 index 6973f0ceb..67531683b 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -25,7 +25,7 @@ class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { * * @var bool */ - private $destructive = true; + private $destructive = true; // @phpstan-ignore-line /** * Returns the verb this ability represents. 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 index ef95f78cc..2d23b5ac8 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -25,7 +25,7 @@ class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { * * @var bool */ - private $readonly = true; + private $readonly = true; // @phpstan-ignore-line /** * Sets whether the ability is idempotent. @@ -34,7 +34,7 @@ class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { * * @var bool */ - private $idempotent = true; + private $idempotent = true; // @phpstan-ignore-line /** * Returns the verb this ability represents. 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 index 321d25a09..5f6cc3fc0 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -28,7 +28,7 @@ class ConvertKit_MCP_Ability_Block_Update extends ConvertKit_MCP_Ability_Block { * * @var bool */ - private $idempotent = true; + private $idempotent = true; // @phpstan-ignore-line /** * Returns the verb this ability represents. diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php index c1d1586ba..f691e51f9 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -145,10 +145,6 @@ protected function get_input_schema_properties() { $properties = array(); $fields = $this->block->get_fields(); - if ( ! is_array( $fields ) ) { - return $properties; - } - foreach ( $fields as $field_name => $field ) { $properties[ $field_name ] = array( 'description' => isset( $field['label'] ) ? (string) $field['label'] : '', From e558396818b96797841181c7008c69dc2ebb2aa9 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 15 May 2026 18:00:51 +0800 Subject: [PATCH 40/82] Coding standards --- tests/Integration/BlockPostHelperTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php index ac3e0ef9a..207a20d75 100644 --- a/tests/Integration/BlockPostHelperTest.php +++ b/tests/Integration/BlockPostHelperTest.php @@ -48,6 +48,13 @@ class BlockPostHelperTest extends WPTestCase 16, ]; + /** + * Holds the total number of blocks in the Post. + * + * @since 3.4.0 + * + * @var int + */ private $totalBlocks = 28; /** From ae23c8f58bfa77a7cfefe028d594085eb3290b22 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:02:27 +0800 Subject: [PATCH 41/82] Abilities API: Restructure Content Helper --- includes/blocks/class-convertkit-block.php | 8 +- .../class-convertkit-content-post-helper.php | 310 ++++++++++++++++++ ...convertkit-mcp-ability-content-delete.php} | 24 +- ...convertkit-mcp-ability-content-insert.php} | 28 +- ...s-convertkit-mcp-ability-content-list.php} | 29 +- ...convertkit-mcp-ability-content-update.php} | 31 +- .../class-convertkit-mcp-ability-content.php} | 17 +- wp-convertkit.php | 11 +- 8 files changed, 385 insertions(+), 73 deletions(-) create mode 100644 includes/blocks/helpers/class-convertkit-content-post-helper.php rename includes/mcp/abilities/{blocks/class-convertkit-mcp-ability-block-delete.php => content/class-convertkit-mcp-ability-content-delete.php} (70%) rename includes/mcp/abilities/{blocks/class-convertkit-mcp-ability-block-insert.php => content/class-convertkit-mcp-ability-content-insert.php} (67%) rename includes/mcp/abilities/{blocks/class-convertkit-mcp-ability-block-list.php => content/class-convertkit-mcp-ability-content-list.php} (74%) rename includes/mcp/abilities/{blocks/class-convertkit-mcp-ability-block-update.php => content/class-convertkit-mcp-ability-content-update.php} (67%) rename includes/mcp/abilities/{blocks/class-convertkit-mcp-ability-block.php => content/class-convertkit-mcp-ability-content.php} (87%) diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 1426f5e42..c308a1bd0 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -68,10 +68,10 @@ 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 ), + new ConvertKit_MCP_Ability_Content_List( $this ), + new ConvertKit_MCP_Ability_Content_Insert( $this ), + new ConvertKit_MCP_Ability_Content_Update( $this ), + new ConvertKit_MCP_Ability_Content_Delete( $this ), ) ); diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php new file mode 100644 index 000000000..96127cde6 --- /dev/null +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -0,0 +1,310 @@ +post_content ) ? 'block' : 'shortcode'; + + } + + /** + * Returns the human-readable name of the page builder used to build the + * given Post, or false if no supported page builder is detected. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @return string|false + */ + private static function detect_page_builder( $post_id ) { + + // Elementor stores its content in the _elementor_data post meta key, + // and flags edited posts via _elementor_edit_mode. + if ( 'builder' === get_post_meta( $post_id, '_elementor_edit_mode', true ) ) { + return 'Elementor'; + } + + /** + * Filters the detected page builder for a Post. + * + * Return a non-empty string (the page builder's name) to mark the Post + * as built with an unsupported page builder, causing the Content MCP + * abilities to return an error rather than writing to post_content. + * + * @since 3.4.0 + * + * @param string|false $page_builder Detected page builder name, or false. + * @param int $post_id Post ID. + */ + return apply_filters( 'convertkit_content_post_helper_detect_page_builder', false, $post_id ); + + } + + /** + * Returns a WP_Error for an unrecognised content mechanism. Acts as a + * defensive fallback; detect_mechanism() should only ever return a known + * mechanism or a WP_Error. + * + * @since 3.4.0 + * + * @param string $mechanism The unrecognised mechanism. + * @return WP_Error + */ + private static function unsupported_mechanism_error( $mechanism ) { + + return new WP_Error( + 'convertkit_content_post_helper_unsupported_mechanism', + sprintf( + /* translators: %s: mechanism identifier */ + __( 'Unsupported content mechanism: %s.', 'convertkit' ), + $mechanism + ) + ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php similarity index 70% rename from includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php rename to includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php index 67531683b..fc21b383a 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php @@ -1,22 +1,22 @@ -delete` (e.g. `kit/form-delete`). + * Registered by an element opting in via the `convertkit_abilities` filter and + * produces an ability named `kit/-delete` (e.g. `kit/form-delete`). * * @package ConvertKit * @author ConvertKit */ -class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { +class ConvertKit_MCP_Ability_Content_Delete extends ConvertKit_MCP_Ability_Content { /** * Sets whether the ability is destructive. @@ -51,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Delete an existing %s block from a post', 'convertkit' ), + __( 'Delete an existing %s element from a post', 'convertkit' ), $this->block->get_title() ); @@ -68,7 +68,7 @@ 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' ), + __( 'Removes a single occurrence of the %1$s (%2$s) element from the given post.', 'convertkit' ), 'convertkit/' . $this->block->get_name(), $this->block->get_title() ); @@ -91,12 +91,12 @@ public function get_input_schema() { 'post_id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + 'description' => __( 'ID of the post containing the element.', 'convertkit' ), ), 'occurrence_index' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => __( 'The zero-based occurrence index of the block to delete.', 'convertkit' ), + 'description' => __( 'The zero-based occurrence index of the element to delete.', 'convertkit' ), ), ), ); @@ -127,8 +127,8 @@ public function execute_callback( $input ) { // 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 ); + // Delete the element from the post. + return ConvertKit_Content_Post_Helper::delete( $post_id, $this->block->get_name(), $occurrence_index ); } diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php similarity index 67% rename from includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php rename to includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php index c76b30edd..5c14151f7 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php @@ -1,22 +1,22 @@ -insert` (e.g. `kit/form-insert`). + * Registered by an element opting in via the `convertkit_abilities` filter and + * produces an ability named `kit/-insert` (e.g. `kit/form-insert`). * * @package ConvertKit * @author ConvertKit */ -class ConvertKit_MCP_Ability_Block_Insert extends ConvertKit_MCP_Ability_Block { +class ConvertKit_MCP_Ability_Content_Insert extends ConvertKit_MCP_Ability_Content { /** * Returns the verb this ability represents. @@ -42,7 +42,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Insert a %s block into a post', 'convertkit' ), + __( 'Insert a %s element into a post', 'convertkit' ), $this->block->get_title() ); @@ -59,7 +59,7 @@ 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' ), + __( 'Inserts a new %1$s (%2$s) element into the given post\'s content. The element can be appended (default), prepended, or positioned relative to an existing element using a zero-based index.', 'convertkit' ), 'convertkit/' . $this->block->get_name(), $this->block->get_title() ); @@ -82,22 +82,22 @@ public function get_input_schema() { 'post_id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'Page / Post / Custom Post Type ID to insert the block into.', 'convertkit' ), + 'description' => __( 'Page / Post / Custom Post Type ID to insert the element 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' ), + 'description' => __( 'Where to insert the new element. "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' ), + 'description' => __( 'When position is "index", the zero-based top-level element index at which to insert the new element.', 'convertkit' ), ), 'attrs' => array( 'type' => 'object', - 'description' => __( 'Block attributes.', 'convertkit' ), + 'description' => __( 'Element attributes.', 'convertkit' ), 'properties' => $this->get_input_schema_properties(), ), ), @@ -131,8 +131,8 @@ public function execute_callback( $input ) { $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 ); + // Insert the element into the post. + return ConvertKit_Content_Post_Helper::insert( $post_id, $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/content/class-convertkit-mcp-ability-content-list.php similarity index 74% rename from includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php rename to includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php index 2d23b5ac8..d68e6d784 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php @@ -1,22 +1,22 @@ -list` (e.g. `kit/form-list`). + * Registered by an element opting in via the `convertkit_abilities` filter and + * produces an ability named `kit/-list` (e.g. `kit/form-list`). * * @package ConvertKit * @author ConvertKit */ -class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { +class ConvertKit_MCP_Ability_Content_List extends ConvertKit_MCP_Ability_Content { /** * Sets whether the ability is readonly. @@ -60,7 +60,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'List %s blocks in a post', 'convertkit' ), + __( 'List %s elements in a post', 'convertkit' ), $this->block->get_title() ); @@ -77,7 +77,7 @@ 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' ), + __( 'Lists every occurrence of the %1$s (%2$s) element 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() ); @@ -136,11 +136,11 @@ public function get_output_schema() { 'index' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), + 'description' => __( 'Zero-based occurrence index among this element\'s appearances in the post.', 'convertkit' ), ), 'attrs' => array( 'type' => 'object', - 'description' => __( 'Block attributes for this occurrence.', 'convertkit' ), + 'description' => __( 'Element attributes for this occurrence.', 'convertkit' ), ), ), ), @@ -171,12 +171,17 @@ public function execute_callback( $input ) { ); } - // Find blocks in post. - $occurrences = ConvertKit_Block_Post_Helper::find( $post_id, 'convertkit/' . $this->block->get_name() ); + // Find element occurrences in post. + $occurrences = ConvertKit_Content_Post_Helper::find( $post_id, $this->block->get_name() ); if ( is_wp_error( $occurrences ) ) { return $occurrences; } + // Normalise a "no occurrences" result (false) to an empty array. + if ( false === $occurrences ) { + $occurrences = array(); + } + // Return result. return array( 'post_id' => $post_id, diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php similarity index 67% rename from includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php rename to includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php index 5f6cc3fc0..d7c73ca47 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php @@ -1,25 +1,22 @@ -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. + * Registered by an element opting in via the `convertkit_abilities` filter and + * produces an ability named `kit/-update` (e.g. `kit/form-update`). * * @package ConvertKit * @author ConvertKit */ -class ConvertKit_MCP_Ability_Block_Update extends ConvertKit_MCP_Ability_Block { +class ConvertKit_MCP_Ability_Content_Update extends ConvertKit_MCP_Ability_Content { /** * Sets whether the ability is idempotent. @@ -54,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Update an existing %s block in a post', 'convertkit' ), + __( 'Update an existing %s element in a post', 'convertkit' ), $this->block->get_title() ); @@ -71,7 +68,7 @@ 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' ), + __( 'Updates the attributes of a single occurrence of the %1$s (%2$s) element in the given post. By default the provided attributes are merged into the existing attributes.', 'convertkit' ), 'convertkit/' . $this->block->get_name(), $this->block->get_title() ); @@ -94,16 +91,16 @@ public function get_input_schema() { 'post_id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => __( 'Page / Post / Custom Post Type ID containing the existing block.', 'convertkit' ), + 'description' => __( 'Page / Post / Custom Post Type ID containing the existing element.', 'convertkit' ), ), 'occurrence_index' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => __( 'The zero-based occurrence index of the block to update.', 'convertkit' ), + 'description' => __( 'The zero-based occurrence index of the element to update.', 'convertkit' ), ), 'attrs' => array( 'type' => 'object', - 'description' => __( 'Block attributes to update. Any attributes not provided will be left unchanged.', 'convertkit' ), + 'description' => __( 'Element attributes to update. Any attributes not provided will be left unchanged.', 'convertkit' ), 'properties' => $this->get_input_schema_properties(), ), ), @@ -132,12 +129,12 @@ public function execute_callback( $input ) { ); } - // Get attributes, position and index. + // Get attributes and occurrence 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 ); + // Update the element in the post. + return ConvertKit_Content_Post_Helper::update( $post_id, $this->block->get_name(), $occurrence_index, $attrs ); } diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php similarity index 87% rename from includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php rename to includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php index f691e51f9..8abfcea81 100644 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php @@ -1,22 +1,21 @@ block->get_name() . '-block-' . $this->get_verb(); + return 'kit/' . $this->block->get_name() . '-' . $this->get_verb(); } diff --git a/wp-convertkit.php b/wp-convertkit.php index 014ee0bcb..085628b2f 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -108,16 +108,17 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-form-builder-field-custom.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-product.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-block-post-helper.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-content-post-helper.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; 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/mcp/abilities/content/class-convertkit-mcp-ability-content.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-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'; From 9ac58dcbda075f22e9a9def166d7c2232fd0428d Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:09:56 +0800 Subject: [PATCH 42/82] PHPStan compat. --- .../class-convertkit-content-post-helper.php | 55 ++++--------------- 1 file changed, 12 insertions(+), 43 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index 96127cde6..68b089999 100644 --- a/includes/blocks/helpers/class-convertkit-content-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -7,17 +7,16 @@ */ /** - * Mechanism-agnostic helper to find, insert, update and delete a Kit feature - * (form, broadcasts, product, etc.) within a WordPress Post's content. + * Mechanism-agnostic helper to find, insert, update and delete a Kit element + * (Broadcast, Form, Form Trigger, Product) within a WordPress Post's content. * * This is the entry point used by the Content MCP abilities. It decides how the - * given Post stores its content — Gutenberg blocks or Classic editor / shortcode + * given Post stores its content — Gutenberg blocks, Classic editor / shortcode * markup — and delegates to the appropriate mechanism-specific helper: * * - ConvertKit_Block_Post_Helper for block-based content. - * - ConvertKit_Shortcode_Post_Helper for Classic editor / shortcode content. * - * Callers pass a feature name (e.g. `form`); this class applies the correct + * Callers pass an element name (e.g. `form`); this class applies the correct * prefix for the chosen mechanism (`convertkit/form` for blocks, * `convertkit_form` for shortcodes). * @@ -46,18 +45,14 @@ public static function find( $post_id, $feature_name ) { return $mechanism; } + // Find the element in the post, depending on the mechanism. + // A switch is used as shortcodes and other mechanisms will be supported in the future. switch ( $mechanism ) { case 'block': return ConvertKit_Block_Post_Helper::find( $post_id, 'convertkit/' . $feature_name ); - - case 'shortcode': - return ConvertKit_Shortcode_Post_Helper::find( - $post_id, - 'convertkit_' . $feature_name - ); } return self::unsupported_mechanism_error( $mechanism ); @@ -84,6 +79,8 @@ public static function insert( $post_id, $feature_name, $attrs, $position = 'app return $mechanism; } + // Insert the element into the post, depending on the mechanism. + // A switch is used as shortcodes and other mechanisms will be supported in the future. switch ( $mechanism ) { case 'block': return ConvertKit_Block_Post_Helper::insert( @@ -93,23 +90,6 @@ public static function insert( $post_id, $feature_name, $attrs, $position = 'app $position, $index ); - - case 'shortcode': - // The Form Builder feature is block-only; it has no shortcode. - if ( 'form-builder' === $feature_name ) { - return new WP_Error( - 'convertkit_content_post_helper_feature_block_only', - __( 'The Form Builder can only be added to block-based content.', 'convertkit' ) - ); - } - - return ConvertKit_Shortcode_Post_Helper::insert( - $post_id, - 'convertkit_' . $feature_name, - $attrs, - $position, - $index - ); } return self::unsupported_mechanism_error( $mechanism ); @@ -136,6 +116,8 @@ public static function update( $post_id, $feature_name, $occurrence_index, $attr return $mechanism; } + // Updates the existing occurrence of the element in the post, depending on the mechanism. + // A switch is used as shortcodes and other mechanisms will be supported in the future. switch ( $mechanism ) { case 'block': return ConvertKit_Block_Post_Helper::update( @@ -144,14 +126,6 @@ public static function update( $post_id, $feature_name, $occurrence_index, $attr $occurrence_index, $attrs ); - - case 'shortcode': - return ConvertKit_Shortcode_Post_Helper::update( - $post_id, - 'convertkit_' . $feature_name, - $occurrence_index, - $attrs - ); } return self::unsupported_mechanism_error( $mechanism ); @@ -177,6 +151,8 @@ public static function delete( $post_id, $feature_name, $occurrence_index ) { return $mechanism; } + // Delete the element from the post, depending on the mechanism. + // A switch is used as shortcodes and other mechanisms will be supported in the future. switch ( $mechanism ) { case 'block': return ConvertKit_Block_Post_Helper::delete( @@ -184,13 +160,6 @@ public static function delete( $post_id, $feature_name, $occurrence_index ) { 'convertkit/' . $feature_name, $occurrence_index ); - - case 'shortcode': - return ConvertKit_Shortcode_Post_Helper::delete( - $post_id, - 'convertkit_' . $feature_name, - $occurrence_index - ); } return self::unsupported_mechanism_error( $mechanism ); From 3101d8a13439359a522670ebdc550bc020071903 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:18:04 +0800 Subject: [PATCH 43/82] Abilities API: Shortcode Support --- .../class-convertkit-content-post-helper.php | 30 ++ ...class-convertkit-shortcode-post-helper.php | 440 ++++++++++++++++++ wp-convertkit.php | 1 + 3 files changed, 471 insertions(+) create mode 100644 includes/blocks/helpers/class-convertkit-shortcode-post-helper.php diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index 68b089999..01225b6aa 100644 --- a/includes/blocks/helpers/class-convertkit-content-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -53,6 +53,12 @@ public static function find( $post_id, $feature_name ) { $post_id, 'convertkit/' . $feature_name ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::find( + $post_id, + 'convertkit_' . $feature_name + ); } return self::unsupported_mechanism_error( $mechanism ); @@ -90,6 +96,15 @@ public static function insert( $post_id, $feature_name, $attrs, $position = 'app $position, $index ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::insert( + $post_id, + 'convertkit_' . $feature_name, + $attrs, + $position, + $index + ); } return self::unsupported_mechanism_error( $mechanism ); @@ -126,6 +141,14 @@ public static function update( $post_id, $feature_name, $occurrence_index, $attr $occurrence_index, $attrs ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::update( + $post_id, + 'convertkit_' . $feature_name, + $occurrence_index, + $attrs + ); } return self::unsupported_mechanism_error( $mechanism ); @@ -160,6 +183,13 @@ public static function delete( $post_id, $feature_name, $occurrence_index ) { 'convertkit/' . $feature_name, $occurrence_index ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::delete( + $post_id, + 'convertkit_' . $feature_name, + $occurrence_index + ); } return self::unsupported_mechanism_error( $mechanism ); diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php new file mode 100644 index 000000000..658fb7156 --- /dev/null +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -0,0 +1,440 @@ +post_content, $shortcode_tag ); + $found = array(); + + foreach ( $matches as $occurrence_index => $match ) { + $found[] = array( + // Shortcodes have no top-level block array, so index and + // occurrence_index are the same value, keeping the return + // shape identical to ConvertKit_Block_Post_Helper::find(). + 'index' => (int) $occurrence_index, + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => self::parse_attrs( $match ), + ); + } + + // If no shortcodes found, return false. + if ( empty( $found ) ) { + return false; + } + + return $found; + + } + + /** + * Inserts a new Kit shortcode into the Post's content at the specified + * position. + * + * For shortcodes, positions are resolved against the Post's top-level + * paragraphs (text separated by blank lines): + * + * - prepend Insert before all existing content. + * - append Insert after all existing content. + * - index Insert after the Nth top-level paragraph. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $shortcode_tag Shortcode tag (e.g. `convertkit_form`). + * @param array $attrs Shortcode attributes. + * @param string $position One of 'prepend', 'append', 'index'. + * @param int $index Zero-based paragraph index; only used when $position is 'index'. + * @return WP_Error|array + */ + public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'append', $index = 0 ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_insert_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Build the shortcode string to insert. + $shortcode = self::build_shortcode( $shortcode_tag, $attrs ); + + // Split content into top-level paragraphs. + $paragraphs = self::split_paragraphs( $post->post_content ); + + // Resolve $position into a concrete zero-based splice point. + switch ( $position ) { + case 'prepend': + $insert_at = 0; + break; + + case 'index': + // Insert after the Nth paragraph, clamped to the valid range. + $insert_at = max( 0, min( (int) $index + 1, count( $paragraphs ) ) ); + break; + + case 'append': + default: + $insert_at = count( $paragraphs ); + break; + } + + // Splice in the new shortcode as its own paragraph. + array_splice( $paragraphs, $insert_at, 0, array( $shortcode ) ); + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => self::join_paragraphs( $paragraphs ), + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the index the shortcode was inserted at. + return array( + 'post_id' => $post_id, + 'index' => $insert_at, + ); + + } + + /** + * Updates the attributes of an existing Kit shortcode in the Post's content. + * + * Provided attributes are merged into the existing attributes. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $shortcode_tag Shortcode tag (e.g. `convertkit_form`). + * @param int $occurrence_index Zero-based occurrence index to update. + * @param array $attrs Shortcode attributes to merge in. + * @return WP_Error|array + */ + public static function update( $post_id, $shortcode_tag, $occurrence_index, $attrs ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_update_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Match all occurrences of the shortcode. + $matches = self::match_shortcodes( $post->post_content, $shortcode_tag ); + + // Bail if the requested occurrence does not exist. + if ( ! isset( $matches[ (int) $occurrence_index ] ) ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_occurrence_not_found', + sprintf( + /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */ + __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ), + $shortcode_tag, + (int) $occurrence_index, + $post_id + ) + ); + } + + // Build the replacement shortcode, merging new attributes over existing. + $match = $matches[ (int) $occurrence_index ]; + $merged_attrs = array_merge( self::parse_attrs( $match ), (array) $attrs ); + $replacement = self::build_shortcode( $shortcode_tag, $merged_attrs ); + + // Replace the matched shortcode text with the rebuilt shortcode. + $content = self::replace_match( $post->post_content, $match, $replacement ); + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $content, + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index that was updated. + return array( + 'post_id' => $post_id, + 'index' => (int) $occurrence_index, + ); + + } + + /** + * Deletes a specific Kit shortcode from the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $shortcode_tag Shortcode tag (e.g. `convertkit_form`). + * @param int $occurrence_index Zero-based occurrence index to delete. + * @return WP_Error|array + */ + public static function delete( $post_id, $shortcode_tag, $occurrence_index ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_delete_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Match all occurrences of the shortcode. + $matches = self::match_shortcodes( $post->post_content, $shortcode_tag ); + + // Bail if the requested occurrence does not exist. + if ( ! isset( $matches[ (int) $occurrence_index ] ) ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_occurrence_not_found', + sprintf( + /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */ + __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ), + $shortcode_tag, + (int) $occurrence_index, + $post_id + ) + ); + } + + // Remove the matched shortcode text from the content. + $content = self::replace_match( $post->post_content, $matches[ (int) $occurrence_index ], '' ); + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $content, + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index that was deleted. + return array( + 'post_id' => $post_id, + 'index' => (int) $occurrence_index, + ); + + } + + /** + * Returns all matches of the given shortcode tag within the content, in + * document order. + * + * Each match is an array: 'text' (the full matched shortcode string) and + * 'offset' (its byte offset within the content). The offset is used by + * replace_match() to target a specific occurrence even when several + * occurrences have identical text. + * + * @since 3.4.0 + * + * @param string $content Post content. + * @param string $shortcode_tag Shortcode tag. + * @return array + */ + private static function match_shortcodes( $content, $shortcode_tag ) { + + // Build a shortcode regex scoped to this single tag. + $pattern = get_shortcode_regex( array( $shortcode_tag ) ); + + // Bail if there are no matches. + if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches, PREG_OFFSET_CAPTURE ) ) { + return array(); + } + + $found = array(); + foreach ( $matches[0] as $match ) { + $found[] = array( + 'text' => $match[0], + 'offset' => (int) $match[1], + ); + } + + return $found; + + } + + /** + * Parses the attributes of a single matched shortcode into a key/value + * array. + * + * @since 3.4.0 + * + * @param array $match A match from match_shortcodes(). + * @return array + */ + private static function parse_attrs( $match ) { + + $attrs = shortcode_parse_atts( $match['text'] ); + + // shortcode_parse_atts() returns a string for an empty shortcode, and + // the parsed array includes the tag name itself as element 0; strip + // any non-string keys so only named attributes remain. + if ( ! is_array( $attrs ) ) { + return array(); + } + + foreach ( array_keys( $attrs ) as $key ) { + if ( ! is_string( $key ) ) { + unset( $attrs[ $key ] ); + } + } + + return $attrs; + + } + + /** + * Builds a self-closing shortcode string from a tag and attributes. + * + * @since 3.4.0 + * + * @param string $shortcode_tag Shortcode tag. + * @param array $attrs Shortcode attributes. + * @return string + */ + private static function build_shortcode( $shortcode_tag, $attrs ) { + + $shortcode = '[' . $shortcode_tag; + + foreach ( (array) $attrs as $key => $value ) { + // Skip empty attribute names. + if ( ! is_string( $key ) || '' === $key ) { + continue; + } + + $shortcode .= sprintf( ' %s="%s"', $key, esc_attr( (string) $value ) ); + } + + $shortcode .= ']'; + + return $shortcode; + + } + + /** + * Replaces a single matched shortcode occurrence with the replacement + * string, targeting it by byte offset so identical occurrences elsewhere + * are left untouched. + * + * @since 3.4.0 + * + * @param string $content Post content. + * @param array $match A match from match_shortcodes(). + * @param string $replacement Replacement string (empty string to delete). + * @return string + */ + private static function replace_match( $content, $match, $replacement ) { + + return substr_replace( + $content, + $replacement, + $match['offset'], + strlen( $match['text'] ) + ); + + } + + /** + * Splits Post content into top-level paragraphs (text blocks separated by + * one or more blank lines), discarding empty fragments. + * + * @since 3.4.0 + * + * @param string $content Post content. + * @return array + */ + private static function split_paragraphs( $content ) { + + // Bail with an empty array if there is no content. + if ( '' === trim( (string) $content ) ) { + return array(); + } + + $paragraphs = preg_split( '/\R{2,}/', trim( (string) $content ) ); + + return is_array( $paragraphs ) ? array_values( array_filter( $paragraphs, 'strlen' ) ) : array(); + + } + + /** + * Joins paragraphs back into Post content, separated by blank lines. + * + * @since 3.4.0 + * + * @param array $paragraphs Paragraphs. + * @return string + */ + private static function join_paragraphs( $paragraphs ) { + + return implode( "\n\n", $paragraphs ); + + } + +} diff --git a/wp-convertkit.php b/wp-convertkit.php index 085628b2f..1b6d3ea7e 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -109,6 +109,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-product.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-block-post-helper.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-content-post-helper.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-product-link.php'; From f3829e698c94959607f7fa9c3f7bbc72b291376a Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:19:39 +0800 Subject: [PATCH 44/82] =?UTF-8?q?Rename=20=E2=80=98feature=E2=80=99=20to?= =?UTF-8?q?=20=E2=80=98element=E2=80=99=20to=20be=20consistent=20with=20ot?= =?UTF-8?q?her=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../class-convertkit-content-post-helper.php | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index 68b089999..cc3b867ac 100644 --- a/includes/blocks/helpers/class-convertkit-content-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -29,15 +29,15 @@ class ConvertKit_Content_Post_Helper { /** - * Finds all occurrences of the given Kit feature in a Post's content. + * Finds all occurrences of the given Kit element in a Post's content. * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $feature_name Kit feature name (e.g. `form`), without prefix. + * @param string $element_name Kit Element name (e.g. `form`), without prefix. * @return WP_Error|bool|array */ - public static function find( $post_id, $feature_name ) { + public static function find( $post_id, $element_name ) { // Determine how this post stores its content. $mechanism = self::detect_mechanism( $post_id ); @@ -51,7 +51,7 @@ public static function find( $post_id, $feature_name ) { case 'block': return ConvertKit_Block_Post_Helper::find( $post_id, - 'convertkit/' . $feature_name + 'convertkit/' . $element_name ); } @@ -60,18 +60,18 @@ public static function find( $post_id, $feature_name ) { } /** - * Inserts a new occurrence of the given Kit feature into a Post's content. + * Inserts a new occurrence of the given Kit Element into a Post's content. * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $feature_name Kit feature name (e.g. `form`), without prefix. - * @param array $attrs Feature attributes. + * @param string $element_name Kit Element name (e.g. `form`), without prefix. + * @param array $attrs Element attributes. * @param string $position One of 'prepend', 'append', 'index'. * @param int $index Zero-based top-level index; only used when $position is 'index'. * @return WP_Error|array */ - public static function insert( $post_id, $feature_name, $attrs, $position = 'append', $index = 0 ) { + public static function insert( $post_id, $element_name, $attrs, $position = 'append', $index = 0 ) { // Determine how this post stores its content. $mechanism = self::detect_mechanism( $post_id ); @@ -85,7 +85,7 @@ public static function insert( $post_id, $feature_name, $attrs, $position = 'app case 'block': return ConvertKit_Block_Post_Helper::insert( $post_id, - 'convertkit/' . $feature_name, + 'convertkit/' . $element_name, $attrs, $position, $index @@ -97,18 +97,18 @@ public static function insert( $post_id, $feature_name, $attrs, $position = 'app } /** - * Updates the attributes of an existing occurrence of the given Kit feature + * Updates the attributes of an existing occurrence of the given Kit Element * in a Post's content. * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $feature_name Kit feature name (e.g. `form`), without prefix. + * @param string $element_name Kit Element name (e.g. `form`), without prefix. * @param int $occurrence_index Zero-based occurrence index to update. - * @param array $attrs Feature attributes. + * @param array $attrs Element attributes. * @return WP_Error|array */ - public static function update( $post_id, $feature_name, $occurrence_index, $attrs ) { + public static function update( $post_id, $element_name, $occurrence_index, $attrs ) { // Determine how this post stores its content. $mechanism = self::detect_mechanism( $post_id ); @@ -122,7 +122,7 @@ public static function update( $post_id, $feature_name, $occurrence_index, $attr case 'block': return ConvertKit_Block_Post_Helper::update( $post_id, - 'convertkit/' . $feature_name, + 'convertkit/' . $element_name, $occurrence_index, $attrs ); @@ -133,17 +133,17 @@ public static function update( $post_id, $feature_name, $occurrence_index, $attr } /** - * Deletes a specific occurrence of the given Kit feature from a Post's + * Deletes a specific occurrence of the given Kit Element from a Post's * content. * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $feature_name Kit feature name (e.g. `form`), without prefix. + * @param string $element_name Kit Element name (e.g. `form`), without prefix. * @param int $occurrence_index Zero-based occurrence index to delete. * @return WP_Error|array */ - public static function delete( $post_id, $feature_name, $occurrence_index ) { + public static function delete( $post_id, $element_name, $occurrence_index ) { // Determine how this post stores its content. $mechanism = self::detect_mechanism( $post_id ); @@ -157,7 +157,7 @@ public static function delete( $post_id, $feature_name, $occurrence_index ) { case 'block': return ConvertKit_Block_Post_Helper::delete( $post_id, - 'convertkit/' . $feature_name, + 'convertkit/' . $element_name, $occurrence_index ); } @@ -206,7 +206,7 @@ private static function detect_mechanism( $post_id ) { 'convertkit_content_post_helper_page_builder_unsupported', sprintf( /* translators: %s: page builder name */ - __( 'This content is built with %s, which is not yet supported. Add the Kit feature using the page builder editor instead.', 'convertkit' ), + __( 'This content is built with %s, which is not yet supported. Add the Kit Element using the page builder editor instead.', 'convertkit' ), $page_builder ) ); From 95ad6bfcab8aa8378a727d76bba707f379e2fe65 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:32:29 +0800 Subject: [PATCH 45/82] Add shortcode support --- .../class-convertkit-content-post-helper.php | 2 +- ...class-convertkit-shortcode-post-helper.php | 52 ++++++------------- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index 017f7f30b..e75b7799e 100644 --- a/includes/blocks/helpers/class-convertkit-content-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -96,7 +96,7 @@ public static function insert( $post_id, $element_name, $attrs, $position = 'app $position, $index ); - + case 'shortcode': return ConvertKit_Shortcode_Post_Helper::insert( $post_id, diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php index 658fb7156..ddf97e96d 100644 --- a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -7,15 +7,7 @@ */ /** - * Helper methods to find, insert, update and delete Kit shortcodes within a - * Classic editor WordPress Post's content. - * - * Mirrors the public API of ConvertKit_Block_Post_Helper (find / insert / - * update / delete) so that ConvertKit_Content_Post_Helper can delegate to - * either helper interchangeably. - * - * All Kit shortcodes are self-closing (e.g. `[convertkit_form form="123"]`), - * so this helper does not handle enclosing shortcode content. + * Helper methods to find, insert, update and delete shortcodes within a WordPress Post's content. * * @package ConvertKit * @author ConvertKit @@ -23,12 +15,12 @@ class ConvertKit_Shortcode_Post_Helper { /** - * Finds all occurrences of the given Kit shortcode in a Post's content. + * Finds all occurrences of the given shortcode in a Post's content. * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $shortcode_tag Shortcode tag (e.g. `convertkit_form`). + * @param string $shortcode_tag Programmatic Shortcode Tag. * @return WP_Error|bool|array */ public static function find( $post_id, $shortcode_tag ) { @@ -68,21 +60,14 @@ public static function find( $post_id, $shortcode_tag ) { } /** - * Inserts a new Kit shortcode into the Post's content at the specified + * Inserts a new shortcode into the Post's content at the specified * position. * - * For shortcodes, positions are resolved against the Post's top-level - * paragraphs (text separated by blank lines): - * - * - prepend Insert before all existing content. - * - append Insert after all existing content. - * - index Insert after the Nth top-level paragraph. - * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $shortcode_tag Shortcode tag (e.g. `convertkit_form`). - * @param array $attrs Shortcode attributes. + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @param array $attrs Shortcode Attributes. * @param string $position One of 'prepend', 'append', 'index'. * @param int $index Zero-based paragraph index; only used when $position is 'index'. * @return WP_Error|array @@ -112,7 +97,6 @@ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'ap break; case 'index': - // Insert after the Nth paragraph, clamped to the valid range. $insert_at = max( 0, min( (int) $index + 1, count( $paragraphs ) ) ); break; @@ -148,16 +132,14 @@ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'ap } /** - * Updates the attributes of an existing Kit shortcode in the Post's content. - * - * Provided attributes are merged into the existing attributes. + * Updates the attributes of an existing shortcode in the Post's content. * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $shortcode_tag Shortcode tag (e.g. `convertkit_form`). + * @param string $shortcode_tag Programmatic Shortcode Tag. * @param int $occurrence_index Zero-based occurrence index to update. - * @param array $attrs Shortcode attributes to merge in. + * @param array $attrs Shortcode Attributes. * @return WP_Error|array */ public static function update( $post_id, $shortcode_tag, $occurrence_index, $attrs ) { @@ -190,9 +172,9 @@ public static function update( $post_id, $shortcode_tag, $occurrence_index, $att } // Build the replacement shortcode, merging new attributes over existing. - $match = $matches[ (int) $occurrence_index ]; - $merged_attrs = array_merge( self::parse_attrs( $match ), (array) $attrs ); - $replacement = self::build_shortcode( $shortcode_tag, $merged_attrs ); + $match = $matches[ (int) $occurrence_index ]; + $merged_attrs = array_merge( self::parse_attrs( $match ), (array) $attrs ); + $replacement = self::build_shortcode( $shortcode_tag, $merged_attrs ); // Replace the matched shortcode text with the rebuilt shortcode. $content = self::replace_match( $post->post_content, $match, $replacement ); @@ -220,12 +202,12 @@ public static function update( $post_id, $shortcode_tag, $occurrence_index, $att } /** - * Deletes a specific Kit shortcode from the Post's content. + * Deletes a specific shortcode from the Post's content. * * @since 3.4.0 * * @param int $post_id Post ID. - * @param string $shortcode_tag Shortcode tag (e.g. `convertkit_form`). + * @param string $shortcode_tag Programmatic Shortcode Tag. * @param int $occurrence_index Zero-based occurrence index to delete. * @return WP_Error|array */ @@ -295,7 +277,7 @@ public static function delete( $post_id, $shortcode_tag, $occurrence_index ) { * @since 3.4.0 * * @param string $content Post content. - * @param string $shortcode_tag Shortcode tag. + * @param string $shortcode_tag Programmatic Shortcode Tag. * @return array */ private static function match_shortcodes( $content, $shortcode_tag ) { @@ -355,8 +337,8 @@ private static function parse_attrs( $match ) { * * @since 3.4.0 * - * @param string $shortcode_tag Shortcode tag. - * @param array $attrs Shortcode attributes. + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @param array $attrs Shortcode Attributes. * @return string */ private static function build_shortcode( $shortcode_tag, $attrs ) { From dfe298f0361bc19750e19ecf450d64ce008d87f6 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:48:04 +0800 Subject: [PATCH 46/82] Started test --- tests/Integration/ShortcodePostHelperTest.php | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 tests/Integration/ShortcodePostHelperTest.php diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php new file mode 100644 index 000000000..38aa2a1b2 --- /dev/null +++ b/tests/Integration/ShortcodePostHelperTest.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 shortcode indicies and attributes. + * + * @since 3.4.0 + */ + public function testFind() + { + // Find the shortcode. + $shortcodes = \ConvertKit_Shortcode_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 #1

+ + + +

Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium

+ + + +
Image #1
+ + + +

Item #2

+ + + + + +

Item #3

+ + + +
Image #2
+ + + + + +

Item #1

+ + + +

Item #4

+ + + +

Item #1

+ + + +

Item #5

+ + + +

Item #2

+ + + +

Item #2

+', + ] + ); + } +} From 3e610b3c2572f850880f8dc965b8d6fae24a05f8 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:55:16 +0800 Subject: [PATCH 47/82] Fix $feature_name --- .../helpers/class-convertkit-content-post-helper.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index e75b7799e..05e74f929 100644 --- a/includes/blocks/helpers/class-convertkit-content-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -57,7 +57,7 @@ public static function find( $post_id, $element_name ) { case 'shortcode': return ConvertKit_Shortcode_Post_Helper::find( $post_id, - 'convertkit_' . $feature_name + 'convertkit_' . $element_name ); } @@ -100,7 +100,7 @@ public static function insert( $post_id, $element_name, $attrs, $position = 'app case 'shortcode': return ConvertKit_Shortcode_Post_Helper::insert( $post_id, - 'convertkit_' . $feature_name, + 'convertkit_' . $element_name, $attrs, $position, $index @@ -145,7 +145,7 @@ public static function update( $post_id, $element_name, $occurrence_index, $attr case 'shortcode': return ConvertKit_Shortcode_Post_Helper::update( $post_id, - 'convertkit_' . $feature_name, + 'convertkit_' . $element_name, $occurrence_index, $attrs ); @@ -187,7 +187,7 @@ public static function delete( $post_id, $element_name, $occurrence_index ) { case 'shortcode': return ConvertKit_Shortcode_Post_Helper::delete( $post_id, - 'convertkit_' . $feature_name, + 'convertkit_' . $element_name, $occurrence_index ); } From 912fc4db28efba5ab17ad9d4bb6a6eb729af4c11 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 15:55:28 +0800 Subject: [PATCH 48/82] Split by element, not paragraph --- ...class-convertkit-shortcode-post-helper.php | 162 ++++++++++++++---- 1 file changed, 130 insertions(+), 32 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php index ddf97e96d..aaa2a1add 100644 --- a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -14,6 +14,22 @@ */ class ConvertKit_Shortcode_Post_Helper { + /** + * The element-level HTML tags treated as top-level element boundaries when + * resolving an insertion position. + * + * This is the same set WordPress' wpautop() recognises as element-level, + * so the segmentation matches how WordPress itself conceptualises Classic + * editor content. + * + * @since 3.4.0 + * + * @var string + */ + const ELEMENT_LEVEL_TAGS = 'address|article|aside|blockquote|details|dd|div|dl|dt|' . + 'figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|' . + 'main|menu|nav|ol|p|pre|section|table|ul'; + /** * Finds all occurrences of the given shortcode in a Post's content. * @@ -65,11 +81,22 @@ public static function find( $post_id, $shortcode_tag ) { * * @since 3.4.0 * + * For shortcodes, positions are resolved against the Post's top-level + * elements (the same element-level HTML elements wpautop() recognises): + * + * - prepend Insert before all existing content. + * - append Insert after all existing content. + * - index Insert after the Nth top-level element. If the content has + * no top-level elements, this falls back to 'append'. + * + * The shortcode is spliced into the content string by byte offset, so all + * other content is left byte-for-byte unchanged. + * * @param int $post_id Post ID. * @param string $shortcode_tag Programmatic Shortcode Tag. * @param array $attrs Shortcode Attributes. * @param string $position One of 'prepend', 'append', 'index'. - * @param int $index Zero-based paragraph index; only used when $position is 'index'. + * @param int $index Zero-based top-level element index; only used when $position is 'index'. * @return WP_Error|array */ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'append', $index = 0 ) { @@ -86,34 +113,55 @@ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'ap // Build the shortcode string to insert. $shortcode = self::build_shortcode( $shortcode_tag, $attrs ); + $content = $post->post_content; - // Split content into top-level paragraphs. - $paragraphs = self::split_paragraphs( $post->post_content ); + // Determine the byte offsets immediately after each top-level element. + $offsets = self::get_element_offsets( $content ); - // Resolve $position into a concrete zero-based splice point. + // Resolve $position into a concrete byte offset within the content. switch ( $position ) { case 'prepend': $insert_at = 0; break; case 'index': - $insert_at = max( 0, min( (int) $index + 1, count( $paragraphs ) ) ); + // Insert after the Nth top-level element. If no elements + // exist, or the index is beyond the last element, fall back + // to appending after all existing content. + if ( empty( $offsets ) || (int) $index >= count( $offsets ) ) { + $insert_at = strlen( $content ); + } else { + $insert_at = $offsets[ max( 0, (int) $index ) ]; + } break; case 'append': default: - $insert_at = count( $paragraphs ); + $insert_at = strlen( $content ); break; } - // Splice in the new shortcode as its own paragraph. - array_splice( $paragraphs, $insert_at, 0, array( $shortcode ) ); + // Determine the occurrence index the new shortcode will have, by + // counting how many existing occurrences of the same shortcode start + // before the insertion offset. + $occurrence_index = 0; + foreach ( self::match_shortcodes( $content, $shortcode_tag ) as $match ) { + if ( $match['offset'] < $insert_at ) { + ++$occurrence_index; + } + } + + // Splice the shortcode into the content at the resolved offset, + // wrapped in blank lines so it sits as its own top-level element. + // All other content is left byte-for-byte unchanged. + $snippet = self::pad_snippet( $shortcode, $content, $insert_at ); + $content = substr_replace( $content, $snippet, $insert_at, 0 ); // Update Post. $result = wp_update_post( array( 'ID' => $post_id, - 'post_content' => self::join_paragraphs( $paragraphs ), + 'post_content' => $content, ), true ); @@ -123,10 +171,10 @@ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'ap return $result; } - // Return the index the shortcode was inserted at. + // Return the occurrence index of the newly inserted shortcode. return array( - 'post_id' => $post_id, - 'index' => $insert_at, + 'post_id' => $post_id, + 'occurrence_index' => $occurrence_index, ); } @@ -195,8 +243,8 @@ public static function update( $post_id, $shortcode_tag, $occurrence_index, $att // Return the occurrence index that was updated. return array( - 'post_id' => $post_id, - 'index' => (int) $occurrence_index, + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, ); } @@ -259,8 +307,8 @@ public static function delete( $post_id, $shortcode_tag, $occurrence_index ) { // Return the occurrence index that was deleted. return array( - 'post_id' => $post_id, - 'index' => (int) $occurrence_index, + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, ); } @@ -384,38 +432,88 @@ private static function replace_match( $content, $match, $replacement ) { } /** - * Splits Post content into top-level paragraphs (text blocks separated by - * one or more blank lines), discarding empty fragments. + * Wraps a shortcode snippet in blank-line padding so that, once inserted + * at the given offset, it sits as its own top-level element. + * + * Padding is only added on a side that is not already preceded / followed + * by a blank line (or the start / end of the content), so inserting next + * to existing whitespace does not pile up extra blank lines. * * @since 3.4.0 * - * @param string $content Post content. - * @return array + * @param string $shortcode The shortcode string to insert. + * @param string $content The content the shortcode is being inserted into. + * @param int $offset Byte offset within $content the shortcode will be inserted at. + * @return string The padded shortcode snippet. */ - private static function split_paragraphs( $content ) { + private static function pad_snippet( $shortcode, $content, $offset ) { - // Bail with an empty array if there is no content. - if ( '' === trim( (string) $content ) ) { - return array(); - } + // Determine the text immediately before and after the insertion point. + $before = substr( $content, 0, $offset ); + $after = substr( $content, $offset ); + + // Add a leading blank line unless the shortcode is at the start of the + // content, or already preceded by a blank line. + $lead = ( '' === $before || (bool) preg_match( '/\R\R\s*$/', $before ) ) ? '' : "\n\n"; - $paragraphs = preg_split( '/\R{2,}/', trim( (string) $content ) ); + // Add a trailing blank line unless the shortcode is at the end of the + // content, or already followed by a blank line. + $trail = ( '' === $after || (bool) preg_match( '/^\s*\R\R/', $after ) ) ? '' : "\n\n"; - return is_array( $paragraphs ) ? array_values( array_filter( $paragraphs, 'strlen' ) ) : array(); + return $lead . $shortcode . $trail; } /** - * Joins paragraphs back into Post content, separated by blank lines. + * Returns the byte offset within the content immediately after each + * top-level element, in document order. + * + * A top-level element is a single top-level element-level HTML element + * (e.g. a whole `

...

`). These are the Classic content analogue of + * the top-level blocks that ConvertKit_Block_Post_Helper works against: + * counting them lets a caller-supplied `index` mean the same kind of unit + * in both mechanisms. + * + * The returned offsets are the points *after* each element, suitable for + * use as a `substr_replace()` insertion point. For content with N + * top-level elements this returns N offsets; `index` 0 inserts after the + * first element, and so on. * * @since 3.4.0 * - * @param array $paragraphs Paragraphs. - * @return string + * @param string $content Post content. + * @return int[] Zero-indexed array of byte offsets. */ - private static function join_paragraphs( $paragraphs ) { + private static function get_element_offsets( $content ) { + + // Bail with an empty array if there is no content. + if ( '' === trim( (string) $content ) ) { + return array(); + } + + // Match each top-level element-level element in document order. The + // 's' flag lets the element span multiple lines; the lazy quantifier + // and \1 backreference keep the match scoped to a single element. + // + // Note: this does not handle an element-level tag nested inside + // another of the same name (e.g. a
within a
). Such + // nesting is rare in Classic editor content, and parsing it correctly + // requires a full HTML parser, which is avoided here to keep the + // content modification surgical (see insert()). + $pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is'; + + // Bail with an empty array if no top-level elements are found. + if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) { + return array(); + } + + // Record the byte offset immediately after each matched element. + $offsets = array(); + foreach ( $matches[0] as $match ) { + $offsets[] = (int) $match[1] + strlen( $match[0] ); + } - return implode( "\n\n", $paragraphs ); + return $offsets; } From b74fc326e05189435a4c6b321dbf85581a131fb9 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 16:26:22 +0800 Subject: [PATCH 49/82] =?UTF-8?q?Remove=20unused=20=E2=80=98index=E2=80=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../class-convertkit-block-post-helper.php | 38 +++++----- ...class-convertkit-shortcode-post-helper.php | 70 ++++++++----------- ...ss-convertkit-mcp-ability-content-list.php | 6 +- .../class-convertkit-mcp-ability-content.php | 8 +-- 4 files changed, 54 insertions(+), 68 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php index 3fcaaab40..ff07a2b51 100644 --- a/includes/blocks/helpers/class-convertkit-block-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-block-post-helper.php @@ -41,13 +41,12 @@ public static function find( $post_id, $block_name ) { $occurrence_index = 0; - foreach ( $blocks as $index => $block ) { + foreach ( $blocks as $block ) { if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { continue; } $found[] = array( - 'index' => (int) $index, 'occurrence_index' => (int) $occurrence_index, 'attrs' => $block['attrs'], ); @@ -120,6 +119,15 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen // Splice in the new block. array_splice( $blocks, $insert_at, 0, array( $new_block ) ); + // Determine the occurrence index of the newly inserted block, by + // counting how many blocks of the same name precede it. + $occurrence_index = 0; + for ( $i = 0; $i < $insert_at; $i++ ) { + if ( isset( $blocks[ $i ]['blockName'] ) && $blocks[ $i ]['blockName'] === $block_name ) { + ++$occurrence_index; + } + } + // Update Post. $result = wp_update_post( array( @@ -134,10 +142,10 @@ public static function insert( $post_id, $block_name, $attrs, $position = 'appen return $result; } - // Return the index the block was inserted at. + // Return the occurrence index of the newly inserted block. return array( - 'post_id' => $post_id, - 'index' => $insert_at, + 'post_id' => $post_id, + 'occurrence_index' => $occurrence_index, ); } @@ -167,13 +175,10 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs // 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; @@ -212,10 +217,10 @@ public static function update( $post_id, $block_name, $occurrence_index, $attrs return $result; } - // Return the index the block was updated at. + // Return the occurrence index of the block that was updated. return array( - 'post_id' => $post_id, - 'index' => ( $update_at - 1 ), + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, ); } @@ -244,19 +249,16 @@ public static function delete( $post_id, $block_name, $occurrence_index ) { // 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. + // Delete the block if the occurrence index matches. if ( $block_index === (int) $occurrence_index ) { unset( $blocks[ $key ] ); $blocks = array_values( $blocks ); @@ -290,10 +292,10 @@ public static function delete( $post_id, $block_name, $occurrence_index ) { return $result; } - // Return the index the block was deleted from. + // Return the occurrence index of the block that was deleted. return array( - 'post_id' => $post_id, - 'index' => ( $delete_at - 1 ), + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, ); } diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php index aaa2a1add..b8cf5acbf 100644 --- a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -57,10 +57,8 @@ public static function find( $post_id, $shortcode_tag ) { foreach ( $matches as $occurrence_index => $match ) { $found[] = array( - // Shortcodes have no top-level block array, so index and - // occurrence_index are the same value, keeping the return - // shape identical to ConvertKit_Block_Post_Helper::find(). - 'index' => (int) $occurrence_index, + // Zero-based index of this occurrence among occurrences of + // this shortcode in the post. 'occurrence_index' => (int) $occurrence_index, 'attrs' => self::parse_attrs( $match ), ); @@ -81,17 +79,6 @@ public static function find( $post_id, $shortcode_tag ) { * * @since 3.4.0 * - * For shortcodes, positions are resolved against the Post's top-level - * elements (the same element-level HTML elements wpautop() recognises): - * - * - prepend Insert before all existing content. - * - append Insert after all existing content. - * - index Insert after the Nth top-level element. If the content has - * no top-level elements, this falls back to 'append'. - * - * The shortcode is spliced into the content string by byte offset, so all - * other content is left byte-for-byte unchanged. - * * @param int $post_id Post ID. * @param string $shortcode_tag Programmatic Shortcode Tag. * @param array $attrs Shortcode Attributes. @@ -115,7 +102,7 @@ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'ap $shortcode = self::build_shortcode( $shortcode_tag, $attrs ); $content = $post->post_content; - // Determine the byte offsets immediately after each top-level element. + // Determine the byte offset immediately after each top-level element. $offsets = self::get_element_offsets( $content ); // Resolve $position into a concrete byte offset within the content. @@ -317,10 +304,11 @@ public static function delete( $post_id, $shortcode_tag, $occurrence_index ) { * Returns all matches of the given shortcode tag within the content, in * document order. * - * Each match is an array: 'text' (the full matched shortcode string) and - * 'offset' (its byte offset within the content). The offset is used by - * replace_match() to target a specific occurrence even when several - * occurrences have identical text. + * Each match is an array of: + * - 'text' The full matched shortcode string (e.g. `[convertkit_form form="1"]`). + * - 'offset' Its byte offset within the content. + * - 'atts' The raw attribute string only (e.g. `form="1"`), suitable for + * passing directly to shortcode_parse_atts(). * * @since 3.4.0 * @@ -338,11 +326,14 @@ private static function match_shortcodes( $content, $shortcode_tag ) { return array(); } + // WordPress' shortcode regex captures the attribute string in group 3. + // With PREG_OFFSET_CAPTURE each group is a [ value, offset ] pair. $found = array(); - foreach ( $matches[0] as $match ) { + foreach ( $matches[0] as $i => $match ) { $found[] = array( 'text' => $match[0], 'offset' => (int) $match[1], + 'atts' => isset( $matches[3][ $i ][0] ) ? trim( (string) $matches[3][ $i ][0] ) : '', ); } @@ -356,20 +347,23 @@ private static function match_shortcodes( $content, $shortcode_tag ) { * * @since 3.4.0 * - * @param array $match A match from match_shortcodes(). + * @param array $shortcode A match from match_shortcodes(). * @return array */ - private static function parse_attrs( $match ) { + private static function parse_attrs( $shortcode ) { - $attrs = shortcode_parse_atts( $match['text'] ); + // Parse the raw attribute string (e.g. `form="1"`). shortcode_parse_atts() + // expects only the attributes, without the surrounding brackets or tag name. + $attrs = shortcode_parse_atts( $shortcode['atts'] ); - // shortcode_parse_atts() returns a string for an empty shortcode, and - // the parsed array includes the tag name itself as element 0; strip - // any non-string keys so only named attributes remain. + // shortcode_parse_atts() returns an empty string when there are no + // attributes; normalise that to an array. if ( ! is_array( $attrs ) ) { return array(); } + // Discard any positional (non-string keyed) attributes, keeping only + // named attributes. foreach ( array_keys( $attrs ) as $key ) { if ( ! is_string( $key ) ) { unset( $attrs[ $key ] ); @@ -416,17 +410,17 @@ private static function build_shortcode( $shortcode_tag, $attrs ) { * @since 3.4.0 * * @param string $content Post content. - * @param array $match A match from match_shortcodes(). + * @param array $atts A match from match_shortcodes(). * @param string $replacement Replacement string (empty string to delete). * @return string */ - private static function replace_match( $content, $match, $replacement ) { + private static function replace_match( $content, $atts, $replacement ) { return substr_replace( $content, $replacement, - $match['offset'], - strlen( $match['text'] ) + $atts['offset'], + strlen( $atts['text'] ) ); } @@ -435,16 +429,12 @@ private static function replace_match( $content, $match, $replacement ) { * Wraps a shortcode snippet in blank-line padding so that, once inserted * at the given offset, it sits as its own top-level element. * - * Padding is only added on a side that is not already preceded / followed - * by a blank line (or the start / end of the content), so inserting next - * to existing whitespace does not pile up extra blank lines. - * * @since 3.4.0 * * @param string $shortcode The shortcode string to insert. * @param string $content The content the shortcode is being inserted into. * @param int $offset Byte offset within $content the shortcode will be inserted at. - * @return string The padded shortcode snippet. + * @return string */ private static function pad_snippet( $shortcode, $content, $offset ) { @@ -465,8 +455,8 @@ private static function pad_snippet( $shortcode, $content, $offset ) { } /** - * Returns the byte offset within the content immediately after each - * top-level element, in document order. + * Returns the byte offset immediately after each top-level element in the + * content, in document order. * * A top-level element is a single top-level element-level HTML element * (e.g. a whole `

...

`). These are the Classic content analogue of @@ -475,9 +465,7 @@ private static function pad_snippet( $shortcode, $content, $offset ) { * in both mechanisms. * * The returned offsets are the points *after* each element, suitable for - * use as a `substr_replace()` insertion point. For content with N - * top-level elements this returns N offsets; `index` 0 inserts after the - * first element, and so on. + * use as a `substr_replace()` insertion point. * * @since 3.4.0 * diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php index d68e6d784..398b51db5 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php @@ -131,14 +131,14 @@ public function get_output_schema() { 'type' => 'array', 'items' => array( 'type' => 'object', - 'required' => array( 'index', 'attrs' ), + 'required' => array( 'occurrence_index', 'attrs' ), 'properties' => array( - 'index' => array( + 'occurrence_index' => array( 'type' => 'integer', 'minimum' => 0, 'description' => __( 'Zero-based occurrence index among this element\'s appearances in the post.', 'convertkit' ), ), - 'attrs' => array( + 'attrs' => array( 'type' => 'object', 'description' => __( 'Element attributes for this occurrence.', 'convertkit' ), ), diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php index 8abfcea81..cce7a4d43 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php @@ -108,7 +108,7 @@ public function get_output_schema() { return array( 'type' => 'object', - 'required' => array( 'post_id', 'occurrence_index', 'index' ), + 'required' => array( 'post_id', 'occurrence_index' ), 'properties' => array( 'post_id' => array( 'type' => 'integer', @@ -116,11 +116,7 @@ public function get_output_schema() { ), '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' ), + 'description' => __( 'The zero-based occurrence index of the Kit element in the post.', 'convertkit' ), ), ), ); From 60068bd676e44915e29a7db2adc8045883dbe8b3 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 16:31:54 +0800 Subject: [PATCH 50/82] Completed tests --- tests/Integration/BlockPostHelperTest.php | 11 -- tests/Integration/ShortcodePostHelperTest.php | 171 +++++++----------- 2 files changed, 68 insertions(+), 114 deletions(-) diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php index 207a20d75..3a6471315 100644 --- a/tests/Integration/BlockPostHelperTest.php +++ b/tests/Integration/BlockPostHelperTest.php @@ -99,12 +99,10 @@ public function testFind() $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'] ); } @@ -146,7 +144,6 @@ public function testInsertPrepend() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 0, $result['index'] ); } /** @@ -166,7 +163,6 @@ public function testInsertAppend() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( $this->totalBlocks + 1, $result['index'] ); } /** @@ -186,7 +182,6 @@ public function testInsertIndex() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 1, $result['index'] ); } /** @@ -207,7 +202,6 @@ public function testInsertIndexOutOfBounds() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( $this->totalBlocks + 1, $result['index'] ); } /** @@ -228,7 +222,6 @@ public function testInsertIndexNegative() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 0, $result['index'] ); } /** @@ -264,7 +257,6 @@ public function testUpdate() $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, @@ -275,7 +267,6 @@ public function testUpdate() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( $this->formBlockIndices[1], $result['index'] ); } /** @@ -324,7 +315,6 @@ public function testDelete() ); $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, @@ -333,7 +323,6 @@ public function testDelete() ); $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( $this->formBlockIndices[0], $result['index'] ); } /** diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php index 38aa2a1b2..6341ca4dd 100644 --- a/tests/Integration/ShortcodePostHelperTest.php +++ b/tests/Integration/ShortcodePostHelperTest.php @@ -94,29 +94,28 @@ public function tearDown(): void public function testFind() { // Find the shortcode. - $shortcodes = \ConvertKit_Shortcode_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'] ); + $shortcodes = \ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'convertkit_form' ); + + $this->assertIsArray( $shortcodes ); + $this->assertCount( 2, $shortcodes ); + + // Assert first matching shortcode indicies and attributes are correct. + $this->assertEquals( 0, $shortcodes[0]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $shortcodes[0]['attrs']['form'] ); + + // Assert second matching shortcode indicies and attributes are correct. + $this->assertEquals( 1, $shortcodes[1]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $shortcodes[1]['attrs']['form'] ); } /** - * Test that the find() method returns false when no blocks match the given block name. + * Test that the find() method returns false when no shortcodes match the given shortcode tag. * * @since 3.4.0 */ - public function testFindWhenNoBlocksMatch() + public function testFindWhenNoShortcodesMatch() { - $this->assertFalse(\ConvertKit_Block_Post_Helper::find( $this->postID, 'fake/block' )); + $this->assertFalse(\ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'fake_shortcode' )); } /** @@ -126,20 +125,20 @@ public function testFindWhenNoBlocksMatch() */ public function testFindWhenPostDoesNotExist() { - $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Block_Post_Helper::find( 999999, 'convertkit/form' )); + $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Shortcode_Post_Helper::find( 999999, 'convertkit_form' )); } /** - * Test that the insert() method inserts a new block at the beginning of the content + * Test that the insert() method inserts a new shortcode 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( + $result = \ConvertKit_Shortcode_Post_Helper::insert( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], position: 'prepend' ); @@ -150,35 +149,34 @@ public function testInsertPrepend() } /** - * Test that the insert() method inserts a new block at the end of the content + * Test that the insert() method inserts a new shortcode 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( + $result = \ConvertKit_Shortcode_Post_Helper::insert( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: '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. + * Test that the insert() method inserts a new shortcode at the specified index position. * * @since 3.4.0 */ public function testInsertIndex() { - $result = \ConvertKit_Block_Post_Helper::insert( + $result = \ConvertKit_Shortcode_Post_Helper::insert( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], position: 'index', index: 1 @@ -186,20 +184,19 @@ public function testInsertIndex() $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 + * Test that the insert() method inserts a new shortcode 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( + $result = \ConvertKit_Shortcode_Post_Helper::insert( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], position: 'index', index: 100 @@ -207,20 +204,19 @@ public function testInsertIndexOutOfBounds() $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 + * Test that the insert() method inserts a new shortcode at the beginning of the content when * the index is negative. * * @since 3.4.0 */ public function testInsertIndexNegative() { - $result = \ConvertKit_Block_Post_Helper::insert( + $result = \ConvertKit_Shortcode_Post_Helper::insert( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], position: 'index', index: -1 @@ -228,7 +224,6 @@ public function testInsertIndexNegative() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 0, $result['index'] ); } /** @@ -238,9 +233,9 @@ public function testInsertIndexNegative() */ public function testInsertWhenPostDoesNotExist() { - $result = \ConvertKit_Block_Post_Helper::insert( + $result = \ConvertKit_Shortcode_Post_Helper::insert( post_id: 999999, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], position: 'index', index: 0 @@ -249,33 +244,31 @@ public function testInsertWhenPostDoesNotExist() } /** - * Test that the update() method updates the attributes of an existing block. + * Test that the update() method updates the attributes of an existing shortcode. * * @since 3.4.0 */ public function testUpdate() { - $result = \ConvertKit_Block_Post_Helper::update( + $result = \ConvertKit_Shortcode_Post_Helper::update( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: '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( + $result = \ConvertKit_Shortcode_Post_Helper::update( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: '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'] ); } /** @@ -285,9 +278,9 @@ public function testUpdate() */ public function testUpdateWhenOccurrenceIndexIsOutOfBounds() { - $result = \ConvertKit_Block_Post_Helper::update( + $result = \ConvertKit_Shortcode_Post_Helper::update( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', occurrence_index: 999, attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] ); @@ -301,9 +294,9 @@ public function testUpdateWhenOccurrenceIndexIsOutOfBounds() */ public function testUpdateWhenPostDoesNotExist() { - $result = \ConvertKit_Block_Post_Helper::update( + $result = \ConvertKit_Shortcode_Post_Helper::update( post_id: 999999, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', occurrence_index: 0, attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] ); @@ -311,29 +304,27 @@ public function testUpdateWhenPostDoesNotExist() } /** - * Test that the delete() method deletes an existing block. + * Test that the delete() method deletes an existing shortcode. * * @since 3.4.0 */ public function testDelete() { - $result = \ConvertKit_Block_Post_Helper::delete( + $result = \ConvertKit_Shortcode_Post_Helper::delete( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: '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( + $result = \ConvertKit_Shortcode_Post_Helper::delete( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', occurrence_index: 0 ); $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( $this->formBlockIndices[0], $result['index'] ); } /** @@ -343,9 +334,9 @@ public function testDelete() */ public function testDeleteWhenOccurrenceIndexIsOutOfBounds() { - $result = \ConvertKit_Block_Post_Helper::delete( + $result = \ConvertKit_Shortcode_Post_Helper::delete( post_id: $this->postID, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', occurrence_index: 999 ); $this->assertInstanceOf(\WP_Error::class, $result ); @@ -358,9 +349,9 @@ public function testDeleteWhenOccurrenceIndexIsOutOfBounds() */ public function testDeleteWhenPostDoesNotExist() { - $result = \ConvertKit_Block_Post_Helper::delete( + $result = \ConvertKit_Shortcode_Post_Helper::delete( post_id: 999999, - block_name: 'convertkit/form', + shortcode_tag: 'convertkit_form', occurrence_index: 0 ); $this->assertInstanceOf(\WP_Error::class, $result ); @@ -374,67 +365,41 @@ public function testDeleteWhenPostDoesNotExist() */ private function createPost() { - // Create a Post with the given block. + // Create a Post with the given shortcode. return $this->factory->post->create( [ 'post_type' => 'page', 'post_status' => 'publish', - 'post_title' => 'Block Post', - 'post_content' => ' -

Item #1

- + 'post_title' => 'Shortcode Post', + 'post_content' => 'Item #1 - -

Item #1

- +

Item #1

- -

Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium

- +Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium - -
Image #1
- +
Image #1
- -

Item #2

- +

Item #2

- +[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"] - -

Item #3

- +Item #3 - -
Image #2
- +
Image #2
- +[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"] - -

Item #1

- +

Item #1

- -

Item #4

- +Item #4 - -

Item #1

- +

Item #1

- -

Item #5

- +Item #5 - -

Item #2

- +

Item #2

- -

Item #2

-', +

Item #2

', ] ); } From a487474ba004173c314341a71f5e9900ec69c052 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 16:32:02 +0800 Subject: [PATCH 51/82] Coding standards --- tests/Integration/ShortcodePostHelperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php index 6341ca4dd..3db10794a 100644 --- a/tests/Integration/ShortcodePostHelperTest.php +++ b/tests/Integration/ShortcodePostHelperTest.php @@ -95,7 +95,7 @@ public function testFind() { // Find the shortcode. $shortcodes = \ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'convertkit_form' ); - + $this->assertIsArray( $shortcodes ); $this->assertCount( 2, $shortcodes ); From 964140c0f93368a76fcbb5a3dc10a9c167218d12 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 16:39:25 +0800 Subject: [PATCH 52/82] Fix test --- tests/Integration/ShortcodePostHelperTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php index 3db10794a..3cc27340e 100644 --- a/tests/Integration/ShortcodePostHelperTest.php +++ b/tests/Integration/ShortcodePostHelperTest.php @@ -145,7 +145,6 @@ public function testInsertPrepend() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 0, $result['index'] ); } /** From bbc967b45ad39658c7abc55d670db56fbab9c56d Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 17:25:21 +0800 Subject: [PATCH 53/82] Abilities API: Enable MCP Server Setting --- .../class-convertkit-admin-section-mcp.php | 172 ++++++++++++++++++ includes/class-convertkit-settings-mcp.php | 121 ++++++++++++ includes/class-wp-convertkit.php | 44 +++++ wp-convertkit.php | 14 +- 4 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 admin/section/class-convertkit-admin-section-mcp.php create mode 100644 includes/class-convertkit-settings-mcp.php diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php new file mode 100644 index 000000000..53f64c04b --- /dev/null +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -0,0 +1,172 @@ + Kit > MCP. + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_Admin_Section_MCP extends ConvertKit_Admin_Section_Base { + + /** + * Constructor. + * + * @since 3.4.0 + */ + public function __construct() { + + // Define the class that reads/writes settings. + $this->settings = new ConvertKit_Settings_MCP(); + + // Define the settings key. + $this->settings_key = $this->settings::SETTINGS_NAME; + + // Define the programmatic name, Title and Tab Text. + $this->name = 'mcp'; + $this->title = __( 'MCP', 'convertkit' ); + $this->tab_text = __( 'MCP', 'convertkit' ); + + // Identify that this is beta functionality. + $this->is_beta = true; + + // Define settings sections. + $this->settings_sections = array( + 'general' => array( + 'title' => $this->title, + 'callback' => array( $this, 'print_section_info' ), + 'wrap' => true, + ), + ); + + // Register and maybe output notices for this settings screen, and the Intercom messenger. + if ( $this->on_settings_screen( $this->name ) ) { + add_action( 'convertkit_settings_base_render_before', array( $this, 'maybe_output_notices' ) ); + } + + // Enqueue scripts and CSS. + add_action( 'convertkit_admin_settings_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); + + parent::__construct(); + + } + + /** + * Enqueues scripts for the Settings > MCP screen. + * + * @since 3.4.0 + * + * @param string $section Settings section / tab (general|tools|restrict-content|broadcasts|mcp). + */ + public function enqueue_scripts( $section ) { + + // Bail if we're not on the MCP section. + if ( $section !== $this->name ) { + return; + } + + // Enqueue JS. + wp_enqueue_script( 'convertkit-admin-settings-conditional-display', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/settings-conditional-display.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); + + } + + /** + * Registers settings fields for this section. + * + * @since 3.4.0 + */ + public function register_fields() { + + // Enable. + add_settings_field( + 'enabled', + __( 'Enable MCP Server', 'convertkit' ), + array( $this, 'enabled_callback' ), + $this->settings_key, + $this->name, + array( + 'name' => 'enabled', + 'label_for' => 'enabled', + 'label' => __( 'When enabled, allows AI clients to connect to the Kit Plugin using MCP.', 'convertkit' ), + 'description' => '', + ) + ); + + } + + /** + * Prints help info for this section + * + * @since 3.4.0 + */ + public function print_section_info() { + + ?> + +

+ output_checkbox_field( + $args['name'], + 'on', + $this->settings->enabled(), + $args['label'], + $args['description'], + array( 'convertkit-conditional-display' ) + ); + + } + +} + +// Bootstrap. +add_filter( + 'convertkit_admin_settings_register_sections', + function ( $sections ) { + + // Don't register the MCP section if the Abilities API is not available (WordPress < 6.9). + if ( ! function_exists( 'wp_register_ability' ) ) { + return $sections; + } + + // Don't register the MCP section if PHP 7.4+ is not installed. + if ( version_compare( PHP_VERSION, '7.4', '<' ) ) { + return $sections; + } + + $sections['mcp'] = new ConvertKit_Admin_Section_MCP(); + return $sections; + + } +); diff --git a/includes/class-convertkit-settings-mcp.php b/includes/class-convertkit-settings-mcp.php new file mode 100644 index 000000000..9e58b50f1 --- /dev/null +++ b/includes/class-convertkit-settings-mcp.php @@ -0,0 +1,121 @@ +settings = $this->get_defaults(); + } else { + $this->settings = array_merge( $this->get_defaults(), $settings ); + } + + } + + /** + * Returns Plugin settings. + * + * @since 3.4.0 + * + * @return array + */ + public function get() { + + return $this->settings; + + } + + /** + * Returns whether the MCP server is enabled. + * + * @since 3.4.0 + * + * @return bool + */ + public function enabled() { + + return ( $this->settings['enabled'] === 'on' ? true : false ); + + } + + /** + * The default settings, used when the ConvertKit MCP Settings haven't been saved + * e.g. on a new installation. + * + * @since 2.1.0 + * + * @return array + */ + public function get_defaults() { + + $defaults = array( + 'enabled' => '', // blank|on. + ); + + /** + * The default settings, used when the ConvertKit MCP Settings haven't been saved + * e.g. on a new installation. + * + * @since 3.4.0 + * + * @param array $defaults Default settings. + */ + $defaults = apply_filters( 'convertkit_settings_mcp_get_defaults', $defaults ); + + return $defaults; + + } + + /** + * Saves the given array of settings to the WordPress options table. + * + * @since 3.4.0 + * + * @param array $settings Settings. + */ + public function save( $settings ) { + + update_option( self::SETTINGS_NAME, array_merge( $this->get(), $settings ) ); + + } + +} diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 63cfa5263..0dc5b83b7 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -64,6 +64,7 @@ public function initialize() { $this->initialize_cli_cron(); $this->initialize_frontend(); $this->initialize_global(); + $this->initialize_mcp(); } @@ -218,6 +219,49 @@ private function initialize_global() { } + /** + * Initializes the MCP server if enabled in the Plugin's settings. + * + * @since 3.4.0 + */ + public function initialize_mcp() { + + // Bail if the MCP server is not enabled. + $settings = new ConvertKit_Settings_MCP(); + if ( ! $settings->enabled() ) { + return; + } + + // Bail if the Abilities API is unavailable (WordPress < 6.9). + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + // Bail if PHP 7.4+ is not installed. + if ( version_compare( PHP_VERSION, '7.4', '<' ) ) { + return; + } + + // Bail if the WordPress MCP Adapter is not installed. + if ( ! file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { + return; + } + + // Load MCP Adapter. + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; + + // Bail if the MCP Adapter class doesn't exist - something went wrong with the autoloader. + if ( ! class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { + return; + } + + // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended + // integration pattern. + // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin. + \WP\MCP\Core\McpAdapter::instance(); + + } + /** * Runs the Plugin's initialization and update routines, which checks if * the Plugin has just been updated to a newer version, diff --git a/wp-convertkit.php b/wp-convertkit.php index 1b6d3ea7e..f46a3708d 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,18 +31,6 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); -// Load WordPress MCP Adapter if the Abilities API is available (WordPress 6.9+) -// and PHP 7.4+ is installed. -if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) && function_exists( 'wp_register_ability' ) && version_compare( PHP_VERSION, '7.4', '>=' ) ) { - require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; - - // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended - // integration pattern. - // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin. - if ( class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { - \WP\MCP\Core\McpAdapter::instance(); - } -} // Load shared classes, if they have not been included by another Kit Plugin. if ( ! trait_exists( 'ConvertKit_API_Traits' ) && ! trait_exists( 'ConvertKit_API\ConvertKit_API_Traits' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php'; @@ -89,6 +77,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-resource-tags.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings-broadcasts.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings-restrict-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-setup.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-shortcodes.php'; @@ -150,6 +139,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-broadcasts.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-form-entries.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-general.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-oauth.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-restrict-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-tools.php'; From 3a6d93b7ede5f8cdef6cc91cd7757ce75c04261a Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 17:55:38 +0800 Subject: [PATCH 54/82] Added tests --- .../plugin-screens/PluginSettingsMCPCest.php | 77 +++++++++++++++++++ tests/Integration/MCPTest.php | 34 +++++++- tests/Support/Helper/KitRestrictContent.php | 15 ++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php new file mode 100644 index 000000000..fbc26a5c5 --- /dev/null +++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php @@ -0,0 +1,77 @@ + Kit > MCP. + * + * @since 3.4.0 + */ +class PluginSettingsMCPCest +{ + /** + * Run common actions before running the test functions in this class. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function _before(EndToEndTester $I) + { + // Activate Kit Plugin. + $I->activateKitPlugin($I); + + // Setup Plugin. + $I->setupKitPlugin($I); + } + + /** + * Tests that enabling and disabling the MCP server setting works with no errors. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) + { + // Go to the Plugin's MCP Screen. + $I->loadKitSettingsMCPScreen($I); + + // Enable MCP server. + $I->checkOption('#enabled'); + $I->click('Save Changes'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the MCP server is enabled. + $I->seeCheckboxIsChecked('#enabled'); + + // Disable MCP server. + $I->uncheckOption('#enabled'); + $I->click('Save Changes'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the MCP server is disabled. + $I->dontSeeCheckboxIsChecked('#enabled'); + } + + /** + * Deactivate and reset Plugin(s) after each test, if the test passes. + * We don't use _after, as this would provide a screenshot of the Plugin + * deactivation and not the true test error. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function _passed(EndToEndTester $I) + { + $I->deactivateKitPlugin($I); + $I->resetKitPlugin($I); + } +} diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php index 3f79ea1c5..5010224f3 100644 --- a/tests/Integration/MCPTest.php +++ b/tests/Integration/MCPTest.php @@ -40,6 +40,22 @@ public function tearDown(): void parent::tearDown(); } + /** + * Test that the Kit MCP server is not created when the MCP server setting is disabled. + * + * @since 3.4.0 + */ + public function testKitMCPServerNotCreatedWhenDisabled() + { + // Make request. + $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); + $request->set_header('Content-Type', 'application/json'); + $response = rest_get_server()->dispatch($request); + + // Assert response is 404 not found. + $this->assertSame( 404, $response->get_status() ); + } + /** * Test that the /wp-json/kit-mcp/v1 REST API route returns a 401 when the user is not authorized. * @@ -47,6 +63,14 @@ public function tearDown(): void */ public function testWhenUnauthorized() { + // Enable MCP server. + update_option( + '_wp_convertkit_settings_mcp', + [ + 'enabled' => 'on', + ] + ); + // Make request. $request = new \WP_REST_Request( 'GET', '/kit-mcp/v1' ); $response = rest_get_server()->dispatch( $request ); @@ -61,11 +85,19 @@ public function testWhenUnauthorized() * * @since 3.4.0 */ - public function testKitMCPServerCreated() + public function testKitMCPServerCreatedWhenEnabled() { // Create and become administrator. $this->actAsAdministrator(); + // Enable MCP server. + update_option( + '_wp_convertkit_settings_mcp', + [ + 'enabled' => 'on', + ] + ); + // Make request. $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); $request->set_header('Content-Type', 'application/json'); diff --git a/tests/Support/Helper/KitRestrictContent.php b/tests/Support/Helper/KitRestrictContent.php index 7c325e08b..ea1882947 100644 --- a/tests/Support/Helper/KitRestrictContent.php +++ b/tests/Support/Helper/KitRestrictContent.php @@ -43,6 +43,21 @@ public function loadKitSettingsRestrictContentScreen($I) $I->checkNoWarningsAndNoticesOnScreen($I); } + /** + * Helper method to load the Plugin's Settings > MCP screen. + * + * @since 3.4.0 + * + * @param EndToEndTester $I EndToEndTester. + */ + public function loadKitSettingsMCPScreen($I) + { + $I->amOnAdminPage('options-general.php?page=_wp_convertkit_settings&tab=mcp'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + } + /** * Returns the expected default settings for Restricted Content. * From c9233aee55383b7b6a03f5459dc9b8bc6461d6bc Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 19:42:48 +0800 Subject: [PATCH 55/82] Conditionally load MCP Server and Abilities --- includes/class-wp-convertkit.php | 6 ---- includes/mcp/class-convertkit-mcp.php | 23 +++++++++++---- tests/Integration/MCPTest.php | 41 ++++++--------------------- 3 files changed, 27 insertions(+), 43 deletions(-) diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 0dc5b83b7..b4387f737 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -226,12 +226,6 @@ private function initialize_global() { */ public function initialize_mcp() { - // Bail if the MCP server is not enabled. - $settings = new ConvertKit_Settings_MCP(); - if ( ! $settings->enabled() ) { - return; - } - // Bail if the Abilities API is unavailable (WordPress < 6.9). if ( ! function_exists( 'wp_register_ability' ) ) { return; diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 05e3dd3df..22826e42e 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -64,11 +64,6 @@ class ConvertKit_MCP { */ public function __construct() { - // Bail if the Abilities API is unavailable (WordPress < 6.9). - if ( ! function_exists( 'wp_register_ability' ) ) { - return; - } - // Register the ability category. add_action( 'wp_abilities_api_categories_init', array( $this, 'register_abilities_category' ) ); @@ -87,6 +82,12 @@ public function __construct() { */ public function register_abilities_category() { + // Bail if the MCP server is not enabled. + $settings = new ConvertKit_Settings_MCP(); + if ( ! $settings->enabled() ) { + return; + } + wp_register_ability_category( self::CATEGORY_SLUG, array( @@ -104,6 +105,12 @@ public function register_abilities_category() { */ public function register_abilities() { + // Bail if the MCP server is not enabled. + $settings = new ConvertKit_Settings_MCP(); + if ( ! $settings->enabled() ) { + return; + } + // Get abilities. $abilities = convertkit_get_abilities(); @@ -136,6 +143,12 @@ public function register_abilities() { */ public function register_mcp_server( $adapter ) { + // Bail if the MCP server is not enabled. + $settings = new ConvertKit_Settings_MCP(); + if ( ! $settings->enabled() ) { + return; + } + // Get abilities. $abilities = convertkit_get_abilities(); diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php index 5010224f3..c3f9a1d95 100644 --- a/tests/Integration/MCPTest.php +++ b/tests/Integration/MCPTest.php @@ -26,6 +26,15 @@ class MCPTest extends WPRestApiTestCase public function setUp(): void { parent::setUp(); + + // Enable MCP server. + update_option( + '_wp_convertkit_settings_mcp', + [ + 'enabled' => 'on', + ] + ); + activate_plugins('convertkit/wp-convertkit.php'); } @@ -40,22 +49,6 @@ public function tearDown(): void parent::tearDown(); } - /** - * Test that the Kit MCP server is not created when the MCP server setting is disabled. - * - * @since 3.4.0 - */ - public function testKitMCPServerNotCreatedWhenDisabled() - { - // Make request. - $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); - $request->set_header('Content-Type', 'application/json'); - $response = rest_get_server()->dispatch($request); - - // Assert response is 404 not found. - $this->assertSame( 404, $response->get_status() ); - } - /** * Test that the /wp-json/kit-mcp/v1 REST API route returns a 401 when the user is not authorized. * @@ -63,14 +56,6 @@ public function testKitMCPServerNotCreatedWhenDisabled() */ public function testWhenUnauthorized() { - // Enable MCP server. - update_option( - '_wp_convertkit_settings_mcp', - [ - 'enabled' => 'on', - ] - ); - // Make request. $request = new \WP_REST_Request( 'GET', '/kit-mcp/v1' ); $response = rest_get_server()->dispatch( $request ); @@ -90,14 +75,6 @@ public function testKitMCPServerCreatedWhenEnabled() // Create and become administrator. $this->actAsAdministrator(); - // Enable MCP server. - update_option( - '_wp_convertkit_settings_mcp', - [ - 'enabled' => 'on', - ] - ); - // Make request. $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); $request->set_header('Content-Type', 'application/json'); From fa3d7bf7f6236b1d4e611e62f58ca3f3f4aa31b2 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 19:52:46 +0800 Subject: [PATCH 56/82] PHPStan compat. --- .../helpers/class-convertkit-shortcode-post-helper.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php index b8cf5acbf..5ab1797e6 100644 --- a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -356,12 +356,6 @@ private static function parse_attrs( $shortcode ) { // expects only the attributes, without the surrounding brackets or tag name. $attrs = shortcode_parse_atts( $shortcode['atts'] ); - // shortcode_parse_atts() returns an empty string when there are no - // attributes; normalise that to an array. - if ( ! is_array( $attrs ) ) { - return array(); - } - // Discard any positional (non-string keyed) attributes, keeping only // named attributes. foreach ( array_keys( $attrs ) as $key ) { From b8d137a581724351849a3ccdc3649f369c286c0a Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 20:33:02 +0800 Subject: [PATCH 57/82] Completed tests --- includes/class-wp-convertkit.php | 10 +++- includes/mcp/class-convertkit-mcp.php | 18 ------- tests/EndToEnd.suite.yml | 1 + .../plugin-screens/PluginSettingsMCPCest.php | 9 ++++ tests/Support/Helper/KitPlugin.php | 1 + tests/Support/Helper/WPRestAPI.php | 51 +++++++++++++++++++ 6 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 tests/Support/Helper/WPRestAPI.php diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index b4387f737..1e358c92f 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -226,17 +226,23 @@ private function initialize_global() { */ public function initialize_mcp() { + // Bail if the MCP server is not enabled. + $settings = new ConvertKit_Settings_MCP(); + if ( ! $settings->enabled() ) { + return; + } + // Bail if the Abilities API is unavailable (WordPress < 6.9). if ( ! function_exists( 'wp_register_ability' ) ) { return; } - // Bail if PHP 7.4+ is not installed. + // Bail if PHP 7.4+ is not installed, as this is required for the MCP Adapter classes. if ( version_compare( PHP_VERSION, '7.4', '<' ) ) { return; } - // Bail if the WordPress MCP Adapter is not installed. + // Bail if the WordPress MCP Adapter autoloader is missing. if ( ! file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { return; } diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 22826e42e..4e1930b0b 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -82,12 +82,6 @@ public function __construct() { */ public function register_abilities_category() { - // Bail if the MCP server is not enabled. - $settings = new ConvertKit_Settings_MCP(); - if ( ! $settings->enabled() ) { - return; - } - wp_register_ability_category( self::CATEGORY_SLUG, array( @@ -105,12 +99,6 @@ public function register_abilities_category() { */ public function register_abilities() { - // Bail if the MCP server is not enabled. - $settings = new ConvertKit_Settings_MCP(); - if ( ! $settings->enabled() ) { - return; - } - // Get abilities. $abilities = convertkit_get_abilities(); @@ -143,12 +131,6 @@ public function register_abilities() { */ public function register_mcp_server( $adapter ) { - // Bail if the MCP server is not enabled. - $settings = new ConvertKit_Settings_MCP(); - if ( ! $settings->enabled() ) { - return; - } - // Get abilities. $abilities = convertkit_get_abilities(); diff --git a/tests/EndToEnd.suite.yml b/tests/EndToEnd.suite.yml index 85037bdcf..0fdb63bbb 100644 --- a/tests/EndToEnd.suite.yml +++ b/tests/EndToEnd.suite.yml @@ -41,6 +41,7 @@ modules: - \Tests\Support\Helper\WPGutenberg - \Tests\Support\Helper\WPMetabox - \Tests\Support\Helper\WPNotices + - \Tests\Support\Helper\WPRestAPI - \Tests\Support\Helper\WPQuickEdit - \Tests\Support\Helper\WPWidget - \Tests\Support\Helper\Xdebug diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php index fbc26a5c5..a2c9a72b5 100644 --- a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php +++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php @@ -36,6 +36,9 @@ public function _before(EndToEndTester $I) */ public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) { + // Check that the MCP server is not registered. + $I->doesNotHaveRoute($I, '/kit-mcp'); + // Go to the Plugin's MCP Screen. $I->loadKitSettingsMCPScreen($I); @@ -49,6 +52,9 @@ public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) // Check that the MCP server is enabled. $I->seeCheckboxIsChecked('#enabled'); + // Check that the MCP server is registered. + $I->hasRoute($I, '/kit-mcp'); + // Disable MCP server. $I->uncheckOption('#enabled'); $I->click('Save Changes'); @@ -58,6 +64,9 @@ public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) // Check that the MCP server is disabled. $I->dontSeeCheckboxIsChecked('#enabled'); + + // Check that the MCP server is not registered. + $I->doesNotHaveRoute($I, '/kit-mcp'); } /** diff --git a/tests/Support/Helper/KitPlugin.php b/tests/Support/Helper/KitPlugin.php index 93fee63e9..a656c238b 100644 --- a/tests/Support/Helper/KitPlugin.php +++ b/tests/Support/Helper/KitPlugin.php @@ -575,6 +575,7 @@ public function resetKitPlugin($I) $I->dontHaveOptionInDatabase('_wp_convertkit_settings'); $I->dontHaveOptionInDatabase('_wp_convertkit_settings_restrict_content'); $I->dontHaveOptionInDatabase('_wp_convertkit_settings_broadcasts'); + $I->dontHaveOptionInDatabase('_wp_convertkit_settings_mcp'); $I->dontHaveOptionInDatabase('convertkit_version'); // Resources. diff --git a/tests/Support/Helper/WPRestAPI.php b/tests/Support/Helper/WPRestAPI.php new file mode 100644 index 000000000..26011ebe2 --- /dev/null +++ b/tests/Support/Helper/WPRestAPI.php @@ -0,0 +1,51 @@ +{yourFunctionName}. + * + * @since 3.4.0 + */ +class WPRestAPI extends \Codeception\Module +{ + /** + * Check that the given route is registered in the REST API. + * + * @since 3.4.0 + * + * @param EndToEndTester $I EndToEndTester. + * @param string $route Route. + */ + public function hasRoute($I, $route) + { + $I->assertTrue( in_array( $route, $this->getRoutes(), true ) ); + } + + /** + * Check that the given route is not registered in the REST API. + * + * @since 3.4.0 + * + * @param EndToEndTester $I EndToEndTester. + * @param string $route Route. + */ + public function doesNotHaveRoute($I, $route) + { + $I->assertFalse( in_array( $route, $this->getRoutes(), true ) ); + } + + /** + * Get the routes registered in the REST API. + * + * @since 3.4.0 + * + * @return array + */ + private function getRoutes() + { + $response = wp_remote_get( rest_url() ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + return array_keys( $body['routes'] ?? [] ); + } +} From 6a9ff89badd99d8cfeeeeffedb442d12b08b0831 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 20:47:32 +0800 Subject: [PATCH 58/82] Remove MCP unit test --- tests/Integration/MCPTest.php | 118 ---------------------------------- 1 file changed, 118 deletions(-) delete mode 100644 tests/Integration/MCPTest.php diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php deleted file mode 100644 index c3f9a1d95..000000000 --- a/tests/Integration/MCPTest.php +++ /dev/null @@ -1,118 +0,0 @@ - 'on', - ] - ); - - activate_plugins('convertkit/wp-convertkit.php'); - } - - /** - * Performs actions after each test. - * - * @since 3.4.0 - */ - public function tearDown(): void - { - deactivate_plugins('convertkit/wp-convertkit.php'); - parent::tearDown(); - } - - /** - * Test that the /wp-json/kit-mcp/v1 REST API route returns a 401 when the user is not authorized. - * - * @since 3.4.0 - */ - public function testWhenUnauthorized() - { - // Make request. - $request = new \WP_REST_Request( 'GET', '/kit-mcp/v1' ); - $response = rest_get_server()->dispatch( $request ); - - // Assert response is unsuccessful. - $this->assertSame( 401, $response->get_status() ); - } - - /** - * Test that the Kit MCP server is registered with the MCP Adapter and - * exposes its discovery endpoint at /wp-json/kit-mcp/v1. - * - * @since 3.4.0 - */ - public function testKitMCPServerCreatedWhenEnabled() - { - // Create and become administrator. - $this->actAsAdministrator(); - - // Make request. - $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); - $request->set_header('Content-Type', 'application/json'); - $request->set_body( - wp_json_encode( - [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'initialize', - 'params' => [ - 'protocolVersion' => '2024-11-05', - 'capabilities' => new \stdClass(), - 'clientInfo' => [ - 'name' => 'test', - 'version' => '1.0', - ], - ], - ] - ) - ); - $response = rest_get_server()->dispatch($request); - - // Assert the discovery endpoint is registered and responds successfully. - $this->assertSame(200, $response->get_status()); - - // Assert the response identifies itself as the Kit MCP server. - $data = $response->get_data(); - $this->assertSame('Kit MCP', $data['result']->serverInfo['name'] ?? null); - } - - /** - * Act as an administrator user. - * - * @since 3.4.0 - */ - private function actAsAdministrator() - { - $administrator_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); - wp_set_current_user( $administrator_id ); - } -} From c2c9044fc11ed7368a6a530cd90b191be1a109c4 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 20:59:57 +0800 Subject: [PATCH 59/82] Change URL to kit/mcp/v1; surface URL to use in AI clients --- admin/section/class-convertkit-admin-section-mcp.php | 6 +++++- includes/mcp/class-convertkit-mcp.php | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index 53f64c04b..8cb7f2be8 100644 --- a/admin/section/class-convertkit-admin-section-mcp.php +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -93,7 +93,11 @@ public function register_fields() { 'name' => 'enabled', 'label_for' => 'enabled', 'label' => __( 'When enabled, allows AI clients to connect to the Kit Plugin using MCP.', 'convertkit' ), - 'description' => '', + 'description' => sprintf( + '%s
%s', + __( 'Go to your AI tool to add a custom connector by pasting this URL to connect to this plugin:', 'convertkit' ), + get_site_url() . '/wp-json/kit/mcp/v1' + ), ) ); diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 4e1930b0b..d16d349ab 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -37,7 +37,7 @@ class ConvertKit_MCP { * * @var string */ - const SERVER_ID = 'kit-mcp'; + const SERVER_ID = 'kit/mcp'; /** * The REST namespace used by the MCP server. @@ -46,7 +46,7 @@ class ConvertKit_MCP { * * @var string */ - const SERVER_NAMESPACE = 'kit-mcp'; + const SERVER_NAMESPACE = 'kit/mcp'; /** * The REST version number used by the MCP server. @@ -145,7 +145,7 @@ public function register_mcp_server( $adapter ) { self::SERVER_ID, self::SERVER_NAMESPACE, self::SERVER_ROUTE, - __( 'Kit MCP', 'convertkit' ), + __( 'Kit WordPress Plugin MCP', 'convertkit' ), __( 'Exposes Kit Plugin abilities over the Model Context Protocol.', 'convertkit' ), '1.0.0', array( 'WP\\MCP\\Transport\\HttpTransport' ), From 33aa917ee46760c024611c3203c91054c156c872 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 25 May 2026 21:18:07 +0800 Subject: [PATCH 60/82] PHPStan compat. --- admin/section/class-convertkit-admin-section-base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/section/class-convertkit-admin-section-base.php b/admin/section/class-convertkit-admin-section-base.php index 650b0321d..79d3e38c0 100644 --- a/admin/section/class-convertkit-admin-section-base.php +++ b/admin/section/class-convertkit-admin-section-base.php @@ -47,7 +47,7 @@ abstract class ConvertKit_Admin_Section_Base { * * @since 1.9.6 * - * @var false|ConvertKit_Settings|ConvertKit_ContactForm7_Settings|ConvertKit_Wishlist_Settings|ConvertKit_Settings_Restrict_Content|ConvertKit_Settings_Broadcasts|ConvertKit_Forminator_Settings + * @var false|ConvertKit_Settings|ConvertKit_ContactForm7_Settings|ConvertKit_Wishlist_Settings|ConvertKit_Settings_Restrict_Content|ConvertKit_Settings_Broadcasts|ConvertKit_Forminator_Settings|ConvertKit_Settings_MCP */ public $settings; From 6cd8073140003487d2660088260a4a3e0c0861c2 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 12:26:38 +0800 Subject: [PATCH 61/82] Fix test --- .../general/plugin-screens/PluginSettingsMCPCest.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php index a2c9a72b5..3d7a009ea 100644 --- a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php +++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php @@ -50,10 +50,12 @@ public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) $I->checkNoWarningsAndNoticesOnScreen($I); // Check that the MCP server is enabled. + $I->waitForElementVisible('#enabled'); $I->seeCheckboxIsChecked('#enabled'); // Check that the MCP server is registered. - $I->hasRoute($I, '/kit-mcp'); + $I->hasRoute($I, '/kit/mcp'); + $I->hasRoute($I, '/kit/mcp/v1'); // Disable MCP server. $I->uncheckOption('#enabled'); @@ -63,10 +65,16 @@ public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) $I->checkNoWarningsAndNoticesOnScreen($I); // Check that the MCP server is disabled. + $I->waitForElementVisible('#enabled'); $I->dontSeeCheckboxIsChecked('#enabled'); + // Go to the Plugin's MCP Screen. + $I->loadKitSettingsMCPScreen($I); + $I->wait(2); + // Check that the MCP server is not registered. - $I->doesNotHaveRoute($I, '/kit-mcp'); + $I->doesNotHaveRoute($I, '/kit/mcp'); + $I->doesNotHaveRoute($I, '/kit/mcp/v1'); } /** From d0d10240f4fbf1c2d597f66cbf770b5984a0195c Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 17:32:25 +0800 Subject: [PATCH 62/82] Register Forms as MCP Tools --- includes/blocks/class-convertkit-block-form.php | 16 ++++++++++++++++ ...ass-convertkit-mcp-ability-content-delete.php | 9 ++++----- ...ass-convertkit-mcp-ability-content-insert.php | 7 +++---- ...class-convertkit-mcp-ability-content-list.php | 11 +++++------ ...ass-convertkit-mcp-ability-content-update.php | 9 ++++----- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/includes/blocks/class-convertkit-block-form.php b/includes/blocks/class-convertkit-block-form.php index f5e121ba8..7a7db6e73 100644 --- a/includes/blocks/class-convertkit-block-form.php +++ b/includes/blocks/class-convertkit-block-form.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts for this Gutenberg Block in the editor view. add_action( 'convertkit_gutenberg_enqueue_scripts', array( $this, 'enqueue_scripts_editor' ) ); @@ -101,6 +104,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Forms', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php index fc21b383a..c0081e9e9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php @@ -51,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Delete an existing %s element from a post', 'convertkit' ), + __( 'Delete Existing %s from a Post, Page or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -67,10 +67,9 @@ public function get_label() { 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) element from the given post.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* translators: Block Name */ + __( 'Removes an existing %s from a Post, Page or Custom Post using the supplied zero-based occurrence index.', 'convertkit' ), + $this->block->get_title_plural() ); } diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php index 5c14151f7..d705ec571 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php @@ -42,7 +42,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Insert a %s element into a post', 'convertkit' ), + __( 'Insert %s into a Page, Post or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -59,9 +59,8 @@ 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) element into the given post\'s content. The element can be appended (default), prepended, or positioned relative to an existing element using a zero-based index.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + __( 'Inserts a new %s in a Page, Post or Custom Post\'s content. The element can be appended (default), prepended, or inserted relative to an existing element using a zero-based index.', 'convertkit' ), + $this->block->get_title_plural() ); } diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php index 398b51db5..3e05e3bc9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php @@ -60,8 +60,8 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'List %s elements in a post', 'convertkit' ), - $this->block->get_title() + __( 'List %s in a Post, Page or Custom Post', 'convertkit' ), + $this->block->get_title_plural() ); } @@ -76,10 +76,9 @@ public function get_label() { 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) element 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() + /* translators: Block Name */ + __( 'Lists every %s in the given Post, Page or Custom Post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), + $this->block->get_title_plural() ); } diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php index d7c73ca47..a614f66c9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php @@ -51,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Update an existing %s element in a post', 'convertkit' ), + __( 'Update Existing %s in a Page, Post or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -67,10 +67,9 @@ public function get_label() { 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) element in the given post. By default the provided attributes are merged into the existing attributes.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* translators: Block Name */ + __( 'Updates the attributes of an existing %s in a Page, Post or Custom Post. The provided attributes are merged into the existing attributes.', 'convertkit' ), + $this->block->get_title_plural() ); } From 12f7a5c0551448f1042c09927d04d7689c209584 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 18:00:18 +0800 Subject: [PATCH 63/82] Abilities API: Register Resources Registers Forms, Landing Pages, Tags and Products that MCP can fetch lists of, which can be used in conjunction with other tools e.g. inserting a specific form by name on a Page, updating an existing product by name on a Post etc. --- ...-convertkit-mcp-ability-resource-forms.php | 133 ++++++++++ ...kit-mcp-ability-resource-landing-pages.php | 73 +++++ ...nvertkit-mcp-ability-resource-products.php | 73 +++++ ...s-convertkit-mcp-ability-resource-tags.php | 73 +++++ .../class-convertkit-mcp-ability-resource.php | 249 ++++++++++++++++++ includes/mcp/class-convertkit-mcp.php | 29 ++ wp-convertkit.php | 5 + 7 files changed, 635 insertions(+) create mode 100644 includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php create mode 100644 includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php create mode 100644 includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-products.php create mode 100644 includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-tags.php create mode 100644 includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource.php diff --git a/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php new file mode 100644 index 000000000..14c91b206 --- /dev/null +++ b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php @@ -0,0 +1,133 @@ + (int) ( $item['id'] ?? 0 ), + 'name' => (string) ( $item['name'] ?? '' ), + 'format' => isset( $item['format'] ) && $item['format'] !== '' ? (string) $item['format'] : 'inline', + ); + + } + + /** + * Returns the JSON Schema for a single Form item, including the `format` + * field added by map_item(). + * + * @since 3.4.0 + * + * @return array + */ + protected function get_item_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'id', 'name', 'format' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Numeric ID of the Kit Form.', 'convertkit' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Human-readable name of the Kit Form.', 'convertkit' ), + ), + 'format' => array( + 'type' => 'string', + 'enum' => array( 'inline', 'modal', 'slide in', 'sticky bar' ), + 'description' => __( 'Where and how the Form is displayed. Inline forms render in post content; modal / slide in / sticky bar forms are site-wide overlays triggered elsewhere.', 'convertkit' ), + ), + ), + ); + + } + +} diff --git a/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php new file mode 100644 index 000000000..be0d05def --- /dev/null +++ b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php @@ -0,0 +1,73 @@ +get_resource() . '-list'; + + } + + /** + * Returns the resource slug for this ability, used in the ability name + * and as a hint for clients (e.g. `forms`, `tags`, `landing-pages`, + * `products`). + * + * @since 3.4.0 + * + * @return string + */ + abstract protected function get_resource(); + + /** + * Returns the fully-qualified class name of the ConvertKit_Resource_* + * implementation backing this ability. + * + * @since 3.4.0 + * + * @return string + */ + abstract protected function get_resource_class(); + + /** + * Maps a single raw resource item from the resource class' get() method + * into the shape exposed in this ability's output. + * + * The default implementation returns just id and name. Subclasses may + * override to expose additional per-item fields (e.g. Forms includes + * `format`) — output_schema() should be overridden to match. + * + * @since 3.4.0 + * + * @param array $item Raw item from the resource class' get() method. + * @return array + */ + protected function map_item( $item ) { + + return array( + 'id' => (int) ( $item['id'] ?? 0 ), + 'name' => (string) ( $item['name'] ?? '' ), + ); + + } + + /** + * Returns the JSON Schema describing a single item in the output `items` + * array. + * + * Subclasses may override to add per-resource fields. Keep in sync with + * map_item() — both describe the same shape, one in schema form and one + * in PHP. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_item_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'id', 'name' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Numeric ID of the resource item.', 'convertkit' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Human-readable name of the resource item.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Permission callback for resource-list abilities. + * + * Listing available Kit resources is permitted for anyone who can edit + * posts — the same capability gate that allows placing a Kit element on + * a post, where these lists are typically used as a lookup. + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_list_resources', + __( 'You do not have permission to list Kit resources.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Returns the ability's input JSON Schema. + * + * Resource-list abilities take no input. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'properties' => new stdClass(), + ); + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'count', 'items' ), + 'properties' => array( + 'count' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'The number of items returned.', 'convertkit' ), + ), + 'items' => array( + 'type' => 'array', + 'description' => __( 'The resource items.', 'convertkit' ), + 'items' => $this->get_item_schema(), + ), + ), + ); + + } + + /** + * Executes the ability: instantiate the backing resource class, fetch + * its cached items, and return them mapped to this ability's output + * shape. + * + * A "no items" result (e.g. the Plugin has not yet cached this resource + * from the Kit API) is returned as a successful empty list rather than + * an error, so the model can explain the absence to the user. + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Instantiate the backing resource class. + $resource_class = $this->get_resource_class(); + if ( ! class_exists( $resource_class ) ) { + return new WP_Error( + 'convertkit_mcp_resource_class_missing', + sprintf( + /* translators: %s: Resource class name */ + __( 'The resource class "%s" does not exist.', 'convertkit' ), + $resource_class + ) + ); + } + + $resource = new $resource_class(); + + // Fetch the items from the resource cache. ConvertKit_Resource::get() + // returns false when nothing has been cached; normalise that to an + // empty array so the output shape is always consistent. + $items = $resource->get(); + if ( ! is_array( $items ) ) { + $items = array(); + } + + // Map each raw item to the ability's output shape. + $mapped = array(); + foreach ( $items as $item ) { + $mapped[] = $this->map_item( $item ); + } + + return array( + 'count' => count( $mapped ), + 'items' => $mapped, + ); + + } + +} diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index d16d349ab..783da7ea3 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -70,11 +70,40 @@ public function __construct() { // Register abilities. add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + // Register resource-list abilities (Forms, Tags, Landing Pages, Products). + // These are owned by the Plugin (not by any single block or feature), + // so they're added here rather than via a per-class register_abilities(). + add_filter( 'convertkit_abilities', array( $this, 'register_resource_abilities' ) ); + // Register the MCP server. add_action( 'mcp_adapter_init', array( $this, 'register_mcp_server' ) ); } + /** + * Appends the resource-list abilities (Forms, Tags, Landing Pages, + * Products) to the convertkit_abilities filter, so they are registered + * with the Abilities API and exposed via the MCP server. + * + * @since 3.4.0 + * + * @param array $abilities Abilities to register. + * @return array + */ + public function register_resource_abilities( $abilities ) { + + return array_merge( + $abilities, + array( + new ConvertKit_MCP_Ability_Resource_Forms(), + new ConvertKit_MCP_Ability_Resource_Tags(), + new ConvertKit_MCP_Ability_Resource_Landing_Pages(), + new ConvertKit_MCP_Ability_Resource_Products(), + ) + ); + + } + /** * Register the 'kit' ability category. * diff --git a/wp-convertkit.php b/wp-convertkit.php index f46a3708d..38fa70679 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -109,6 +109,11 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-tags.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-products.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'; From c4fd0ea47997cef429226c8795e5f0f1df1645f3 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 21:15:54 +0800 Subject: [PATCH 64/82] Started tests --- tests/Integration/MCPResourceTest.php | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/Integration/MCPResourceTest.php diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php new file mode 100644 index 000000000..4df9a435e --- /dev/null +++ b/tests/Integration/MCPResourceTest.php @@ -0,0 +1,68 @@ +dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + } + + public function testWhenNoResourcesExist() + { + + } + + public function testWhenResourcesExist() + { + + } +} From 14630e187839121f204b395474c204f5c0c21615 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 21:23:24 +0800 Subject: [PATCH 65/82] Completed tests --- tests/Integration/MCPResourceTest.php | 260 ++++++++++++++++++++++++-- 1 file changed, 244 insertions(+), 16 deletions(-) diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php index 4df9a435e..602d2a3f5 100644 --- a/tests/Integration/MCPResourceTest.php +++ b/tests/Integration/MCPResourceTest.php @@ -5,11 +5,21 @@ use lucatume\WPBrowser\TestCase\WPTestCase; /** - * Tests for the Kit MCP Form Resources + * Tests for the Kit MCP resource-list abilities: + * + * - kit/forms-list (ConvertKit_MCP_Ability_Resource_Forms) + * - kit/tags-list (ConvertKit_MCP_Ability_Resource_Tags) + * - kit/landing-pages-list (ConvertKit_MCP_Ability_Resource_Landing_Pages) + * - kit/products-list (ConvertKit_MCP_Ability_Resource_Products) + * + * Each ability is exercised by instantiating its PHP class directly and + * calling permission_callback() / execute_callback(). The MCP transport + * layer is covered by E2E tests; this suite proves the abilities themselves + * read from the resource cache and shape the output correctly. * * @since 3.4.0 */ -class MCPResourceFormsTest extends WPTestCase +class MCPResourceTest extends WPTestCase { /** * The testing implementation. @@ -18,10 +28,20 @@ class MCPResourceFormsTest extends WPTestCase */ protected $tester; + /** + * Holds the ConvertKit Settings class, so we can seed credentials in + * setUp() and clean up in tearDown(). + * + * @since 3.4.0 + * + * @var \ConvertKit_Settings + */ + private $settings; + /** * Performs actions before each test. * - * @since 1.9.7.4 + * @since 3.4.0 */ public function setUp(): void { @@ -29,40 +49,248 @@ public function setUp(): void // Activate Plugin. activate_plugins('convertkit/wp-convertkit.php'); + + // Store credentials in Plugin's settings, so the resource classes + // can fetch live data from the Kit API when init() is called. + $this->settings = new \ConvertKit_Settings(); + update_option( + $this->settings::SETTINGS_NAME, + [ + 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + ] + ); } /** * Performs actions after each test. * - * @since 1.9.6.9 + * @since 3.4.0 */ public function tearDown(): void { + // Delete credentials and any cached resources so each test starts clean. + delete_option($this->settings::SETTINGS_NAME); + + foreach ( + [ + new \ConvertKit_Resource_Forms(), + new \ConvertKit_Resource_Tags(), + new \ConvertKit_Resource_Landing_Pages(), + new \ConvertKit_Resource_Products(), + ] as $resource + ) { + delete_option($resource->settings_name); + delete_option($resource->settings_name . '_last_queried'); + } + + // Restore the current user. + wp_set_current_user(0); + // Deactivate Plugin. deactivate_plugins('convertkit/wp-convertkit.php'); parent::tearDown(); } - /** - * Test that the ability returns a 401 when the user is not authorized. + /** + * Returns each of the four resource-list abilities, paired with the + * resource class that backs it. Used by tests that should run identically + * across all four abilities. + * + * @since 3.4.0 + * + * @return array + */ + public function abilityProvider(): array + { + return [ + 'forms' => [ + new \ConvertKit_MCP_Ability_Resource_Forms(), + new \ConvertKit_Resource_Forms(), + ], + 'tags' => [ + new \ConvertKit_MCP_Ability_Resource_Tags(), + new \ConvertKit_Resource_Tags(), + ], + 'landing_pages' => [ + new \ConvertKit_MCP_Ability_Resource_Landing_Pages(), + new \ConvertKit_Resource_Landing_Pages(), + ], + 'products' => [ + new \ConvertKit_MCP_Ability_Resource_Products(), + new \ConvertKit_Resource_Products(), + ], + ]; + } + + /** + * Test that each ability's name is the expected `kit/-list`. + * + * @since 3.4.0 + */ + public function testAbilityNames() + { + $this->assertSame('kit/forms-list', ( new \ConvertKit_MCP_Ability_Resource_Forms() )->get_name()); + $this->assertSame('kit/tags-list', ( new \ConvertKit_MCP_Ability_Resource_Tags() )->get_name()); + $this->assertSame('kit/landing-pages-list', ( new \ConvertKit_MCP_Ability_Resource_Landing_Pages() )->get_name()); + $this->assertSame('kit/products-list', ( new \ConvertKit_MCP_Ability_Resource_Products() )->get_name()); + } + + /** + * Test that the permission_callback() rejects a user without the + * edit_posts capability. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostsCapability() + { + // Become a Subscriber (no edit_posts capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + foreach ($this->abilityProvider() as $row) { + [ $ability ] = $row; + + $result = $ability->permission_callback([]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_cannot_list_resources', $result->get_error_code()); + } + } + + /** + * Test that the permission_callback() permits a user with the edit_posts + * capability (e.g. an Editor or Administrator). + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsWithEditPostsCapability() + { + // Become an Editor (has edit_posts capability). + $editor_id = static::factory()->user->create([ 'role' => 'editor' ]); + wp_set_current_user($editor_id); + + foreach ($this->abilityProvider() as $row) { + [ $ability ] = $row; + + $this->assertTrue($ability->permission_callback([])); + } + } + + /** + * Test that the execute_callback() returns an empty (but successful) list + * when the resource cache is empty, rather than an error. * * @since 3.4.0 */ - public function testWhenUnauthorized() + public function testReturnsEmptyListWhenNoResourcesAreCached() { - $request = new \WP_REST_Request( 'GET', '/kit/v1/blocks' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 401, $response->get_status() ); + foreach ($this->abilityProvider() as $row) { + [ $ability, $resource ] = $row; + + // Ensure the cache is empty for this resource. + delete_option($resource->settings_name); + + $result = $ability->execute_callback([]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('count', $result); + $this->assertArrayHasKey('items', $result); + $this->assertSame(0, $result['count']); + $this->assertSame([], $result['items']); + } } - public function testWhenNoResourcesExist() - { + /** + * Test that execute_callback() returns the cached items, shaped as + * { count, items: [{ id, name, ... }] }, when the resource cache is + * populated. + * + * @since 3.4.0 + */ + public function testReturnsCachedItems() + { + foreach ($this->abilityProvider() as $key => $row) { + [ $ability, $resource ] = $row; - } + // Populate the resource cache from the Kit API. + $resource->init(); - public function testWhenResourcesExist() - { + $result = $ability->execute_callback([]); - } + $this->assertIsArray($result); + $this->assertArrayHasKey('count', $result); + $this->assertArrayHasKey('items', $result); + $this->assertGreaterThan(0, $result['count']); + $this->assertCount($result['count'], $result['items']); + + // Each item must have id and name. + foreach ($result['items'] as $item) { + $this->assertArrayHasKey('id', $item); + $this->assertArrayHasKey('name', $item); + $this->assertIsInt($item['id']); + $this->assertIsString($item['name']); + } + } + } + + /** + * Test that the Forms ability includes the `format` field on each item, + * and that legacy forms (which omit `format` in the raw resource cache) + * fall back to 'inline'. + * + * @since 3.4.0 + */ + public function testFormsItemsIncludeFormat() + { + $ability = new \ConvertKit_MCP_Ability_Resource_Forms(); + $resource = new \ConvertKit_Resource_Forms(); + $resource->init(); + + $result = $ability->execute_callback([]); + + $this->assertGreaterThan(0, $result['count']); + + $allowedFormats = [ 'inline', 'modal', 'slide in', 'sticky bar' ]; + + foreach ($result['items'] as $item) { + $this->assertArrayHasKey('format', $item); + $this->assertContains($item['format'],$allowedFormats); + } + } + + /** + * Test that the output schema returned by each ability advertises the + * same keys (id, name, plus format for forms) that execute_callback() + * actually returns. Guards against drift between map_item() and + * get_item_schema(). + * + * @since 3.4.0 + */ + public function testOutputSchemaMatchesExecuteShape() + { + foreach ($this->abilityProvider() as $key => $row) { + [ $ability, $resource ] = $row; + + $resource->init(); + $result = $ability->execute_callback([]); + if ($result['count'] === 0) { + // No items to compare against; skip this ability. + continue; + } + + $schema = $ability->get_output_schema(); + $this->assertSame('object', $schema['type']); + $this->assertSame([ 'count', 'items' ], $schema['required']); + + $itemSchemaKeys = array_keys($schema['properties']['items']['items']['properties']); + $itemKeys = array_keys($result['items'][0]); + + sort($itemSchemaKeys); + sort($itemKeys); + + $this->assertSame($itemSchemaKeys,$itemKeys); + } + } } From 1efc168a9f8ec7737b22f920104f873d6d8abcc6 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 21:33:19 +0800 Subject: [PATCH 66/82] Test abilities are registered --- includes/mcp/class-convertkit-mcp.php | 8 +++---- tests/Integration/MCPResourceTest.php | 32 +++++++++++++++++++-------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 783da7ea3..33cec9fe2 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -95,10 +95,10 @@ public function register_resource_abilities( $abilities ) { return array_merge( $abilities, array( - new ConvertKit_MCP_Ability_Resource_Forms(), - new ConvertKit_MCP_Ability_Resource_Tags(), - new ConvertKit_MCP_Ability_Resource_Landing_Pages(), - new ConvertKit_MCP_Ability_Resource_Products(), + 'kit/forms-list' => new ConvertKit_MCP_Ability_Resource_Forms(), + 'kit/tags-list' => new ConvertKit_MCP_Ability_Resource_Tags(), + 'kit/landing-pages-list' => new ConvertKit_MCP_Ability_Resource_Landing_Pages(), + 'kit/products-list' => new ConvertKit_MCP_Ability_Resource_Products(), ) ); diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php index 602d2a3f5..d38f00c6d 100644 --- a/tests/Integration/MCPResourceTest.php +++ b/tests/Integration/MCPResourceTest.php @@ -102,7 +102,7 @@ public function tearDown(): void * * @return array */ - public function abilityProvider(): array + private function abilityProvider(): array { return [ 'forms' => [ @@ -125,16 +125,30 @@ public function abilityProvider(): array } /** - * Test that each ability's name is the expected `kit/-list`. + * Test that the four resource-list abilities are registered with the + * `convertkit_abilities` filter, so they are picked up by the Abilities + * API and exposed by the MCP server. * * @since 3.4.0 */ - public function testAbilityNames() + public function testAbilitiesRegistered() { - $this->assertSame('kit/forms-list', ( new \ConvertKit_MCP_Ability_Resource_Forms() )->get_name()); - $this->assertSame('kit/tags-list', ( new \ConvertKit_MCP_Ability_Resource_Tags() )->get_name()); - $this->assertSame('kit/landing-pages-list', ( new \ConvertKit_MCP_Ability_Resource_Landing_Pages() )->get_name()); - $this->assertSame('kit/products-list', ( new \ConvertKit_MCP_Ability_Resource_Products() )->get_name()); + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/forms-list' => \ConvertKit_MCP_Ability_Resource_Forms::class, + 'kit/tags-list' => \ConvertKit_MCP_Ability_Resource_Tags::class, + 'kit/landing-pages-list' => \ConvertKit_MCP_Ability_Resource_Landing_Pages::class, + 'kit/products-list' => \ConvertKit_MCP_Ability_Resource_Products::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } } /** @@ -256,7 +270,7 @@ public function testFormsItemsIncludeFormat() foreach ($result['items'] as $item) { $this->assertArrayHasKey('format', $item); - $this->assertContains($item['format'],$allowedFormats); + $this->assertContains($item['format'], $allowedFormats); } } @@ -290,7 +304,7 @@ public function testOutputSchemaMatchesExecuteShape() sort($itemSchemaKeys); sort($itemKeys); - $this->assertSame($itemSchemaKeys,$itemKeys); + $this->assertSame($itemSchemaKeys, $itemKeys); } } } From 72f0b0b82e43cfeea99887a4e68c700781a226f9 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 22:34:28 +0800 Subject: [PATCH 67/82] Added tests --- includes/blocks/class-convertkit-block.php | 8 +- tests/Integration/MCPContentFormTest.php | 356 +++++++++++++++++++++ 2 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/MCPContentFormTest.php diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index c308a1bd0..b94b8226f 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -68,10 +68,10 @@ public function register_abilities( $abilities ) { return array_merge( $abilities, array( - new ConvertKit_MCP_Ability_Content_List( $this ), - new ConvertKit_MCP_Ability_Content_Insert( $this ), - new ConvertKit_MCP_Ability_Content_Update( $this ), - new ConvertKit_MCP_Ability_Content_Delete( $this ), + 'kit/' . $this->get_name() . '-list' => new ConvertKit_MCP_Ability_Content_List( $this ), + 'kit/' . $this->get_name() . '-insert' => new ConvertKit_MCP_Ability_Content_Insert( $this ), + 'kit/' . $this->get_name() . '-update' => new ConvertKit_MCP_Ability_Content_Update( $this ), + 'kit/' . $this->get_name() . '-delete' => new ConvertKit_MCP_Ability_Content_Delete( $this ), ) ); diff --git a/tests/Integration/MCPContentFormTest.php b/tests/Integration/MCPContentFormTest.php new file mode 100644 index 000000000..79bbe1f1b --- /dev/null +++ b/tests/Integration/MCPContentFormTest.php @@ -0,0 +1,356 @@ +postID = $this->createPostWithFormBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Form block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const FORM_ABILITY_NAMES = array( + 'kit/form-list', + 'kit/form-insert', + 'kit/form-update', + 'kit/form-delete', + ); + + /** + * Test that the Form block registers all four content abilities via the + * convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/form-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/form-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/form-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/form-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/form-list returns every Form block occurrence in the + * post, with shape { post_id, count, occurrences: [{occurrence_index, attrs}] }. + * + * @since 3.4.0 + */ + public function testListReturnsAllFormOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the form ID from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_ID'], + (string) $occurrence['attrs']['form'] + ); + } + } + + /** + * Test that kit/form-insert appends a new Form block to the post, and + * returns the new occurrence_index. + * + * @since 3.4.0 + */ + public function testInsertAppendsFormBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-insert']->execute_callback(array( + 'post_id' => $this->postID, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + 'position' => 'append', + )); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Form blocks. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + } + + /** + * Test that kit/form-update changes the attrs of a specific occurrence, + * leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Form block (occurrence_index 1) to a different form ID. + $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_ID'] + 1 ); + $result = $abilities['kit/form-update']->execute_callback(array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'form' => $new_form_id ), + )); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new form ID. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_ID'], + (string) $listed['occurrences'][0]['attrs']['form'], + 'kit/form-update must not modify other occurrences.' + ); + $this->assertSame( + $new_form_id, + (string) $listed['occurrences'][1]['attrs']['form'], + 'kit/form-update did not apply the new form ID to the requested occurrence.' + ); + } + + /** + * Test that kit/form-delete removes a specific occurrence and the post + * now contains one fewer Form block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-delete']->execute_callback(array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + )); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Form block. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/form-update returns a WP_Error when asked to update an + * occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-update']->execute_callback(array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + )); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/form blocks interleaved with + * non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithFormBlocks(): int + { + return $this->factory->post->create(array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Form Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + )); + } +} From 235a395c0a106616bcbbb79c1578ccd303d2470a Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 22:34:59 +0800 Subject: [PATCH 68/82] Coding standards --- tests/Integration/MCPContentFormTest.php | 60 ++++++++++++++---------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/tests/Integration/MCPContentFormTest.php b/tests/Integration/MCPContentFormTest.php index 79bbe1f1b..1bad4ba47 100644 --- a/tests/Integration/MCPContentFormTest.php +++ b/tests/Integration/MCPContentFormTest.php @@ -223,11 +223,13 @@ public function testInsertAppendsFormBlock() $abilities = convertkit_get_abilities(); // Execute the ability. - $result = $abilities['kit/form-insert']->execute_callback(array( - 'post_id' => $this->postID, - 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), - 'position' => 'append', - )); + $result = $abilities['kit/form-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + 'position' => 'append', + ) + ); $this->assertIsArray($result); $this->assertSame($this->postID, $result['post_id']); @@ -251,11 +253,13 @@ public function testUpdateModifiesSingleOccurrence() // Update the second Form block (occurrence_index 1) to a different form ID. $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_ID'] + 1 ); - $result = $abilities['kit/form-update']->execute_callback(array( - 'post_id' => $this->postID, - 'occurrence_index' => 1, - 'attrs' => array( 'form' => $new_form_id ), - )); + $result = $abilities['kit/form-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'form' => $new_form_id ), + ) + ); $this->assertIsArray($result); $this->assertSame(1, $result['occurrence_index']); @@ -286,10 +290,12 @@ public function testDeleteRemovesSingleOccurrence() $abilities = convertkit_get_abilities(); // Execute the ability. - $result = $abilities['kit/form-delete']->execute_callback(array( - 'post_id' => $this->postID, - 'occurrence_index' => 0, - )); + $result = $abilities['kit/form-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); $this->assertIsArray($result); $this->assertSame(0, $result['occurrence_index']); @@ -312,11 +318,13 @@ public function testUpdateOnMissingOccurrenceReturnsError() $abilities = convertkit_get_abilities(); // Execute the ability. - $result = $abilities['kit/form-update']->execute_callback(array( - 'post_id' => $this->postID, - 'occurrence_index' => 99, - 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), - )); + $result = $abilities['kit/form-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + ) + ); // Assert that the result is a WP_Error. $this->assertInstanceOf(\WP_Error::class, $result); @@ -332,11 +340,12 @@ public function testUpdateOnMissingOccurrenceReturnsError() */ private function createPostWithFormBlocks(): int { - return $this->factory->post->create(array( - 'post_type' => 'page', - 'post_status' => 'publish', - 'post_title' => 'Form Abilities Fixture', - 'post_content' => ' + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Form Abilities Fixture', + 'post_content' => '

Intro paragraph.

@@ -351,6 +360,7 @@ private function createPostWithFormBlocks(): int

Closing paragraph.

', - )); + ) + ); } } From ca02a2333960945480befccb85827a2e664362a1 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 22:48:58 +0800 Subject: [PATCH 69/82] Improve testing --- tests/Integration/MCPResourceTest.php | 121 +++++++++++++------------- 1 file changed, 59 insertions(+), 62 deletions(-) diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php index d38f00c6d..e659579f6 100644 --- a/tests/Integration/MCPResourceTest.php +++ b/tests/Integration/MCPResourceTest.php @@ -12,11 +12,6 @@ * - kit/landing-pages-list (ConvertKit_MCP_Ability_Resource_Landing_Pages) * - kit/products-list (ConvertKit_MCP_Ability_Resource_Products) * - * Each ability is exercised by instantiating its PHP class directly and - * calling permission_callback() / execute_callback(). The MCP transport - * layer is covered by E2E tests; this suite proves the abilities themselves - * read from the resource cache and shape the output correctly. - * * @since 3.4.0 */ class MCPResourceTest extends WPTestCase @@ -72,14 +67,8 @@ public function tearDown(): void // Delete credentials and any cached resources so each test starts clean. delete_option($this->settings::SETTINGS_NAME); - foreach ( - [ - new \ConvertKit_Resource_Forms(), - new \ConvertKit_Resource_Tags(), - new \ConvertKit_Resource_Landing_Pages(), - new \ConvertKit_Resource_Products(), - ] as $resource - ) { + foreach ( self::RESOURCE_CLASSES as $resource_class ) { + $resource = new $resource_class(); delete_option($resource->settings_name); delete_option($resource->settings_name . '_last_queried'); } @@ -94,35 +83,20 @@ public function tearDown(): void } /** - * Returns each of the four resource-list abilities, paired with the - * resource class that backs it. Used by tests that should run identically - * across all four abilities. + * Map of resource-list ability names to the ConvertKit_Resource_* class + * backing them. Used by tests that need to seed / clear the resource + * cache alongside the ability under test. * * @since 3.4.0 * - * @return array + * @var array */ - private function abilityProvider(): array - { - return [ - 'forms' => [ - new \ConvertKit_MCP_Ability_Resource_Forms(), - new \ConvertKit_Resource_Forms(), - ], - 'tags' => [ - new \ConvertKit_MCP_Ability_Resource_Tags(), - new \ConvertKit_Resource_Tags(), - ], - 'landing_pages' => [ - new \ConvertKit_MCP_Ability_Resource_Landing_Pages(), - new \ConvertKit_Resource_Landing_Pages(), - ], - 'products' => [ - new \ConvertKit_MCP_Ability_Resource_Products(), - new \ConvertKit_Resource_Products(), - ], - ]; - } + private const RESOURCE_CLASSES = array( + 'kit/forms-list' => \ConvertKit_Resource_Forms::class, + 'kit/tags-list' => \ConvertKit_Resource_Tags::class, + 'kit/landing-pages-list' => \ConvertKit_Resource_Landing_Pages::class, + 'kit/products-list' => \ConvertKit_Resource_Products::class, + ); /** * Test that the four resource-list abilities are registered with the @@ -163,13 +137,16 @@ public function testPermissionCallbackDeniesWithoutEditPostsCapability() $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); wp_set_current_user($subscriber_id); - foreach ($this->abilityProvider() as $row) { - [ $ability ] = $row; + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); - $result = $ability->permission_callback([]); + // Assert that the abilities are permission denied. + foreach ( array_keys( self::RESOURCE_CLASSES ) as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + // Assert that the result is a WP_Error. $this->assertInstanceOf(\WP_Error::class, $result); - $this->assertSame('convertkit_mcp_cannot_list_resources', $result->get_error_code()); } } @@ -185,10 +162,13 @@ public function testPermissionCallbackPermitsWithEditPostsCapability() $editor_id = static::factory()->user->create([ 'role' => 'editor' ]); wp_set_current_user($editor_id); - foreach ($this->abilityProvider() as $row) { - [ $ability ] = $row; + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); - $this->assertTrue($ability->permission_callback([])); + // Assert that the abilities are permission granted. + foreach ( array_keys( self::RESOURCE_CLASSES ) as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([])); } } @@ -200,13 +180,15 @@ public function testPermissionCallbackPermitsWithEditPostsCapability() */ public function testReturnsEmptyListWhenNoResourcesAreCached() { - foreach ($this->abilityProvider() as $row) { - [ $ability, $resource ] = $row; + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) { // Ensure the cache is empty for this resource. - delete_option($resource->settings_name); + delete_option( ( new $resource_class() )->settings_name ); - $result = $ability->execute_callback([]); + // Execute the ability. + $result = $abilities[ $name ]->execute_callback([]); $this->assertIsArray($result); $this->assertArrayHasKey('count', $result); @@ -225,13 +207,16 @@ public function testReturnsEmptyListWhenNoResourcesAreCached() */ public function testReturnsCachedItems() { - foreach ($this->abilityProvider() as $key => $row) { - [ $ability, $resource ] = $row; + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + // Execute the abilities. + foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) { // Populate the resource cache from the Kit API. - $resource->init(); + ( new $resource_class() )->init(); - $result = $ability->execute_callback([]); + // Execute the ability. + $result = $abilities[ $name ]->execute_callback([]); $this->assertIsArray($result); $this->assertArrayHasKey('count', $result); @@ -258,16 +243,20 @@ public function testReturnsCachedItems() */ public function testFormsItemsIncludeFormat() { - $ability = new \ConvertKit_MCP_Ability_Resource_Forms(); - $resource = new \ConvertKit_Resource_Forms(); - $resource->init(); + // Populate the resource cache from the Kit API. + ( new \ConvertKit_Resource_Forms() )->init(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); - $result = $ability->execute_callback([]); + // Execute the ability. + $result = $abilities['kit/forms-list']->execute_callback([]); + // Assert that the result is an array. $this->assertGreaterThan(0, $result['count']); + // Assert that the result has items. $allowedFormats = [ 'inline', 'modal', 'slide in', 'sticky bar' ]; - foreach ($result['items'] as $item) { $this->assertArrayHasKey('format', $item); $this->assertContains($item['format'], $allowedFormats); @@ -284,20 +273,28 @@ public function testFormsItemsIncludeFormat() */ public function testOutputSchemaMatchesExecuteShape() { - foreach ($this->abilityProvider() as $key => $row) { - [ $ability, $resource ] = $row; + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the abilities. + foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) { + // Populate the resource cache from the Kit API. + ( new $resource_class() )->init(); + + // Execute the ability. + $result = $abilities[ $name ]->execute_callback([]); - $resource->init(); - $result = $ability->execute_callback([]); if ($result['count'] === 0) { // No items to compare against; skip this ability. continue; } + // Assert that the output schema is an object. $schema = $ability->get_output_schema(); $this->assertSame('object', $schema['type']); $this->assertSame([ 'count', 'items' ], $schema['required']); + // Assert that the item schema keys match the result item keys. $itemSchemaKeys = array_keys($schema['properties']['items']['items']['properties']); $itemKeys = array_keys($result['items'][0]); From d9f74319d768e8763b9bf383dcc96e6380efcb9b Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 22:49:03 +0800 Subject: [PATCH 70/82] Improve testing --- tests/Integration/MCPResourceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php index e659579f6..2d9eae881 100644 --- a/tests/Integration/MCPResourceTest.php +++ b/tests/Integration/MCPResourceTest.php @@ -250,7 +250,7 @@ public function testFormsItemsIncludeFormat() $abilities = convertkit_get_abilities(); // Execute the ability. - $result = $abilities['kit/forms-list']->execute_callback([]); + $result = $abilities['kit/forms-list']->execute_callback([]); // Assert that the result is an array. $this->assertGreaterThan(0, $result['count']); From ef4685ba2cea8db4b0fc52748e07a5b2df9130aa Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 28 May 2026 16:22:42 +0800 Subject: [PATCH 71/82] Fix test --- tests/Integration/MCPResourceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php index 2d9eae881..e90cf8fd0 100644 --- a/tests/Integration/MCPResourceTest.php +++ b/tests/Integration/MCPResourceTest.php @@ -290,7 +290,7 @@ public function testOutputSchemaMatchesExecuteShape() } // Assert that the output schema is an object. - $schema = $ability->get_output_schema(); + $schema = $abilities[ $name ]->get_output_schema(); $this->assertSame('object', $schema['type']); $this->assertSame([ 'count', 'items' ], $schema['required']); From 3560d829982abe9aaadb6e038e672db53f4cfeff Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 28 May 2026 16:30:56 +0800 Subject: [PATCH 72/82] Abilities API: Form Triggers --- .../class-convertkit-block-form-trigger.php | 16 + .../Integration/MCPContentFormTriggerTest.php | 366 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 tests/Integration/MCPContentFormTriggerTest.php diff --git a/includes/blocks/class-convertkit-block-form-trigger.php b/includes/blocks/class-convertkit-block-form-trigger.php index 4bec4aa50..4acbdd3f4 100644 --- a/includes/blocks/class-convertkit-block-form-trigger.php +++ b/includes/blocks/class-convertkit-block-form-trigger.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views. add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) ); @@ -73,6 +76,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Form Triggers', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/tests/Integration/MCPContentFormTriggerTest.php b/tests/Integration/MCPContentFormTriggerTest.php new file mode 100644 index 000000000..afceca369 --- /dev/null +++ b/tests/Integration/MCPContentFormTriggerTest.php @@ -0,0 +1,366 @@ +postID = $this->createPostWithFormTriggerBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Form Trigger block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const FORM_TRIGGER_ABILITY_NAMES = array( + 'kit/formtrigger-list', + 'kit/formtrigger-insert', + 'kit/formtrigger-update', + 'kit/formtrigger-delete', + ); + + /** + * Test that the Form Trigger block registers all four content abilities via the + * convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/formtrigger-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/formtrigger-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/formtrigger-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/formtrigger-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_TRIGGER_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_TRIGGER_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::FORM_TRIGGER_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/formtrigger-list returns every Form Trigger block occurrence in the + * post, with shape { post_id, count, occurrences: [{occurrence_index, attrs}] }. + * + * @since 3.4.0 + */ + public function testListReturnsAllFormTriggerOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the form ID from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'], + (string) $occurrence['attrs']['form'] + ); + } + } + + /** + * Test that kit/formtrigger-insert appends a new Form Trigger block to the post, and + * returns the new occurrence_index. + * + * @since 3.4.0 + */ + public function testInsertAppendsFormTriggerBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] ), + 'position' => 'append', + ) + ); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Form Trigger blocks. + $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + } + + /** + * Test that kit/formtrigger-update changes the attrs of a specific occurrence, + * leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Form Trigger block (occurrence_index 1) to a different form ID. + $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] + 1 ); + $result = $abilities['kit/formtrigger-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'form' => $new_form_id ), + ) + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new form ID. + $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'], + (string) $listed['occurrences'][0]['attrs']['form'], + 'kit/formtrigger-update must not modify other occurrences.' + ); + $this->assertSame( + $new_form_id, + (string) $listed['occurrences'][1]['attrs']['form'], + 'kit/formtrigger-update did not apply the new form ID to the requested occurrence.' + ); + } + + /** + * Test that kit/formtrigger-delete removes a specific occurrence and the post + * now contains one fewer Form Trigger block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Form Trigger block. + $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/formtrigger-update returns a WP_Error when asked to update an + * occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] ), + ) + ); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/formtrigger blocks interleaved with + * non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithFormTriggerBlocks(): int + { + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Form Trigger Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + ) + ); + } +} From 5b15fd27794a5cbe96b5b10fe57d2d1c23a5ff26 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 28 May 2026 17:29:44 +0800 Subject: [PATCH 73/82] Abilities API: Broadcasts --- .../class-convertkit-block-broadcasts.php | 16 + .../Integration/MCPContentBroadcastsTest.php | 368 ++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 tests/Integration/MCPContentBroadcastsTest.php diff --git a/includes/blocks/class-convertkit-block-broadcasts.php b/includes/blocks/class-convertkit-block-broadcasts.php index d41ba18fb..60db77639 100644 --- a/includes/blocks/class-convertkit-block-broadcasts.php +++ b/includes/blocks/class-convertkit-block-broadcasts.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views. add_action( 'convertkit_gutenberg_enqueue_scripts_editor_and_frontend', array( $this, 'enqueue_scripts' ) ); add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) ); @@ -171,6 +174,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Broadcasts', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/tests/Integration/MCPContentBroadcastsTest.php b/tests/Integration/MCPContentBroadcastsTest.php new file mode 100644 index 000000000..1d2a6a1f7 --- /dev/null +++ b/tests/Integration/MCPContentBroadcastsTest.php @@ -0,0 +1,368 @@ +postID = $this->createPostWithBroadcastsBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Broadcasts block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const BROADCASTS_ABILITY_NAMES = array( + 'kit/broadcasts-list', + 'kit/broadcasts-insert', + 'kit/broadcasts-update', + 'kit/broadcasts-delete', + ); + + /** + * Test that the Broadcasts block registers all four content abilities via + * the convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/broadcasts-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/broadcasts-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/broadcasts-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/broadcasts-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::BROADCASTS_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::BROADCASTS_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::BROADCASTS_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/broadcasts-list returns every Broadcasts block occurrence + * in the post. + * + * @since 3.4.0 + */ + public function testListReturnsAllBroadcastsOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the limit attribute from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame(5, (int) $occurrence['attrs']['limit']); + } + } + + /** + * Test that kit/broadcasts-insert appends a new Broadcasts block to the + * post, and returns the new occurrence_index. + * + * @since 3.4.0 + */ + public function testInsertAppendsBroadcastsBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( + 'limit' => 3, + 'display_image' => true, + ), + 'position' => 'append', + ) + ); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Broadcasts blocks. + $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + + // Confirm the newly inserted block carries the attrs we passed in. + $this->assertSame(3, (int) $listed['occurrences'][2]['attrs']['limit']); + $this->assertTrue( (bool) $listed['occurrences'][2]['attrs']['display_image']); + } + + /** + * Test that kit/broadcasts-update changes the attrs of a specific + * occurrence, leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Broadcasts block (occurrence_index 1) to a different limit. + $result = $abilities['kit/broadcasts-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'limit' => 25 ), + ) + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new limit. + $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + 5, + (int) $listed['occurrences'][0]['attrs']['limit'], + 'kit/broadcasts-update must not modify other occurrences.' + ); + $this->assertSame( + 25, + (int) $listed['occurrences'][1]['attrs']['limit'], + 'kit/broadcasts-update did not apply the new limit to the requested occurrence.' + ); + } + + /** + * Test that kit/broadcasts-delete removes a specific occurrence and the + * post now contains one fewer Broadcasts block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Broadcasts block. + $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/broadcasts-update returns a WP_Error when asked to update + * an occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'limit' => 5 ), + ) + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/broadcasts blocks interleaved + * with non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithBroadcastsBlocks(): int + { + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Broadcasts Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + ) + ); + } +} From 23f60a985bb08b5d395955aa8df59d2a6ea29ecb Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 28 May 2026 18:34:55 +0800 Subject: [PATCH 74/82] Abilites API: Products --- .../blocks/class-convertkit-block-product.php | 16 + tests/Integration/MCPContentProductTest.php | 394 ++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 tests/Integration/MCPContentProductTest.php diff --git a/includes/blocks/class-convertkit-block-product.php b/includes/blocks/class-convertkit-block-product.php index bea8b12a5..aaf522f81 100644 --- a/includes/blocks/class-convertkit-block-product.php +++ b/includes/blocks/class-convertkit-block-product.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views. add_action( 'convertkit_gutenberg_enqueue_scripts_editor_and_frontend', array( $this, 'enqueue_scripts' ) ); add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) ); @@ -95,6 +98,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Products', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/tests/Integration/MCPContentProductTest.php b/tests/Integration/MCPContentProductTest.php new file mode 100644 index 000000000..50a6504ba --- /dev/null +++ b/tests/Integration/MCPContentProductTest.php @@ -0,0 +1,394 @@ +postID = $this->createPostWithProductBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Product block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const PRODUCT_ABILITY_NAMES = array( + 'kit/product-list', + 'kit/product-insert', + 'kit/product-update', + 'kit/product-delete', + ); + + /** + * Test that the Product block registers all four content abilities via + * the convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/product-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/product-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/product-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/product-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::PRODUCT_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::PRODUCT_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::PRODUCT_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/product-list returns every Product block occurrence in + * the post. + * + * @since 3.4.0 + */ + public function testListReturnsAllProductOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the product ID from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'], + (string) $occurrence['attrs']['product'] + ); + } + } + + /** + * Test that kit/product-insert appends a new Product block to the post, + * and returns the new occurrence_index. Exercises all four primary + * Product attributes so each round-trips through the block helper. + * + * @since 3.4.0 + */ + public function testInsertAppendsProductBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( + 'product' => $_ENV['CONVERTKIT_API_PRODUCT_ID'], + 'text' => 'Buy this product', + 'discount_code' => $_ENV['CONVERTKIT_API_PRODUCT_DISCOUNT_CODE'], + 'checkout' => true, + ), + 'position' => 'append', + ) + ); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + // Two Product blocks existed in setUp(); the newly inserted one is the third. + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Product blocks. + $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + + // Confirm the newly inserted block carries the attrs we passed in. + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'], + (string) $listed['occurrences'][2]['attrs']['product'] + ); + $this->assertSame('Buy this product', $listed['occurrences'][2]['attrs']['text']); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_DISCOUNT_CODE'], + (string) $listed['occurrences'][2]['attrs']['discount_code'] + ); + $this->assertTrue( (bool) $listed['occurrences'][2]['attrs']['checkout']); + } + + /** + * Test that kit/product-update changes the attrs of a specific + * occurrence, leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Product block (occurrence_index 1) to a different + // product ID and text. + $new_product_id = (string) ( (int) $_ENV['CONVERTKIT_API_PRODUCT_ID'] + 1 ); + $result = $abilities['kit/product-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( + 'product' => $new_product_id, + 'text' => 'Updated CTA', + ), + ) + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the + // new product ID and text. + $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'], + (string) $listed['occurrences'][0]['attrs']['product'], + 'kit/product-update must not modify other occurrences.' + ); + $this->assertSame( + $new_product_id, + (string) $listed['occurrences'][1]['attrs']['product'], + 'kit/product-update did not apply the new product ID to the requested occurrence.' + ); + $this->assertSame( + 'Updated CTA', + $listed['occurrences'][1]['attrs']['text'], + 'kit/product-update did not apply the new text to the requested occurrence.' + ); + } + + /** + * Test that kit/product-delete removes a specific occurrence and the + * post now contains one fewer Product block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Product block. + $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/product-update returns a WP_Error when asked to update + * an occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'product' => $_ENV['CONVERTKIT_API_PRODUCT_ID'] ), + ) + ); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/product blocks interleaved + * with non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithProductBlocks(): int + { + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Product Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + ) + ); + } +} From c5baacadd4fdbf4228f8303864061dab393ec52b Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 8 Jun 2026 10:53:14 +0100 Subject: [PATCH 75/82] Return WP_Error or array on find() methods --- .../blocks/helpers/class-convertkit-block-post-helper.php | 7 +------ .../helpers/class-convertkit-content-post-helper.php | 2 +- .../content/class-convertkit-mcp-ability-content-list.php | 5 ----- tests/Integration/BlockPostHelperTest.php | 6 ++++-- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php index 3fcaaab40..5472cb319 100644 --- a/includes/blocks/helpers/class-convertkit-block-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-block-post-helper.php @@ -21,7 +21,7 @@ class ConvertKit_Block_Post_Helper { * * @param int $post_id Post ID. * @param string $block_name Programmatic Block Name. - * @return WP_Error|bool|array + * @return WP_Error|array */ public static function find( $post_id, $block_name ) { @@ -55,11 +55,6 @@ public static function find( $post_id, $block_name ) { ++$occurrence_index; } - // If no blocks found, return false. - if ( empty( $found ) ) { - return false; - } - return $found; } diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index cc3b867ac..4925eaf73 100644 --- a/includes/blocks/helpers/class-convertkit-content-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -35,7 +35,7 @@ class ConvertKit_Content_Post_Helper { * * @param int $post_id Post ID. * @param string $element_name Kit Element name (e.g. `form`), without prefix. - * @return WP_Error|bool|array + * @return WP_Error|array */ public static function find( $post_id, $element_name ) { diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php index d68e6d784..d0fcf89d1 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php @@ -177,11 +177,6 @@ public function execute_callback( $input ) { return $occurrences; } - // Normalise a "no occurrences" result (false) to an empty array. - if ( false === $occurrences ) { - $occurrences = array(); - } - // Return result. return array( 'post_id' => $post_id, diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php index 207a20d75..ac34dc25a 100644 --- a/tests/Integration/BlockPostHelperTest.php +++ b/tests/Integration/BlockPostHelperTest.php @@ -110,13 +110,15 @@ public function testFind() } /** - * Test that the find() method returns false when no blocks match the given block name. + * Test that the find() method returns an empty array 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' )); + $blocks = \ConvertKit_Block_Post_Helper::find( $this->postID, 'fake/block' ); + $this->assertIsArray($blocks); + $this->assertCount(0, $blocks); } /** From 3e91dd7826d39f1cc6732654b59d20c1a5443b50 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 8 Jun 2026 11:17:42 +0100 Subject: [PATCH 76/82] Parse using WP_HTML_Tag_Processor where available --- .../class-convertkit-block-post-helper.php | 12 ++ ...class-convertkit-shortcode-post-helper.php | 138 +++++++++++++----- 2 files changed, 117 insertions(+), 33 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php index ceacd59af..7d25b57b8 100644 --- a/includes/blocks/helpers/class-convertkit-block-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-block-post-helper.php @@ -72,6 +72,18 @@ public static function find( $post_id, $block_name ) { */ public static function insert( $post_id, $block_name, $attrs, $position = 'append', $index = 0 ) { + // If the index is negative, bail. + if ( $position === 'index' && (int) $index < 0 ) { + return new WP_Error( + 'convertkit_block_post_helper_invalid_index', + sprintf( + /* translators: %d: index */ + __( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ), + (int) $index + ) + ); + } + // Get Post. $post = get_post( $post_id ); if ( ! $post ) { diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php index 5ab1797e6..b090f3ae6 100644 --- a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -88,6 +88,18 @@ public static function find( $post_id, $shortcode_tag ) { */ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'append', $index = 0 ) { + // If the index is negative, bail. + if ( $position === 'index' && (int) $index < 0 ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_invalid_index', + sprintf( + /* translators: %d: index */ + __( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ), + (int) $index + ) + ); + } + // Get Post. $post = get_post( $post_id ); if ( ! $post ) { @@ -102,8 +114,8 @@ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'ap $shortcode = self::build_shortcode( $shortcode_tag, $attrs ); $content = $post->post_content; - // Determine the byte offset immediately after each top-level element. - $offsets = self::get_element_offsets( $content ); + // Determine the byte offset of the start of each top-level element. + $starts = self::get_element_starts( $content ); // Resolve $position into a concrete byte offset within the content. switch ( $position ) { @@ -112,13 +124,14 @@ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'ap break; case 'index': - // Insert after the Nth top-level element. If no elements - // exist, or the index is beyond the last element, fall back - // to appending after all existing content. - if ( empty( $offsets ) || (int) $index >= count( $offsets ) ) { + // Insert before the Nth top-level element. If no elements + // exist, or the index is equal to / beyond count(), append + // after all existing content — mirroring how array_splice() + // treats an index equal to the array length. + if ( empty( $starts ) || (int) $index >= count( $starts ) ) { $insert_at = strlen( $content ); } else { - $insert_at = $offsets[ max( 0, (int) $index ) ]; + $insert_at = $starts[ (int) $index ]; } break; @@ -449,53 +462,112 @@ private static function pad_snippet( $shortcode, $content, $offset ) { } /** - * Returns the byte offset immediately after each top-level element in the - * content, in document order. + * Returns the byte offset of the start of each top-level element in + * the content, in document order. * - * A top-level element is a single top-level element-level HTML element - * (e.g. a whole `

...

`). These are the Classic content analogue of - * the top-level blocks that ConvertKit_Block_Post_Helper works against: - * counting them lets a caller-supplied `index` mean the same kind of unit - * in both mechanisms. + * Uses WP_HTML_Tag_Processor when available (WordPress 6.2+), which is a + * streaming HTML parser that does not rewrite the content (unlike + * DOMDocument), preserving the byte-for-byte guarantee that update() / + * delete() rely on. * - * The returned offsets are the points *after* each element, suitable for - * use as a `substr_replace()` insertion point. + * Falls back to a regex for older WordPress versions, which + * cannot handle same-tag nesting (e.g.
inside
) — rare in + * Classic editor content. * * @since 3.4.0 * * @param string $content Post content. * @return int[] Zero-indexed array of byte offsets. */ - private static function get_element_offsets( $content ) { + private static function get_element_starts( $content ) { // Bail with an empty array if there is no content. - if ( '' === trim( (string) $content ) ) { + if ( trim( (string) $content ) === '' ) { return array(); } - // Match each top-level element-level element in document order. The - // 's' flag lets the element span multiple lines; the lazy quantifier - // and \1 backreference keep the match scoped to a single element. - // - // Note: this does not handle an element-level tag nested inside - // another of the same name (e.g. a
within a
). Such - // nesting is rare in Classic editor content, and parsing it correctly - // requires a full HTML parser, which is avoided here to keep the - // content modification surgical (see insert()). - $pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is'; + // Prefer WP_HTML_Tag_Processor where available (WordPress 6.2+) — + // it is a streaming HTML parser that handles nested same-name tags + // correctly, and does not re-serialise / normalise content. + if ( class_exists( 'WP_HTML_Tag_Processor' ) ) { + return self::get_element_starts_via_html_processor( $content ); + } - // Bail with an empty array if no top-level elements are found. + // Fallback: regex-based detection of top-level element-level elements. + // This matches the same set of tags as wpautop() treats as block-level, + // but does not handle same-tag nesting (e.g. a
within a
). + // Used only on WordPress versions older than 6.2. + $pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is'; if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) { return array(); } - // Record the byte offset immediately after each matched element. - $offsets = array(); + $starts = array(); foreach ( $matches[0] as $match ) { - $offsets[] = (int) $match[1] + strlen( $match[0] ); + $starts[] = (int) $match[1]; + } + + return $starts; + + } + + /** + * Returns top-level element start offsets, computed using + * WP_HTML_Tag_Processor. + * + * @since 3.4.0 + * + * @param string $content Post content. + * @return int[] + */ + private static function get_element_starts_via_html_processor( $content ) { + + $processor = new WP_HTML_Tag_Processor( $content ); + + $starts = array(); + $depth = 0; + $element_level_tags = array_flip( explode( '|', strtoupper( self::ELEMENT_LEVEL_TAGS ) ) ); + + while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + + $tag = $processor->get_tag(); + + // Only element-level tags participate in depth tracking. + if ( ! isset( $element_level_tags[ $tag ] ) ) { + continue; + } + + if ( $processor->is_tag_closer() ) { + if ( $depth > 0 ) { + --$depth; + } + continue; + } + + // Opening tag at depth zero: this is a top-level element. + if ( 0 === $depth ) { + // Bookmark the opener so we can read its absolute start byte + // offset. WP_HTML_Tag_Processor::get_bookmark() returns an + // object whose `->start` property is the offset of the `<`. + $processor->set_bookmark( 'el' ); + $bookmark = $processor->get_bookmark( 'el' ); + + if ( is_object( $bookmark ) && isset( $bookmark->start ) ) { + $starts[] = (int) $bookmark->start; + } + } + + // Void elements (hr) do not increase depth; everything else does. + // WP_HTML_Tag_Processor returns is_tag_closer() === false for both + // void and non-void openers, so we approximate void-vs-non-void + // by checking the tag name. `hr` is the only void element in our + // ELEMENT_LEVEL_TAGS list. + if ( 'HR' !== $tag ) { + ++$depth; + } } - return $offsets; + return $starts; } From 7d72678a3ee3d6e889ea9e439a57b5465d2f2356 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 9 Jun 2026 12:45:08 +0100 Subject: [PATCH 77/82] Use WP_HTML_Tag_Processor if available --- ...class-convertkit-shortcode-post-helper.php | 95 ++++++------------- tests/Integration/BlockPostHelperTest.php | 7 +- 2 files changed, 31 insertions(+), 71 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php index b090f3ae6..74e5aae63 100644 --- a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -339,8 +339,7 @@ private static function match_shortcodes( $content, $shortcode_tag ) { return array(); } - // WordPress' shortcode regex captures the attribute string in group 3. - // With PREG_OFFSET_CAPTURE each group is a [ value, offset ] pair. + // Build array of shortcode matches. $found = array(); foreach ( $matches[0] as $i => $match ) { $found[] = array( @@ -411,8 +410,7 @@ private static function build_shortcode( $shortcode_tag, $attrs ) { /** * Replaces a single matched shortcode occurrence with the replacement - * string, targeting it by byte offset so identical occurrences elsewhere - * are left untouched. + * string. * * @since 3.4.0 * @@ -451,88 +449,65 @@ private static function pad_snippet( $shortcode, $content, $offset ) { // Add a leading blank line unless the shortcode is at the start of the // content, or already preceded by a blank line. - $lead = ( '' === $before || (bool) preg_match( '/\R\R\s*$/', $before ) ) ? '' : "\n\n"; + $lead = ( $before === '' || preg_match( '/\R\R\s*$/', $before ) ) ? '' : "\n\n"; // Add a trailing blank line unless the shortcode is at the end of the // content, or already followed by a blank line. - $trail = ( '' === $after || (bool) preg_match( '/^\s*\R\R/', $after ) ) ? '' : "\n\n"; + $trail = ( $after === '' || preg_match( '/^\s*\R\R/', $after ) ) ? '' : "\n\n"; return $lead . $shortcode . $trail; } /** - * Returns the byte offset of the start of each top-level element in - * the content, in document order. + * Returns the byte offset of the start of each top-level element in the + * content, in document order. * - * Uses WP_HTML_Tag_Processor when available (WordPress 6.2+), which is a - * streaming HTML parser that does not rewrite the content (unlike - * DOMDocument), preserving the byte-for-byte guarantee that update() / - * delete() rely on. - * - * Falls back to a regex for older WordPress versions, which - * cannot handle same-tag nesting (e.g.
inside
) — rare in - * Classic editor content. + * Uses WP_HTML_Tag_Processor (WP 6.2+) for nesting-aware structure, paired + * with a regex for the byte offsets the tag processor does not expose. + * Falls back to regex alone on older WordPress versions. * * @since 3.4.0 * * @param string $content Post content. - * @return int[] Zero-indexed array of byte offsets. + * @return array */ private static function get_element_starts( $content ) { - // Bail with an empty array if there is no content. if ( trim( (string) $content ) === '' ) { return array(); } - // Prefer WP_HTML_Tag_Processor where available (WordPress 6.2+) — - // it is a streaming HTML parser that handles nested same-name tags - // correctly, and does not re-serialise / normalise content. - if ( class_exists( 'WP_HTML_Tag_Processor' ) ) { - return self::get_element_starts_via_html_processor( $content ); - } - - // Fallback: regex-based detection of top-level element-level elements. - // This matches the same set of tags as wpautop() treats as block-level, - // but does not handle same-tag nesting (e.g. a
within a
). - // Used only on WordPress versions older than 6.2. + // Candidate offsets, one per regex-matched element-level opener. $pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is'; if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) { return array(); } - $starts = array(); - foreach ( $matches[0] as $match ) { - $starts[] = (int) $match[1]; + // Fallback for WP < 6.2: regex offsets verbatim, no nesting awareness. + if ( ! class_exists( 'WP_HTML_Tag_Processor' ) ) { + $starts = array(); + foreach ( $matches[0] as $match ) { + $starts[] = (int) $match[1]; + } + return $starts; } - return $starts; - - } - - /** - * Returns top-level element start offsets, computed using - * WP_HTML_Tag_Processor. - * - * @since 3.4.0 - * - * @param string $content Post content. - * @return int[] - */ - private static function get_element_starts_via_html_processor( $content ) { - - $processor = new WP_HTML_Tag_Processor( $content ); + // Per-tag queue of regex offsets in document order. + $queues = array(); + foreach ( $matches[1] as $i => $tag_match ) { + $queues[ strtoupper( $tag_match[0] ) ][] = (int) $matches[0][ $i ][1]; + } + // Walk with depth tracking; record offsets only for depth-zero openers. + $processor = new WP_HTML_Tag_Processor( $content ); $starts = array(); $depth = 0; $element_level_tags = array_flip( explode( '|', strtoupper( self::ELEMENT_LEVEL_TAGS ) ) ); while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - $tag = $processor->get_tag(); - // Only element-level tags participate in depth tracking. if ( ! isset( $element_level_tags[ $tag ] ) ) { continue; } @@ -544,25 +519,13 @@ private static function get_element_starts_via_html_processor( $content ) { continue; } - // Opening tag at depth zero: this is a top-level element. - if ( 0 === $depth ) { - // Bookmark the opener so we can read its absolute start byte - // offset. WP_HTML_Tag_Processor::get_bookmark() returns an - // object whose `->start` property is the offset of the `<`. - $processor->set_bookmark( 'el' ); - $bookmark = $processor->get_bookmark( 'el' ); + $offset = array_shift( $queues[ $tag ] ); - if ( is_object( $bookmark ) && isset( $bookmark->start ) ) { - $starts[] = (int) $bookmark->start; - } + if ( $depth === 0 ) { + $starts[] = $offset; } - // Void elements (hr) do not increase depth; everything else does. - // WP_HTML_Tag_Processor returns is_tag_closer() === false for both - // void and non-void openers, so we approximate void-vs-non-void - // by checking the tag name. `hr` is the only void element in our - // ELEMENT_LEVEL_TAGS list. - if ( 'HR' !== $tag ) { + if ( $tag !== 'HR' ) { ++$depth; } } diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php index 56f38301d..370c86721 100644 --- a/tests/Integration/BlockPostHelperTest.php +++ b/tests/Integration/BlockPostHelperTest.php @@ -207,8 +207,7 @@ public function testInsertIndexOutOfBounds() } /** - * Test that the insert() method inserts a new block at the beginning of the content when - * the index is negative. + * Test that the insert() method returns a WP_Error when the index is negative. * * @since 3.4.0 */ @@ -221,9 +220,7 @@ public function testInsertIndexNegative() position: 'index', index: -1 ); - - $this->assertIsArray( $result ); - $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertInstanceOf(\WP_Error::class, $result ); } /** From 7674ef4208c50d18a5226635dd1f94d0f72ae677 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 11 Jun 2026 15:20:46 +0100 Subject: [PATCH 78/82] Fix test --- tests/Integration/ShortcodePostHelperTest.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php index 3cc27340e..1c51b843c 100644 --- a/tests/Integration/ShortcodePostHelperTest.php +++ b/tests/Integration/ShortcodePostHelperTest.php @@ -206,8 +206,7 @@ public function testInsertIndexOutOfBounds() } /** - * Test that the insert() method inserts a new shortcode at the beginning of the content when - * the index is negative. + * Test that the insert() method returns a WP_Error when the index is negative. * * @since 3.4.0 */ @@ -220,9 +219,7 @@ public function testInsertIndexNegative() position: 'index', index: -1 ); - - $this->assertIsArray( $result ); - $this->assertEquals( $this->postID, $result['post_id'] ); + $this->assertInstanceOf(\WP_Error::class, $result ); } /** From 02856548df39f44fffee336ab03d1afb244b933a Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 17 Jun 2026 15:08:38 +0800 Subject: [PATCH 79/82] Integration Tests: Check content is updated correctly --- .../class-convertkit-content-post-helper.php | 2 - ...class-convertkit-shortcode-post-helper.php | 20 +++++++++ tests/Integration/ShortcodePostHelperTest.php | 44 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index 8b7a1ca2a..762ea5edd 100644 --- a/includes/blocks/helpers/class-convertkit-content-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -86,7 +86,6 @@ public static function insert( $post_id, $element_name, $attrs, $position = 'app } // Insert the element into the post, depending on the mechanism. - // A switch is used as shortcodes and other mechanisms will be supported in the future. switch ( $mechanism ) { case 'block': return ConvertKit_Block_Post_Helper::insert( @@ -132,7 +131,6 @@ public static function update( $post_id, $element_name, $occurrence_index, $attr } // Updates the existing occurrence of the element in the post, depending on the mechanism. - // A switch is used as shortcodes and other mechanisms will be supported in the future. switch ( $mechanism ) { case 'block': return ConvertKit_Block_Post_Helper::update( diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php index 74e5aae63..f13fddce9 100644 --- a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -530,6 +530,26 @@ private static function get_element_starts( $content ) { } } + // Treat blank line separated text as paragraphs, matching the logic in wpautop(). + $opener_prefix = '/^<(?:' . self::ELEMENT_LEVEL_TAGS . ')\b/i'; + $offset = 0; + foreach ( preg_split( '/(\R\R+)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE ) as $i => $chunk ) { + // Odd indices are the delimiters captured by PREG_SPLIT_DELIM_CAPTURE. + if ( $i % 2 === 1 ) { + $offset += strlen( $chunk ); + continue; + } + + $trimmed = trim( $chunk ); + if ( $trimmed !== '' && ! preg_match( $opener_prefix, $trimmed ) ) { + $starts[] = $offset + ( strlen( $chunk ) - strlen( ltrim( $chunk ) ) ); + } + + $offset += strlen( $chunk ); + } + + sort( $starts, SORT_NUMERIC ); + return $starts; } diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php index 1c51b843c..0188d10ea 100644 --- a/tests/Integration/ShortcodePostHelperTest.php +++ b/tests/Integration/ShortcodePostHelperTest.php @@ -143,8 +143,13 @@ public function testInsertPrepend() position: 'prepend' ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); } /** @@ -162,8 +167,13 @@ public function testInsertAppend() position: 'append' ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); } /** @@ -181,8 +191,13 @@ public function testInsertIndex() index: 1 ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringContainsString( "Item #1\n\n[convertkit_form form=\"" . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); } /** @@ -201,8 +216,37 @@ public function testInsertIndexOutOfBounds() index: 100 ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new shortcode at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndexZero() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 0 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); } /** From 3655fb24cdd1671742a9bc1614622a8af23336ce Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 17 Jun 2026 15:24:34 +0800 Subject: [PATCH 80/82] Updated integration tests for block post helper --- tests/Integration/BlockPostHelperTest.php | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php index 370c86721..020c2bc0d 100644 --- a/tests/Integration/BlockPostHelperTest.php +++ b/tests/Integration/BlockPostHelperTest.php @@ -144,8 +144,13 @@ public function testInsertPrepend() position: 'prepend' ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '', $post->post_content ); } /** @@ -163,8 +168,13 @@ public function testInsertAppend() position: 'append' ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '', $post->post_content ); } /** @@ -182,8 +192,37 @@ public function testInsertIndex() index: 1 ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringContainsString( "\n

Item #1

\n', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new block at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndexZero() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 0 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '', $post->post_content ); } /** @@ -202,8 +241,13 @@ public function testInsertIndexOutOfBounds() index: 100 ); + // Confirm result is an array and the post ID is correct. $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '', $post->post_content ); } /** From 8a425e258571e912ec605a58f66234ded9c3cbda Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 17 Jun 2026 15:45:27 +0800 Subject: [PATCH 81/82] PHPStan compat. --- includes/blocks/class-convertkit-block.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index b94b8226f..35d78d32f 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -105,6 +105,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return ''; + + } + /** * Returns this block's icon. * From 8579b93e9354eb00707abf06d511f2c1fbf84e88 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 19 Jun 2026 07:35:10 +0800 Subject: [PATCH 82/82] Return `string` type as default and for text, color and select fields --- .../content/class-convertkit-mcp-ability-content.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php index cce7a4d43..539ef3e36 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php @@ -165,6 +165,9 @@ private function get_input_schema_property_type( $field ) { switch ( $type ) { case 'resource': + case 'text': + case 'color': + case 'select': return 'string'; case 'number': @@ -174,7 +177,8 @@ private function get_input_schema_property_type( $field ) { return 'boolean'; default: - return $type; + // Unknown field type — fall back to string. + return 'string'; } }