Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions admin-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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', '' );
Expand Down Expand Up @@ -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.
*
Expand Down
8 changes: 8 additions & 0 deletions src/admin/options.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
148 changes: 148 additions & 0 deletions src/notes-copier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

namespace Yoast\WP\Duplicate_Post;

use WP_Comment;
use WP_Post;

/**
* Copies block-level Notes from one post to another.
*
* @since 4.7
*/
class Notes_Copier {

/**
* Copies Notes and updates their references in the post content.
*
* @param int $new_id The new post ID.
* @param WP_Post $post The original post object.
* @param bool $copy_date Whether to preserve the Note dates.
*
* @return void
*/
public function copy( $new_id, WP_Post $post, $copy_date = false ) {
$notes = \get_comments(
[
'post_id' => $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<int, int> $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<int, array{}> $blocks Parsed blocks.
* @param array<int, int> $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 );
}
}
2 changes: 2 additions & 0 deletions tests/WP/Admin_Functions_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' );
Expand Down
62 changes: 62 additions & 0 deletions tests/WP/Legacy_Duplication_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' );
Expand Down Expand Up @@ -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 = "<!-- wp:paragraph {\"metadata\":{\"noteId\":$note_id}} -->\n<p>Content</p>\n<!-- /wp:paragraph -->";
// 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.
*
Expand Down