diff --git a/includes/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php index 5472cb319..7d25b57b8 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'], ); @@ -73,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 ) { @@ -115,6 +126,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( @@ -129,10 +149,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, ); } @@ -162,13 +182,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; @@ -207,10 +224,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, ); } @@ -239,19 +256,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 ); @@ -285,10 +299,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-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php index 4925eaf73..8b7a1ca2a 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, $element_name ) { $post_id, 'convertkit/' . $element_name ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::find( + $post_id, + 'convertkit_' . $element_name + ); } return self::unsupported_mechanism_error( $mechanism ); @@ -90,6 +96,15 @@ public static function insert( $post_id, $element_name, $attrs, $position = 'app $position, $index ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::insert( + $post_id, + 'convertkit_' . $element_name, + $attrs, + $position, + $index + ); } return self::unsupported_mechanism_error( $mechanism ); @@ -126,6 +141,14 @@ public static function update( $post_id, $element_name, $occurrence_index, $attr $occurrence_index, $attrs ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::update( + $post_id, + 'convertkit_' . $element_name, + $occurrence_index, + $attrs + ); } return self::unsupported_mechanism_error( $mechanism ); @@ -160,6 +183,13 @@ public static function delete( $post_id, $element_name, $occurrence_index ) { 'convertkit/' . $element_name, $occurrence_index ); + + case 'shortcode': + return ConvertKit_Shortcode_Post_Helper::delete( + $post_id, + 'convertkit_' . $element_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..74e5aae63 --- /dev/null +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -0,0 +1,537 @@ +post_content, $shortcode_tag ); + $found = array(); + + foreach ( $matches as $occurrence_index => $match ) { + $found[] = array( + // Zero-based index of this occurrence among occurrences of + // this shortcode in the post. + '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 shortcode into the Post's content at the specified + * position. + * + * @since 3.4.0 + * + * @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 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 ) { + + // 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 ) { + 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 ); + $content = $post->post_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 ) { + case 'prepend': + $insert_at = 0; + break; + + case 'index': + // 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 = $starts[ (int) $index ]; + } + break; + + case 'append': + default: + $insert_at = strlen( $content ); + break; + } + + // 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' => $content, + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index of the newly inserted shortcode. + return array( + 'post_id' => $post_id, + 'occurrence_index' => $occurrence_index, + ); + + } + + /** + * 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 Programmatic Shortcode Tag. + * @param int $occurrence_index Zero-based occurrence index to update. + * @param array $attrs Shortcode Attributes. + * @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, + 'occurrence_index' => (int) $occurrence_index, + ); + + } + + /** + * Deletes a specific shortcode from the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @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, + 'occurrence_index' => (int) $occurrence_index, + ); + + } + + /** + * Returns all matches of the given shortcode tag within the content, in + * document order. + * + * 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 + * + * @param string $content Post content. + * @param string $shortcode_tag Programmatic 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(); + } + + // Build array of shortcode matches. + $found = array(); + 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] ) : '', + ); + } + + return $found; + + } + + /** + * Parses the attributes of a single matched shortcode into a key/value + * array. + * + * @since 3.4.0 + * + * @param array $shortcode A match from match_shortcodes(). + * @return array + */ + private static function parse_attrs( $shortcode ) { + + // 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'] ); + + // Discard any positional (non-string keyed) attributes, keeping only + // named attributes. + 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 Programmatic 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. + * + * @since 3.4.0 + * + * @param string $content Post content. + * @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, $atts, $replacement ) { + + return substr_replace( + $content, + $replacement, + $atts['offset'], + strlen( $atts['text'] ) + ); + + } + + /** + * Wraps a shortcode snippet in blank-line padding so that, once inserted + * at the given offset, it sits as its own top-level element. + * + * @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 + */ + private static function pad_snippet( $shortcode, $content, $offset ) { + + // 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 === '' || 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 === '' || 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. + * + * 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 array + */ + private static function get_element_starts( $content ) { + + if ( trim( (string) $content ) === '' ) { + return array(); + } + + // 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(); + } + + // 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; + } + + // 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(); + + if ( ! isset( $element_level_tags[ $tag ] ) ) { + continue; + } + + if ( $processor->is_tag_closer() ) { + if ( $depth > 0 ) { + --$depth; + } + continue; + } + + $offset = array_shift( $queues[ $tag ] ); + + if ( $depth === 0 ) { + $starts[] = $offset; + } + + if ( $tag !== 'HR' ) { + ++$depth; + } + } + + return $starts; + + } + +} 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 d0fcf89d1..441557214 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' ), ), ), ); diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php index ac34dc25a..370c86721 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'] ); } @@ -148,7 +146,6 @@ public function testInsertPrepend() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 0, $result['index'] ); } /** @@ -168,7 +165,6 @@ public function testInsertAppend() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( $this->totalBlocks + 1, $result['index'] ); } /** @@ -188,7 +184,6 @@ public function testInsertIndex() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 1, $result['index'] ); } /** @@ -209,12 +204,10 @@ 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 - * the index is negative. + * Test that the insert() method returns a WP_Error when the index is negative. * * @since 3.4.0 */ @@ -227,10 +220,7 @@ public function testInsertIndexNegative() position: 'index', index: -1 ); - - $this->assertIsArray( $result ); - $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( 0, $result['index'] ); + $this->assertInstanceOf(\WP_Error::class, $result ); } /** @@ -266,7 +256,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, @@ -277,7 +266,6 @@ public function testUpdate() $this->assertIsArray( $result ); $this->assertEquals( $this->postID, $result['post_id'] ); - $this->assertEquals( $this->formBlockIndices[1], $result['index'] ); } /** @@ -326,7 +314,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, @@ -335,7 +322,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 new file mode 100644 index 000000000..1c51b843c --- /dev/null +++ b/tests/Integration/ShortcodePostHelperTest.php @@ -0,0 +1,402 @@ +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( $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 shortcodes match the given shortcode tag. + * + * @since 3.4.0 + */ + public function testFindWhenNoShortcodesMatch() + { + $this->assertFalse(\ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'fake_shortcode' )); + } + + /** + * 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_Shortcode_Post_Helper::find( 999999, 'convertkit_form' )); + } + + /** + * 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_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'prepend' + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * 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_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'append' + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * Test that the insert() method inserts a new shortcode at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndex() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 1 + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * 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_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 100 + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * Test that the insert() method returns a WP_Error when the index is negative. + * + * @since 3.4.0 + */ + public function testInsertIndexNegative() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: -1 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the insert() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testInsertWhenPostDoesNotExist() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: 999999, + shortcode_tag: '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 shortcode. + * + * @since 3.4.0 + */ + public function testUpdate() + { + $result = \ConvertKit_Shortcode_Post_Helper::update( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 0, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + $result = \ConvertKit_Shortcode_Post_Helper::update( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 1, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * 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_Shortcode_Post_Helper::update( + post_id: $this->postID, + shortcode_tag: '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_Shortcode_Post_Helper::update( + post_id: 999999, + shortcode_tag: '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 shortcode. + * + * @since 3.4.0 + */ + public function testDelete() + { + $result = \ConvertKit_Shortcode_Post_Helper::delete( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 1 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + $result = \ConvertKit_Shortcode_Post_Helper::delete( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 0 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * 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_Shortcode_Post_Helper::delete( + post_id: $this->postID, + shortcode_tag: '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_Shortcode_Post_Helper::delete( + post_id: 999999, + shortcode_tag: '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 shortcode. + return $this->factory->post->create( + [ + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Shortcode Post', + 'post_content' => 'Item #1 + +

Item #1

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

Item #2

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

Item #1

+ +Item #4 + +

Item #1

+ +Item #5 + +

Item #2

+ +

Item #2

', + ] + ); + } +} 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';