From 685cf6524b29dc1649f9bb5b3d287d686282589f Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Thu, 17 Sep 2026 18:20:01 +0600 Subject: [PATCH] feat(republish): option to preserve original date Scheduled Rewrite & Republish copies the copy's future post_date onto the original post, which bumps the original to the top of the post feed. Adds a "Preserve the original publication date" checkbox to the Duplicate Post options, defaulting to off. When enabled, the date fields are left out of the post array passed to wp_update_post(), so the original keeps its publish date while the modified date is refreshed like a normal post update. Immediate republishing is unchanged. Adds unit and integration tests for both option states. Fixes #403 --- admin-functions.php | 1 + src/admin/options.php | 6 ++ src/admin/views/options.php | 10 +++ src/post-republisher.php | 30 +++++-- tests/Unit/Post_Republisher_Test.php | 52 ++++++++++++- tests/WP/Admin_Functions_Test.php | 1 + tests/WP/Post_Republisher_Test.php | 112 +++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 9 deletions(-) diff --git a/admin-functions.php b/admin-functions.php index 26acf31ae..1c680f3a3 100644 --- a/admin-functions.php +++ b/admin-functions.php @@ -136,6 +136,7 @@ function duplicate_post_plugin_upgrade() { add_option( 'duplicate_post_show_original_column', '0' ); add_option( 'duplicate_post_show_original_in_post_states', '0' ); add_option( 'duplicate_post_show_original_meta_box', '0' ); + add_option( 'duplicate_post_preserve_date_on_scheduled_republish', '0' ); add_option( 'duplicate_post_show_link', [ diff --git a/src/admin/options.php b/src/admin/options.php index 995ac18c8..6943f008b 100644 --- a/src/admin/options.php +++ b/src/admin/options.php @@ -179,6 +179,12 @@ public function get_options() { 'value' => 1, 'description' => \__( 'except pingbacks and trackbacks', 'duplicate-post' ), ], + 'duplicate_post_preserve_date_on_scheduled_republish' => [ + 'tab' => 'what-to-copy', + 'type' => 'checkbox', + 'label' => \__( 'Preserve the original publication date', 'duplicate-post' ), + 'value' => 1, + ], 'duplicate_post_copymenuorder' => [ 'tab' => 'what-to-copy', 'fieldset' => 'elements-to-copy', diff --git a/src/admin/views/options.php b/src/admin/views/options.php index a1652ac27..49d5744ef 100644 --- a/src/admin/views/options.php +++ b/src/admin/views/options.php @@ -133,6 +133,16 @@ class="nav-tab" + + + + generate_input( 'duplicate_post_preserve_date_on_scheduled_republish' ); + ?> +

