• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / duplicate-post / 21497836723

29 Jan 2026 11:01PM UTC coverage: 50.117% (+0.2%) from 49.922%
21497836723

Pull #447

github

web-flow
Merge 821c436c4 into 70cd21894
Pull Request #447: Prevents and cleans up orphan Rewrite & Republish copies

13 of 18 new or added lines in 1 file covered. (72.22%)

4 existing lines in 1 file now uncovered.

1287 of 2568 relevant lines covered (50.12%)

2.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.17
/src/post-republisher.php
1
<?php
2

3
namespace Yoast\WP\Duplicate_Post;
4

5
use WP_Post;
6

7
/**
8
 * Duplicate Post class to republish a rewritten post.
9
 *
10
 * @since 4.0
11
 */
12
class Post_Republisher {
13

14
        /**
15
         * Post_Duplicator object.
16
         *
17
         * @var Post_Duplicator
18
         */
19
        protected $post_duplicator;
20

21
        /**
22
         * Holds the permissions helper.
23
         *
24
         * @var Permissions_Helper
25
         */
26
        protected $permissions_helper;
27

28
        /**
29
         * Initializes the class.
30
         *
31
         * @param Post_Duplicator    $post_duplicator    The Post_Duplicator object.
32
         * @param Permissions_Helper $permissions_helper The Permissions Helper object.
33
         */
34
        public function __construct( Post_Duplicator $post_duplicator, Permissions_Helper $permissions_helper ) {
2✔
35
                $this->post_duplicator    = $post_duplicator;
2✔
36
                $this->permissions_helper = $permissions_helper;
2✔
37
        }
38

39
        /**
40
         * Adds hooks to integrate with WordPress.
41
         *
42
         * @return void
43
         */
44
        public function register_hooks() {
2✔
45
                \add_action( 'init', [ $this, 'register_post_statuses' ] );
2✔
46
                \add_filter( 'wp_insert_post_data', [ $this, 'change_post_copy_status' ], 1, 2 );
2✔
47

48
                $enabled_post_types = $this->permissions_helper->get_enabled_post_types();
2✔
49
                foreach ( $enabled_post_types as $enabled_post_type ) {
2✔
50
                        /**
51
                         * Called in the REST API when submitting the post copy in the Block Editor.
52
                         * Runs the republishing of the copy onto the original.
53
                         */
54
                        \add_action( "rest_after_insert_{$enabled_post_type}", [ $this, 'republish_after_rest_api_request' ] );
2✔
55
                }
56

57
                /**
58
                 * Called by `wp_insert_post()` when submitting the post copy, which runs in two cases:
59
                 * - In the Classic Editor, where there's only one request that updates everything.
60
                 * - In the Block Editor, only when there are custom meta boxes.
61
                 */
62
                \add_action( 'wp_insert_post', [ $this, 'republish_after_post_request' ], \PHP_INT_MAX, 2 );
2✔
63

64
                // Clean up orphaned R&R copies when opening a post for editing.
65
                \add_action( 'load-post.php', [ $this, 'clean_up_orphaned_copy' ], 5 );
2✔
66
                // Clean up after the redirect to the original post.
67
                \add_action( 'load-post.php', [ $this, 'clean_up_after_redirect' ] );
2✔
68
                // Clean up the original when the copy is manually deleted from the trash.
69
                \add_action( 'before_delete_post', [ $this, 'clean_up_when_copy_manually_deleted' ] );
2✔
70
                // Ensure scheduled Rewrite and Republish posts are properly handled.
71
                \add_action( 'future_to_publish', [ $this, 'republish_scheduled_post' ] );
2✔
72
        }
73

74
        /**
75
         * Adds custom post statuses.
76
         *
77
         * These post statuses are meant for internal use. However, we can't use the
78
         * `internal` status because the REST API posts controller allows all registered
79
         * statuses but the `internal` one.
80
         *
81
         * @return void
82
         */
83
        public function register_post_statuses() {
2✔
84
                $options = [
2✔
85
                        'label'                     => \__( 'Republish', 'duplicate-post' ),
2✔
86
                        'exclude_from_search'       => false,
2✔
87
                        'show_in_admin_all_list'    => false,
2✔
88
                        'show_in_admin_status_list' => false,
2✔
89
                ];
2✔
90

91
                \register_post_status( 'dp-rewrite-republish', $options );
2✔
92
        }
93

94
        /**
95
         * Changes the post copy status.
96
         *
97
         * Runs on the `wp_insert_post_data` hook in `wp_insert_post()` when
98
         * submitting the post copy.
99
         *
100
         * @param array $data    An array of slashed, sanitized, and processed post data.
101
         * @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
102
         *
103
         * @return array An array of slashed, sanitized, and processed attachment post data.
104
         */
105
        public function change_post_copy_status( $data, $postarr ) {
16✔
106
                if ( ! \array_key_exists( 'ID', $postarr ) || empty( $postarr['ID'] ) ) {
16✔
107
                        return $data;
12✔
108
                }
109

110
                $post = \get_post( $postarr['ID'] );
16✔
111

112
                if ( ! $post || ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
16✔
113
                        return $data;
6✔
114
                }
115

116
                if ( $data['post_status'] === 'publish' ) {
10✔
117
                        $data['post_status'] = 'dp-rewrite-republish';
6✔
118
                }
119

120
                return $data;
10✔
121
        }
122

123
        /**
124
         * Executes the republish request.
125
         *
126
         * @param WP_Post $post The copy's post object.
127
         *
128
         * @return void
129
         */
130
        public function republish_request( $post ) {
4✔
131
                if (
132
                        ! $post instanceof WP_Post
4✔
133
                        || ! $this->permissions_helper->is_rewrite_and_republish_copy( $post )
4✔
134
                        || ! $this->permissions_helper->is_copy_allowed_to_be_republished( $post )
4✔
135
                ) {
136
                        return;
4✔
137
                }
138

UNCOV
139
                $original_post = Utils::get_original( $post->ID );
×
140

UNCOV
141
                if ( ! $original_post ) {
×
UNCOV
142
                        return;
×
143
                }
144

145
                $this->republish( $post, $original_post );
×
146

147
                // Trigger the redirect in the Classic Editor.
148
                if ( $this->is_classic_editor_post_request() ) {
×
149
                        $this->redirect( $original_post->ID, $post->ID );
×
150
                }
151
        }
152

153
        /**
154
         * Republishes the original post with the passed post, when using the Block Editor.
155
         *
156
         * @param WP_Post $post The copy's post object.
157
         *
158
         * @return void
159
         */
160
        public function republish_after_rest_api_request( $post ) {
×
161
                $this->republish_request( $post );
×
162
        }
163

164
        /**
165
         * Republishes the original post with the passed post, when using the Classic Editor.
166
         *
167
         * Runs also in the Block Editor to save the custom meta data only when there
168
         * are custom meta boxes.
169
         *
170
         * @param int     $post_id The copy's post ID.
171
         * @param WP_Post $post    The copy's post object.
172
         *
173
         * @return void
174
         */
175
        public function republish_after_post_request( $post_id, $post ) {
×
176
                if ( $this->is_rest_request() ) {
×
177
                        return;
×
178
                }
179

180
                $this->republish_request( $post );
×
181
        }
182

183
        /**
184
         * Republishes the scheduled Rewrited and Republish post.
185
         *
186
         * @param WP_Post $copy The scheduled copy.
187
         *
188
         * @return void
189
         */
190
        public function republish_scheduled_post( $copy ) {
18✔
191
                if ( ! $copy instanceof WP_Post || ! $this->permissions_helper->is_rewrite_and_republish_copy( $copy ) ) {
18✔
192
                        return;
6✔
193
                }
194

195
                $original_post = Utils::get_original( $copy->ID );
12✔
196

197
                // If the original post was permanently deleted, we don't want to republish, so trash instead.
198
                if ( ! $original_post ) {
12✔
199
                        $this->delete_copy( $copy->ID, null, false );
6✔
200

201
                        return;
6✔
202
                }
203

204
                \kses_remove_filters();
6✔
205
                $this->republish( $copy, $original_post );
6✔
206
                \kses_init_filters();
6✔
207
                $this->delete_copy( $copy->ID, $original_post->ID );
6✔
208
        }
209

210
        /**
211
         * Cleans up orphaned Rewrite & Republish copies when opening a post for editing.
212
         *
213
         * This ensures that if a copy is stuck in the dp-rewrite-republish status,
214
         * it gets deleted automatically to unblock the R&R functionality.
215
         *
216
         * @return void
217
         */
218
        public function clean_up_orphaned_copy() {
8✔
219
                if ( empty( $_GET['post'] ) || empty( $_GET['action'] ) || $_GET['action'] !== 'edit' ) {
8✔
220
                        return;
2✔
221
                }
222

223
                $post_id = \intval( \wp_unslash( $_GET['post'] ) );
6✔
224
                $post    = \get_post( $post_id );
6✔
225

226
                if ( ! $post || $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
6✔
NEW
227
                        return;
×
228
                }
229

230
                // Check if this post has an orphaned R&R copy.
231
                $copy = $this->permissions_helper->get_rewrite_and_republish_copy( $post );
6✔
232

233
                if ( ! $copy ) {
6✔
234
                        return;
2✔
235
                }
236

237
                // If the copy is in dp-rewrite-republish status, it's orphaned and should be deleted.
238
                if ( $copy->post_status === 'dp-rewrite-republish' ) {
4✔
239
                        $this->delete_copy( $copy->ID, $post->ID );
2✔
240
                }
241
        }
242

243
        /**
244
         * Cleans up after the user has been redirected to the original post.
245
         *
246
         * Note: The copy is now deleted immediately after republishing, so this method
247
         * only verifies the nonce when the redirect parameters are present.
248
         *
249
         * @return void
250
         */
NEW
251
        public function clean_up_after_redirect() {
×
NEW
252
                if ( ! empty( $_GET['dprepublished'] ) && ! empty( $_GET['post'] ) && ! empty( $_GET['dpnonce'] ) ) {
×
NEW
253
                        \check_admin_referer( 'dp-republish', 'dpnonce' );
×
254
                }
255
        }
256

257
        /**
258
         * Checks whether a request is the Classic Editor POST request.
259
         *
260
         * @return bool Whether the request is the Classic Editor POST request.
261
         */
262
        public function is_classic_editor_post_request() {
6✔
263
                if ( $this->is_rest_request() || \wp_doing_ajax() ) {
6✔
264
                        return false;
2✔
265
                }
266

267
                return isset( $_GET['meta-box-loader'] ) === false;
4✔
268
        }
269

270
        /**
271
         * Determines whether the current request is a REST request.
272
         *
273
         * @return bool Whether or not the request is a REST request.
274
         */
275
        public function is_rest_request() {
×
276
                return \defined( 'REST_REQUEST' ) && \REST_REQUEST;
×
277
        }
278

279
        /**
280
         * Republishes the post by overwriting the original post.
281
         *
282
         * @param WP_Post $post          The Rewrite & Republish copy.
283
         * @param WP_Post $original_post The original post.
284
         *
285
         * @return void
286
         */
287
        public function republish( WP_Post $post, WP_Post $original_post ) {
64✔
288
                // Remove WordPress default filter so a new revision is not created on republish.
289
                \remove_action( 'post_updated', 'wp_save_post_revision', 10 );
64✔
290

291
                // Republish taxonomies and meta.
292
                $this->republish_post_taxonomies( $post );
64✔
293
                $this->republish_post_meta( $post );
64✔
294

295
                // Republish the post.
296
                $this->republish_post_elements( $post, $original_post );
64✔
297

298
                // Mark the copy as already published.
299
                \update_post_meta( $post->ID, '_dp_has_been_republished', '1' );
64✔
300

301
                // Delete the copy immediately after republishing.
302
                $this->delete_copy( $post->ID, $original_post->ID );
64✔
303

304
                // Re-enable the creation of a new revision.
305
                \add_action( 'post_updated', 'wp_save_post_revision', 10, 1 );
64✔
306
        }
307

308
        /**
309
         * Deletes the copy and associated post meta, if applicable.
310
         *
311
         * @param int      $copy_id            The copy's ID.
312
         * @param int|null $post_id            The original post's ID. Optional.
313
         * @param bool     $permanently_delete Whether to permanently delete the copy. Defaults to true.
314
         *
315
         * @return void
316
         */
317
        public function delete_copy( $copy_id, $post_id = null, $permanently_delete = true ) {
20✔
318
                /**
319
                 * Fires before deleting a Rewrite & Republish copy.
320
                 *
321
                 * @param int $copy_id The copy's ID.
322
                 * @param int $post_id The original post's ID..
323
                 */
324
                \do_action( 'duplicate_post_after_rewriting', $copy_id, $post_id );
20✔
325

326
                // Delete the copy bypassing the trash so it also deletes the copy post meta.
327
                \wp_delete_post( $copy_id, $permanently_delete );
20✔
328

329
                if ( ! \is_null( $post_id ) ) {
20✔
330
                        // Delete the meta that marks the original post has having a copy.
331
                        \delete_post_meta( $post_id, '_dp_has_rewrite_republish_copy' );
16✔
332
                }
333
        }
334

335
        /**
336
         * Republishes the post elements overwriting the original post.
337
         *
338
         * @param WP_Post $post          The post object.
339
         * @param WP_Post $original_post The original post.
340
         *
341
         * @return void
342
         */
343
        protected function republish_post_elements( $post, $original_post ) {
44✔
344
                // Cast to array and not alter the copy's original object.
345
                $post_to_be_rewritten = clone $post;
44✔
346

347
                // Prepare post data for republishing.
348
                $post_to_be_rewritten->ID          = $original_post->ID;
44✔
349
                $post_to_be_rewritten->post_name   = $original_post->post_name;
44✔
350
                $post_to_be_rewritten->post_status = $this->determine_post_status( $post, $original_post );
44✔
351

352
                /**
353
                 * Yoast SEO and other plugins prevent from accidentally updating another post's
354
                 * data (e.g. the Yoast SEO metadata by checking the $_POST data ID with the post object ID.
355
                 * We need to overwrite the $_POST data ID to allow updating the original post.
356
                 */
357
                $_POST['ID'] = $original_post->ID;
44✔
358

359
                // Republish the original post.
360
                $rewritten_post_id = \wp_update_post( $post_to_be_rewritten );
44✔
361

362
                if ( $rewritten_post_id === 0 ) {
44✔
363
                        \wp_die( \esc_html__( 'An error occurred while republishing the post.', 'duplicate-post' ) );
×
364
                }
365
        }
366

367
        /**
368
         * Republishes the post taxonomies overwriting the ones of the original post.
369
         *
370
         * @param WP_Post $post The copy's post object.
371
         *
372
         * @return void
373
         */
374
        protected function republish_post_taxonomies( $post ) {
28✔
375
                $original_post_id = Utils::get_original_post_id( $post->ID );
28✔
376

377
                $copy_taxonomies_options = [
28✔
378
                        'taxonomies_excludelist' => [],
28✔
379
                        'use_filters'            => false,
28✔
380
                        'copy_format'            => true,
28✔
381
                ];
28✔
382
                $this->post_duplicator->copy_post_taxonomies( $original_post_id, $post, $copy_taxonomies_options );
28✔
383
        }
384

385
        /**
386
         * Republishes the post meta overwriting the ones of the original post.
387
         *
388
         * @param WP_Post $post The copy's post object.
389
         *
390
         * @return void
391
         */
392
        protected function republish_post_meta( $post ) {
24✔
393
                $original_post_id = Utils::get_original_post_id( $post->ID );
24✔
394

395
                $copy_meta_options = [
24✔
396
                        'meta_excludelist' => Utils::get_default_filtered_meta_names(),
24✔
397
                        'use_filters'      => false,
24✔
398
                        'copy_thumbnail'   => true,
24✔
399
                        'copy_template'    => true,
24✔
400
                ];
24✔
401
                $this->post_duplicator->copy_post_meta_info( $original_post_id, $post, $copy_meta_options );
24✔
402
        }
403

404
        /**
405
         * Redirects the user to the original post.
406
         *
407
         * @param int $original_post_id The ID of the original post to redirect to.
408
         *
409
         * @return void
410
         */
NEW
411
        protected function redirect( $original_post_id ) {
×
412
                \wp_safe_redirect(
×
413
                        \add_query_arg(
×
414
                                [
×
415
                                        'dprepublished' => 1,
×
416
                                        'dpnonce'       => \wp_create_nonce( 'dp-republish' ),
×
417
                                ],
×
418
                                \admin_url( 'post.php?action=edit&post=' . $original_post_id )
×
419
                        )
×
420
                );
×
421
                exit();
×
422
        }
423

424
        /**
425
         * Determines the post status to use when publishing the Rewrite & Republish copy.
426
         *
427
         * @param WP_Post $post          The post object.
428
         * @param WP_Post $original_post The original post object.
429
         *
430
         * @return string The post status to use.
431
         */
432
        protected function determine_post_status( $post, $original_post ) {
20✔
433
                if ( $original_post->post_status === 'trash' ) {
20✔
434
                        return 'trash';
8✔
435
                }
436

437
                if ( $post->post_status === 'private' ) {
12✔
438
                        return 'private';
4✔
439
                }
440

441
                return 'publish';
8✔
442
        }
443

444
        /**
445
         * Deletes the original post meta that flags it as having a copy when the copy is manually deleted.
446
         *
447
         * @param int $post_id Post ID of a post that is going to be deleted.
448
         *
449
         * @return void
450
         */
451
        public function clean_up_when_copy_manually_deleted( $post_id ) {
4✔
452
                $post = \get_post( $post_id );
4✔
453

454
                if ( ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
4✔
UNCOV
455
                        return;
×
456
                }
457

458
                $original_post_id = Utils::get_original_post_id( $post_id );
4✔
459
                \delete_post_meta( $original_post_id, '_dp_has_rewrite_republish_copy' );
4✔
460
        }
461
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc