From 9df3f722af73bb79fa6f372eea87b9c2545d1814 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Thu, 17 Sep 2026 04:12:19 +0600 Subject: [PATCH] feat: add Notes to the elements to copy Adds a Notes checkbox to the settings page and a Notes_Copier class that copies block-level notes and their replies when cloning a post. The note IDs stored in the block markup are remapped to the copied notes, so the copy references its own notes. --- admin-functions.php | 19 ++++ src/admin/options.php | 8 ++ src/notes-copier.php | 148 +++++++++++++++++++++++++++ tests/WP/Admin_Functions_Test.php | 2 + tests/WP/Legacy_Duplication_Test.php | 62 +++++++++++ 5 files changed, 239 insertions(+) create mode 100644 src/notes-copier.php diff --git a/admin-functions.php b/admin-functions.php index 26acf31ae..74abd9532 100644 --- a/admin-functions.php +++ b/admin-functions.php @@ -10,6 +10,7 @@ return; } +use Yoast\WP\Duplicate_Post\Notes_Copier; use Yoast\WP\Duplicate_Post\UI\Newsletter; use Yoast\WP\Duplicate_Post\Utils; @@ -70,6 +71,10 @@ function duplicate_post_admin_init() { add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_comments', 40, 2 ); } + if ( (int) get_option( 'duplicate_post_copynotes' ) === 1 ) { + add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_notes', 45, 2 ); + } + add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_post_taxonomies', 50, 2 ); add_filter( 'plugin_row_meta', 'duplicate_post_add_plugin_links', 10, 2 ); @@ -129,6 +134,7 @@ function duplicate_post_plugin_upgrade() { add_option( 'duplicate_post_copyattachments', '0' ); add_option( 'duplicate_post_copychildren', '0' ); add_option( 'duplicate_post_copycomments', '0' ); + add_option( 'duplicate_post_copynotes', '0' ); add_option( 'duplicate_post_copymenuorder', '1' ); add_option( 'duplicate_post_taxonomies_blacklist', [] ); add_option( 'duplicate_post_blacklist', '' ); @@ -540,6 +546,19 @@ function duplicate_post_copy_children( $new_id, $post, $status = '' ) { } } +/** + * Copies Notes. + * + * @param int $new_id The new post ID. + * @param WP_Post $post The original post object. + * + * @return void + */ +function duplicate_post_copy_notes( $new_id, $post ) { + $notes_copier = new Notes_Copier(); + $notes_copier->copy( $new_id, $post, (int) get_option( 'duplicate_post_copydate' ) === 1 ); +} + /** * Copies comments. * diff --git a/src/admin/options.php b/src/admin/options.php index 995ac18c8..1c61202c5 100644 --- a/src/admin/options.php +++ b/src/admin/options.php @@ -179,6 +179,14 @@ public function get_options() { 'value' => 1, 'description' => \__( 'except pingbacks and trackbacks', 'duplicate-post' ), ], + 'duplicate_post_copynotes' => [ + 'tab' => 'what-to-copy', + 'fieldset' => 'elements-to-copy', + 'type' => 'checkbox', + 'label' => \__( 'Notes', 'duplicate-post' ), + 'value' => 1, + 'description' => \__( 'including their replies', 'duplicate-post' ), + ], 'duplicate_post_copymenuorder' => [ 'tab' => 'what-to-copy', 'fieldset' => 'elements-to-copy', diff --git a/src/notes-copier.php b/src/notes-copier.php new file mode 100644 index 000000000..413ff77e7 --- /dev/null +++ b/src/notes-copier.php @@ -0,0 +1,148 @@ + $post->ID, + 'type' => 'note', + 'order' => 'ASC', + 'orderby' => 'comment_date_gmt', + ], + ); + + $old_id_to_new = []; + $pending = $notes; + while ( ! empty( $pending ) ) { + $copied_note = false; + foreach ( $pending as $index => $note ) { + if ( $note->comment_parent && ! isset( $old_id_to_new[ $note->comment_parent ] ) ) { + continue; + } + + $new_note_id = $this->insert_note( $new_id, $note, $old_id_to_new, $copy_date ); + if ( $new_note_id ) { + $old_id_to_new[ $note->comment_ID ] = $new_note_id; + } + unset( $pending[ $index ] ); + $copied_note = true; + } + + if ( ! $copied_note ) { + break; + } + } + + if ( empty( $old_id_to_new ) ) { + return; + } + + $target_content = \get_post_field( 'post_content', $new_id ); + $blocks = \parse_blocks( $target_content ); + $this->remap_block_note_ids( $blocks, $old_id_to_new ); + $content = \serialize_blocks( $blocks ); + if ( $content !== $target_content ) { + \wp_update_post( + [ + 'ID' => $new_id, + 'post_content' => $content, + ], + ); + } + } + + /** + * Inserts one Note and copies its metadata. + * + * @param int $new_id The new post ID. + * @param WP_Comment $note The Note to copy. + * @param array $old_id_to_new The copied Note IDs. + * @param bool $copy_date Whether to preserve the Note dates. + * + * @return int The new Note ID, or 0 on failure. + */ + private function insert_note( $new_id, WP_Comment $note, array $old_id_to_new, $copy_date ) { + $parent = 0; + if ( $note->comment_parent && isset( $old_id_to_new[ $note->comment_parent ] ) ) { + $parent = $old_id_to_new[ $note->comment_parent ]; + } + + $commentdata = [ + 'comment_post_ID' => $new_id, + 'comment_author' => $note->comment_author, + 'comment_author_email' => $note->comment_author_email, + 'comment_author_url' => $note->comment_author_url, + 'comment_content' => $note->comment_content, + 'comment_type' => 'note', + 'comment_parent' => $parent, + 'user_id' => $note->user_id, + 'comment_author_IP' => $note->comment_author_IP, + 'comment_agent' => $note->comment_agent, + 'comment_karma' => $note->comment_karma, + 'comment_approved' => $note->comment_approved, + ]; + + if ( $copy_date ) { + $commentdata['comment_date'] = $note->comment_date; + $commentdata['comment_date_gmt'] = \get_gmt_from_date( $note->comment_date ); + } + + $new_note_id = \wp_insert_comment( $commentdata ); + if ( ! $new_note_id ) { + return 0; + } + + $commentmeta = \get_comment_meta( $note->comment_ID ); + foreach ( $commentmeta as $meta_key => $meta_values ) { + foreach ( $meta_values as $meta_value ) { + \add_comment_meta( $new_note_id, $meta_key, Utils::recursively_slash_strings( $meta_value ) ); + } + } + + return $new_note_id; + } + + /** + * Remaps Note IDs in block metadata recursively. + * + * @param array $blocks Parsed blocks. + * @param array $old_id_to_new Note ID map. + * + * @return void + */ + private function remap_block_note_ids( array &$blocks, array $old_id_to_new ) { + foreach ( $blocks as &$block ) { + if ( isset( $block['attrs']['metadata']['noteId'] ) ) { + $old_id = (int) $block['attrs']['metadata']['noteId']; + if ( isset( $old_id_to_new[ $old_id ] ) ) { + $block['attrs']['metadata']['noteId'] = $old_id_to_new[ $old_id ]; + } + } + + if ( ! empty( $block['innerBlocks'] ) ) { + $this->remap_block_note_ids( $block['innerBlocks'], $old_id_to_new ); + } + } + unset( $block ); + } +} diff --git a/tests/WP/Admin_Functions_Test.php b/tests/WP/Admin_Functions_Test.php index 23ae40cea..a57e79b18 100644 --- a/tests/WP/Admin_Functions_Test.php +++ b/tests/WP/Admin_Functions_Test.php @@ -38,6 +38,7 @@ final class Admin_Functions_Test extends TestCase { 'duplicate_post_increase_menu_order_by', 'duplicate_post_copychildren', 'duplicate_post_copycomments', + 'duplicate_post_copynotes', 'duplicate_post_copythumbnail', 'duplicate_post_copytemplate', 'duplicate_post_copyformat', @@ -106,6 +107,7 @@ public function set_up() { \update_option( 'duplicate_post_increase_menu_order_by', '' ); \update_option( 'duplicate_post_copychildren', '0' ); \update_option( 'duplicate_post_copycomments', '0' ); + \update_option( 'duplicate_post_copynotes', '0' ); \update_option( 'duplicate_post_copythumbnail', '1' ); \update_option( 'duplicate_post_copytemplate', '1' ); \update_option( 'duplicate_post_copyformat', '1' ); diff --git a/tests/WP/Legacy_Duplication_Test.php b/tests/WP/Legacy_Duplication_Test.php index e78a0cc4b..1af57a627 100644 --- a/tests/WP/Legacy_Duplication_Test.php +++ b/tests/WP/Legacy_Duplication_Test.php @@ -57,6 +57,7 @@ final class Legacy_Duplication_Test extends TestCase { 'duplicate_post_copychildren', 'duplicate_post_copyattachments', 'duplicate_post_copycomments', + 'duplicate_post_copynotes', 'duplicate_post_copythumbnail', 'duplicate_post_copytemplate', 'duplicate_post_copyformat', @@ -120,6 +121,7 @@ public function set_up() { \update_option( 'duplicate_post_copychildren', '0' ); \update_option( 'duplicate_post_copyattachments', '0' ); \update_option( 'duplicate_post_copycomments', '0' ); + \update_option( 'duplicate_post_copynotes', '0' ); \update_option( 'duplicate_post_copythumbnail', '1' ); \update_option( 'duplicate_post_copytemplate', '1' ); \update_option( 'duplicate_post_copyformat', '1' ); @@ -357,6 +359,66 @@ public function test_copies_comments_with_threading_and_skips_pingbacks() { $this->assertSame( (int) $by_content['Parent comment']->comment_ID, (int) $by_content['Reply comment']->comment_parent ); } + /** + * Tests that Notes and their block references are copied when enabled. + * + * @covers ::duplicate_post_copy_notes + * + * @return void + */ + public function test_copies_notes_and_remaps_block_references() { + \update_option( 'duplicate_post_copynotes', '1' ); + \add_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_notes', 45, 2 ); + + $original = $this->create_original_post(); + $note_id = $this->factory->comment->create( + [ + 'comment_post_ID' => $original->ID, + 'comment_content' => 'Parent note', + 'comment_type' => 'note', + 'comment_approved' => '0', + ], + ); + $reply_id = $this->factory->comment->create( + [ + 'comment_post_ID' => $original->ID, + 'comment_content' => 'Reply note', + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + ], + ); + global $wpdb; + $content = "\n

Content

\n"; + // Bypass post update hooks so the fixture keeps the invalid note reference. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->update( $wpdb->posts, [ 'post_content' => $content ], [ 'ID' => $original->ID ] ); + \clean_post_cache( $original->ID ); + $original = \get_post( $original->ID ); + + $new_id = \duplicate_post_create_duplicate( $original ); + + \remove_action( 'duplicate_post_after_duplicated', 'duplicate_post_copy_notes', 45 ); + $new_notes = \get_comments( + [ + 'post_id' => $new_id, + 'type' => 'note', + ], + ); + $by_content = []; + foreach ( $new_notes as $new_note ) { + $by_content[ $new_note->comment_content ] = $new_note; + } + + $this->assertCount( 2, $new_notes ); + $this->assertArrayHasKey( 'Parent note', $by_content ); + $this->assertArrayHasKey( 'Reply note', $by_content ); + $this->assertSame( (int) $by_content['Parent note']->comment_ID, (int) $by_content['Reply note']->comment_parent ); + $this->assertNotSame( $note_id, (int) $by_content['Parent note']->comment_ID ); + $this->assertStringContainsString( '"noteId":' . $by_content['Parent note']->comment_ID, \get_post_field( 'post_content', $new_id ) ); + $this->assertNotSame( $reply_id, (int) $by_content['Reply note']->comment_ID ); + } + /** * Tests that copied children include grandchildren via recursion. *