+ +
republish( $copy, $original_post ); + $this->republish( $copy, $original_post, $preserve_original_date ); \kses_init_filters(); $this->delete_copy( $copy->ID, $original_post->ID ); } @@ -304,12 +307,13 @@ public function is_rest_request() { /** * Republishes the post by overwriting the original post. * - * @param WP_Post $post The Rewrite & Republish copy. - * @param WP_Post $original_post The original post. + * @param WP_Post $post The Rewrite & Republish copy. + * @param WP_Post $original_post The original post. + * @param bool $preserve_original_date Whether to preserve the original post date. * * @return void */ - public function republish( WP_Post $post, WP_Post $original_post ) { + public function republish( WP_Post $post, WP_Post $original_post, $preserve_original_date = false ) { /** * Fires before the Rewrite & Republish copy is republished to the original post. @@ -333,7 +337,7 @@ public function republish( WP_Post $post, WP_Post $original_post ) { $this->republish_post_meta( $post ); // Republish the post. - $this->republish_post_elements( $post, $original_post ); + $this->republish_post_elements( $post, $original_post, $preserve_original_date ); // Mark the copy as already published. \update_post_meta( $post->ID, '_dp_has_been_republished', '1' ); @@ -387,12 +391,13 @@ public function delete_copy( $copy_id, $post_id = null, $permanently_delete = tr /** * Republishes the post elements overwriting the original post. * - * @param WP_Post $post The post object. - * @param WP_Post $original_post The original post. + * @param WP_Post $post The post object. + * @param WP_Post $original_post The original post. + * @param bool $preserve_original_date Whether to preserve the original post date. * * @return void */ - protected function republish_post_elements( $post, $original_post ) { + protected function republish_post_elements( $post, $original_post, $preserve_original_date = false ) { // Cast to array and not alter the copy's original object. $post_to_be_rewritten = clone $post; @@ -401,6 +406,15 @@ protected function republish_post_elements( $post, $original_post ) { $post_to_be_rewritten->post_name = $original_post->post_name; $post_to_be_rewritten->post_status = $this->determine_post_status( $post, $original_post ); + if ( $preserve_original_date ) { + unset( + $post_to_be_rewritten->post_date, + $post_to_be_rewritten->post_date_gmt, + $post_to_be_rewritten->post_modified, + $post_to_be_rewritten->post_modified_gmt, + ); + } + /** * Yoast SEO and other plugins prevent from accidentally updating another post's * data (e.g. the Yoast SEO metadata by checking the $_POST data ID with the post object ID. diff --git a/tests/Unit/Post_Republisher_Test.php b/tests/Unit/Post_Republisher_Test.php index e89e36920..e9fb372cc 100644 --- a/tests/Unit/Post_Republisher_Test.php +++ b/tests/Unit/Post_Republisher_Test.php @@ -546,10 +546,60 @@ public function test_republish_scheduled_post() { ->once() ->andReturn( $original ); + Monkey\Functions\expect( 'get_option' ) + ->with( 'duplicate_post_preserve_date_on_scheduled_republish' ) + ->once() + ->andReturn( '0' ); + Monkey\Functions\expect( 'kses_remove_filters' ); Monkey\Functions\expect( 'kses_init_filters' ); - $this->instance->expects( 'republish' )->with( $copy, $original )->once(); + $this->instance->expects( 'republish' )->with( $copy, $original, false )->once(); + $this->instance->expects( 'delete_copy' )->with( $copy->ID, $original->ID )->once(); + + $this->instance->republish_scheduled_post( $copy ); + } + + /** + * Tests the republish_scheduled_post function when the original date should be preserved. + * + * @covers \Yoast\WP\Duplicate_Post\Post_Republisher::republish_scheduled_post + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_republish_scheduled_post_preserves_original_date_when_enabled() { + $original = Mockery::mock( WP_Post::class ); + $original->ID = 1; + $original->post_status = 'publish'; + + $copy = Mockery::mock( WP_Post::class ); + $copy->ID = 123; + $copy->post_status = 'future'; + + $this->permissions_helper + ->expects( 'is_rewrite_and_republish_copy' ) + ->with( $copy ) + ->once() + ->andReturnTrue(); + + $utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' ); + $utils + ->expects( 'get_original' ) + ->with( $copy->ID ) + ->once() + ->andReturn( $original ); + + Monkey\Functions\expect( 'get_option' ) + ->with( 'duplicate_post_preserve_date_on_scheduled_republish' ) + ->once() + ->andReturn( '1' ); + + Monkey\Functions\expect( 'kses_remove_filters' ); + Monkey\Functions\expect( 'kses_init_filters' ); + + $this->instance->expects( 'republish' )->with( $copy, $original, true )->once(); $this->instance->expects( 'delete_copy' )->with( $copy->ID, $original->ID )->once(); $this->instance->republish_scheduled_post( $copy ); diff --git a/tests/WP/Admin_Functions_Test.php b/tests/WP/Admin_Functions_Test.php index 23ae40cea..f21c05626 100644 --- a/tests/WP/Admin_Functions_Test.php +++ b/tests/WP/Admin_Functions_Test.php @@ -30,6 +30,7 @@ final class Admin_Functions_Test extends TestCase { 'duplicate_post_copycontent', 'duplicate_post_copyexcerpt', 'duplicate_post_copydate', + 'duplicate_post_preserve_date_on_scheduled_republish', 'duplicate_post_copystatus', 'duplicate_post_copyslug', 'duplicate_post_copyauthor', diff --git a/tests/WP/Post_Republisher_Test.php b/tests/WP/Post_Republisher_Test.php index 67a3a8ae1..b59e4fb8c 100644 --- a/tests/WP/Post_Republisher_Test.php +++ b/tests/WP/Post_Republisher_Test.php @@ -47,6 +47,7 @@ public function set_up() { // Enable post and page for duplication. \update_option( 'duplicate_post_types_enabled', [ 'post', 'page' ] ); + \update_option( 'duplicate_post_preserve_date_on_scheduled_republish', '0' ); $this->post_duplicator = new Post_Duplicator(); $this->permissions_helper = new Permissions_Helper(); @@ -458,6 +459,117 @@ public function test_republish_scheduled_post_republishes_copy() { $this->assertSame( '', \get_post_meta( $original_id, '_dp_has_rewrite_republish_copy', true ) ); } + /** + * Tests republish_scheduled_post preserves the original post date when the option is enabled. + * + * @covers ::republish_scheduled_post + * @covers ::republish_post_elements + * + * @return void + */ + public function test_republish_scheduled_post_preserves_original_date_when_option_enabled() { + \update_option( 'duplicate_post_preserve_date_on_scheduled_republish', '1' ); + + $original = $this->create_original_post( + [ + 'post_title' => 'Original Title', + 'post_content' => 'Original content.', + 'post_date' => '2020-01-01 00:00:00', + 'post_date_gmt' => '2020-01-01 00:00:00', + ], + ); + + $copy = $this->create_rewrite_and_republish_copy( $original ); + + // Pin the original's modified date in the past so the refresh is observable. + // wp_update_post is not used here because it recalculates post_modified itself. + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->update( + $wpdb->posts, + [ + 'post_modified' => '2020-01-02 00:00:00', + 'post_modified_gmt' => '2020-01-02 00:00:00', + ], + [ 'ID' => $original->ID ], + ); + \clean_post_cache( $original->ID ); + + $this->update_post_without_republish( + [ + 'ID' => $copy->ID, + 'post_title' => 'Scheduled Updated Title', + 'post_content' => 'Scheduled updated content.', + ], + ); + + $copy = $this->schedule_copy_for_future( $copy ); + + $this->instance->republish_scheduled_post( $copy ); + + $updated_original = \get_post( $original->ID ); + + // The content is republished as usual. + $this->assertSame( 'Scheduled Updated Title', $updated_original->post_title ); + $this->assertSame( 'Scheduled updated content.', $updated_original->post_content ); + + // The publish date is untouched, so the post does not jump to the top of the feed. + $this->assertSame( '2020-01-01 00:00:00', $updated_original->post_date ); + $this->assertSame( '2020-01-01 00:00:00', $updated_original->post_date_gmt ); + + // The modified date is refreshed, mimicking a default WordPress post update. + $this->assertNotSame( '2020-01-02 00:00:00', $updated_original->post_modified ); + $this->assertNotSame( '2020-01-02 00:00:00', $updated_original->post_modified_gmt ); + $this->assertSame( 'publish', $updated_original->post_status ); + + // Verify the copy was deleted. + $this->assertNull( \get_post( $copy->ID ) ); + } + + /** + * Tests republish_scheduled_post overwrites the original post date by default. + * + * Without the option, the pre-existing behavior is kept: the original takes the + * scheduled date of the copy. + * + * @covers ::republish_scheduled_post + * @covers ::republish_post_elements + * + * @return void + */ + public function test_republish_scheduled_post_overwrites_original_date_by_default() { + $original = $this->create_original_post( + [ + 'post_title' => 'Original Title', + 'post_content' => 'Original content.', + 'post_date' => '2020-01-01 00:00:00', + 'post_date_gmt' => '2020-01-01 00:00:00', + ], + ); + + $copy = $this->create_rewrite_and_republish_copy( $original ); + + $this->update_post_without_republish( + [ + 'ID' => $copy->ID, + 'post_title' => 'Scheduled Updated Title', + 'post_content' => 'Scheduled updated content.', + ], + ); + + $copy = $this->schedule_copy_for_future( $copy ); + + $this->instance->republish_scheduled_post( $copy ); + + $updated_original = \get_post( $original->ID ); + + $this->assertSame( 'Scheduled Updated Title', $updated_original->post_title ); + + // Without the option the original adopts the copy's scheduled date. + $this->assertSame( $copy->post_date, $updated_original->post_date ); + $this->assertNotSame( '2020-01-01 00:00:00', $updated_original->post_date ); + } + /** * Tests republish_scheduled_post trashes copy when original is deleted. *