PluginProbe ʕ •ᴥ•ʔ
Yoast Duplicate Post / 4.6
Yoast Duplicate Post v4.6
trunk 0.3 0.4 0.5 0.6 0.6.1 1.0 1.1 1.1.1 1.1.2 2.0 2.0.1 2.0.2 2.1 2.1.1 2.2 2.3 2.4 2.4.1 2.5 2.6 3.0 3.0.1 3.0.2 3.0.3 3.1 3.1.1 3.1.2 3.2 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 4.0 4.0.1 4.0.2 4.1 4.1.1 4.1.2 4.2 4.3 4.4 4.5 4.6
duplicate-post / src / post-republisher.php
duplicate-post / src Last commit date
admin 3 months ago handlers 3 months ago ui 3 months ago watchers 3 months ago duplicate-post.php 4 years ago permissions-helper.php 4 years ago post-duplicator.php 3 months ago post-republisher.php 3 months ago revisions-migrator.php 4 years ago utils.php 3 months ago
post-republisher.php
506 lines
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 ) {
35 $this->post_duplicator = $post_duplicator;
36 $this->permissions_helper = $permissions_helper;
37 }
38
39 /**
40 * Adds hooks to integrate with WordPress.
41 *
42 * @return void
43 */
44 public function register_hooks() {
45 \add_action( 'init', [ $this, 'register_post_statuses' ] );
46 \add_filter( 'wp_insert_post_data', [ $this, 'change_post_copy_status' ], 1, 2 );
47
48 $enabled_post_types = $this->permissions_helper->get_enabled_post_types();
49 foreach ( $enabled_post_types as $enabled_post_type ) {
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' ] );
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 );
63
64 // Clean up after the redirect to the original post.
65 \add_action( 'load-post.php', [ $this, 'clean_up_after_redirect' ] );
66 // Clean up orphaned R&R copies when opening a post for editing.
67 \add_action( 'load-post.php', [ $this, 'clean_up_orphaned_copy' ], 11 );
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' ] );
70 // Ensure scheduled Rewrite and Republish posts are properly handled.
71 \add_action( 'future_to_publish', [ $this, 'republish_scheduled_post' ] );
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() {
84 $options = [
85 'label' => \__( 'Republish', 'duplicate-post' ),
86 'exclude_from_search' => false,
87 'show_in_admin_all_list' => false,
88 'show_in_admin_status_list' => false,
89 ];
90
91 \register_post_status( 'dp-rewrite-republish', $options );
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 ) {
106 if ( ! \array_key_exists( 'ID', $postarr ) || empty( $postarr['ID'] ) ) {
107 return $data;
108 }
109
110 $post = \get_post( $postarr['ID'] );
111
112 if ( ! $post || ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
113 return $data;
114 }
115
116 if ( $data['post_status'] === 'publish' ) {
117 $data['post_status'] = 'dp-rewrite-republish';
118 }
119
120 return $data;
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 ) {
131 if (
132 ! $post instanceof WP_Post
133 || ! $this->permissions_helper->is_rewrite_and_republish_copy( $post )
134 || ! $this->permissions_helper->is_copy_allowed_to_be_republished( $post )
135 ) {
136 return;
137 }
138
139 $original_post = Utils::get_original( $post->ID );
140
141 if ( ! $original_post ) {
142 return;
143 }
144
145 if ( ! \current_user_can( 'edit_post', $original_post->ID ) ) {
146 \wp_die(
147 \esc_html__( 'You are not allowed to republish this post.', 'duplicate-post' ),
148 \esc_html__( 'Permission denied', 'duplicate-post' ),
149 [ 'response' => 403 ],
150 );
151 }
152
153 $this->republish( $post, $original_post );
154
155 // Trigger the redirect in the Classic Editor.
156 if ( $this->is_classic_editor_post_request() ) {
157 $this->redirect( $original_post->ID, $post->ID );
158 }
159 }
160
161 /**
162 * Republishes the original post with the passed post, when using the Block Editor.
163 *
164 * @param WP_Post $post The copy's post object.
165 *
166 * @return void
167 */
168 public function republish_after_rest_api_request( $post ) {
169 $this->republish_request( $post );
170 }
171
172 /**
173 * Republishes the original post with the passed post, when using the Classic Editor.
174 *
175 * Runs also in the Block Editor to save the custom meta data only when there
176 * are custom meta boxes.
177 *
178 * @param int $post_id The copy's post ID.
179 * @param WP_Post $post The copy's post object.
180 *
181 * @return void
182 */
183 public function republish_after_post_request( $post_id, $post ) {
184 if ( $this->is_rest_request() ) {
185 return;
186 }
187
188 $this->republish_request( $post );
189 }
190
191 /**
192 * Republishes the scheduled Rewrited and Republish post.
193 *
194 * @param WP_Post $copy The scheduled copy.
195 *
196 * @return void
197 */
198 public function republish_scheduled_post( $copy ) {
199 if ( ! $copy instanceof WP_Post || ! $this->permissions_helper->is_rewrite_and_republish_copy( $copy ) ) {
200 return;
201 }
202
203 $original_post = Utils::get_original( $copy->ID );
204
205 // If the original post was permanently deleted, we don't want to republish, so trash instead.
206 if ( ! $original_post ) {
207 $this->delete_copy( $copy->ID, null, false );
208
209 return;
210 }
211
212 \kses_remove_filters();
213 $this->republish( $copy, $original_post );
214 \kses_init_filters();
215 $this->delete_copy( $copy->ID, $original_post->ID );
216 }
217
218 /**
219 * Cleans up orphaned Rewrite & Republish copies when opening a post for editing.
220 *
221 * This ensures that if a copy is stuck in the dp-rewrite-republish status,
222 * it gets deleted automatically to unblock the R&R functionality.
223 *
224 * @return void
225 */
226 public function clean_up_orphaned_copy() {
227 if ( empty( $_GET['post'] ) || empty( $_GET['action'] ) || $_GET['action'] !== 'edit' ) {
228 return;
229 }
230
231 $post_id = \intval( \wp_unslash( $_GET['post'] ) );
232 $post = \get_post( $post_id );
233
234 if ( ! $post || $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
235 return;
236 }
237
238 // Check if this post has an orphaned R&R copy.
239 $copy = $this->permissions_helper->get_rewrite_and_republish_copy( $post );
240
241 if ( ! $copy ) {
242 return;
243 }
244
245 // If the copy is in dp-rewrite-republish status, it's orphaned and should be deleted.
246 if ( $copy->post_status === 'dp-rewrite-republish' ) {
247 $this->delete_copy( $copy->ID, $post->ID );
248 }
249 }
250
251 /**
252 * Cleans up the copied post and temporary metadata after the user has been redirected.
253 *
254 * @return void
255 */
256 public function clean_up_after_redirect() {
257 if ( ! empty( $_GET['dprepublished'] ) && ! empty( $_GET['dpcopy'] ) && ! empty( $_GET['post'] ) ) {
258 $copy_id = \intval( \wp_unslash( $_GET['dpcopy'] ) );
259 $post_id = \intval( \wp_unslash( $_GET['post'] ) );
260
261 \check_admin_referer( 'dp-republish', 'dpnonce' );
262
263 if ( \intval( \get_post_meta( $copy_id, '_dp_has_been_republished', true ) ) === 1 ) {
264 $this->delete_copy( $copy_id, $post_id );
265 }
266 else {
267 \wp_die( \esc_html__( 'An error occurred while deleting the Rewrite & Republish copy.', 'duplicate-post' ) );
268 }
269 }
270 }
271
272 /**
273 * Checks whether a request is the Classic Editor POST request.
274 *
275 * @return bool Whether the request is the Classic Editor POST request.
276 */
277 public function is_classic_editor_post_request() {
278 if ( $this->is_rest_request() || \wp_doing_ajax() ) {
279 return false;
280 }
281
282 return isset( $_GET['meta-box-loader'] ) === false;
283 }
284
285 /**
286 * Determines whether the current request is a REST request.
287 *
288 * @return bool Whether or not the request is a REST request.
289 */
290 public function is_rest_request() {
291 return \defined( 'REST_REQUEST' ) && \REST_REQUEST;
292 }
293
294 /**
295 * Republishes the post by overwriting the original post.
296 *
297 * @param WP_Post $post The Rewrite & Republish copy.
298 * @param WP_Post $original_post The original post.
299 *
300 * @return void
301 */
302 public function republish( WP_Post $post, WP_Post $original_post ) {
303
304 /**
305 * Fires before the Rewrite & Republish copy is republished to the original post.
306 *
307 * This action runs before any content, taxonomies, or meta are copied from the
308 * Rewrite & Republish copy to the original post. Use this hook to perform actions
309 * or modifications before the republishing process begins.
310 *
311 * @since 4.6
312 *
313 * @param WP_Post $post The Rewrite & Republish copy.
314 * @param WP_Post $original_post The original post that will be overwritten.
315 */
316 \do_action( 'duplicate_post_before_republish', $post, $original_post );
317
318 // Remove WordPress default filter so a new revision is not created on republish.
319 \remove_action( 'post_updated', 'wp_save_post_revision', 10 );
320
321 // Republish taxonomies and meta.
322 $this->republish_post_taxonomies( $post );
323 $this->republish_post_meta( $post );
324
325 // Republish the post.
326 $this->republish_post_elements( $post, $original_post );
327
328 // Mark the copy as already published.
329 \update_post_meta( $post->ID, '_dp_has_been_republished', '1' );
330
331 // Re-enable the creation of a new revision.
332 \add_action( 'post_updated', 'wp_save_post_revision', 10, 1 );
333
334 /**
335 * Fires after the Rewrite & Republish copy has been republished to the original post.
336 *
337 * This action runs after all content, taxonomies, and meta have been copied from
338 * the Rewrite & Republish copy to the original post. The copy is marked as republished
339 * but has not yet been deleted. Use this hook to perform cleanup or additional
340 * processing after the republishing is complete.
341 *
342 * @since 4.6
343 *
344 * @param WP_Post $post The Rewrite & Republish copy.
345 * @param WP_Post $original_post The original post that has been updated.
346 */
347 \do_action( 'duplicate_post_after_republish', $post, $original_post );
348 }
349
350 /**
351 * Deletes the copy and associated post meta, if applicable.
352 *
353 * @param int $copy_id The copy's ID.
354 * @param int|null $post_id The original post's ID. Optional.
355 * @param bool $permanently_delete Whether to permanently delete the copy. Defaults to true.
356 *
357 * @return void
358 */
359 public function delete_copy( $copy_id, $post_id = null, $permanently_delete = true ) {
360 /**
361 * Fires before deleting a Rewrite & Republish copy.
362 *
363 * @param int $copy_id The copy's ID.
364 * @param int $post_id The original post's ID..
365 */
366 \do_action( 'duplicate_post_after_rewriting', $copy_id, $post_id );
367
368 // Delete the copy bypassing the trash so it also deletes the copy post meta.
369 \wp_delete_post( $copy_id, $permanently_delete );
370
371 if ( ! \is_null( $post_id ) ) {
372 // Delete the meta that marks the original post has having a copy.
373 \delete_post_meta( $post_id, '_dp_has_rewrite_republish_copy' );
374 }
375 }
376
377 /**
378 * Republishes the post elements overwriting the original post.
379 *
380 * @param WP_Post $post The post object.
381 * @param WP_Post $original_post The original post.
382 *
383 * @return void
384 */
385 protected function republish_post_elements( $post, $original_post ) {
386 // Cast to array and not alter the copy's original object.
387 $post_to_be_rewritten = clone $post;
388
389 // Prepare post data for republishing.
390 $post_to_be_rewritten->ID = $original_post->ID;
391 $post_to_be_rewritten->post_name = $original_post->post_name;
392 $post_to_be_rewritten->post_status = $this->determine_post_status( $post, $original_post );
393
394 /**
395 * Yoast SEO and other plugins prevent from accidentally updating another post's
396 * data (e.g. the Yoast SEO metadata by checking the $_POST data ID with the post object ID.
397 * We need to overwrite the $_POST data ID to allow updating the original post.
398 */
399 $_POST['ID'] = $original_post->ID;
400
401 // Republish the original post.
402 $rewritten_post_id = \wp_update_post( $post_to_be_rewritten );
403
404 if ( $rewritten_post_id === 0 ) {
405 \wp_die( \esc_html__( 'An error occurred while republishing the post.', 'duplicate-post' ) );
406 }
407 }
408
409 /**
410 * Republishes the post taxonomies overwriting the ones of the original post.
411 *
412 * @param WP_Post $post The copy's post object.
413 *
414 * @return void
415 */
416 protected function republish_post_taxonomies( $post ) {
417 $original_post_id = Utils::get_original_post_id( $post->ID );
418
419 $copy_taxonomies_options = [
420 'taxonomies_excludelist' => [],
421 'use_filters' => false,
422 'copy_format' => true,
423 ];
424 $this->post_duplicator->copy_post_taxonomies( $original_post_id, $post, $copy_taxonomies_options );
425 }
426
427 /**
428 * Republishes the post meta overwriting the ones of the original post.
429 *
430 * @param WP_Post $post The copy's post object.
431 *
432 * @return void
433 */
434 protected function republish_post_meta( $post ) {
435 $original_post_id = Utils::get_original_post_id( $post->ID );
436
437 $copy_meta_options = [
438 'meta_excludelist' => Utils::get_default_filtered_meta_names(),
439 'use_filters' => false,
440 'copy_thumbnail' => true,
441 'copy_template' => true,
442 ];
443 $this->post_duplicator->copy_post_meta_info( $original_post_id, $post, $copy_meta_options );
444 }
445
446 /**
447 * Redirects the user to the original post.
448 *
449 * @param int $original_post_id The ID of the original post to redirect to.
450 * @param int $copy_id The ID of the copy post.
451 *
452 * @return void
453 */
454 protected function redirect( $original_post_id, $copy_id ) {
455 \wp_safe_redirect(
456 \add_query_arg(
457 [
458 'dprepublished' => 1,
459 'dpcopy' => $copy_id,
460 'dpnonce' => \wp_create_nonce( 'dp-republish' ),
461 ],
462 \admin_url( 'post.php?action=edit&post=' . $original_post_id ),
463 ),
464 );
465 exit();
466 }
467
468 /**
469 * Determines the post status to use when publishing the Rewrite & Republish copy.
470 *
471 * @param WP_Post $post The post object.
472 * @param WP_Post $original_post The original post object.
473 *
474 * @return string The post status to use.
475 */
476 protected function determine_post_status( $post, $original_post ) {
477 if ( $original_post->post_status === 'trash' ) {
478 return 'trash';
479 }
480
481 if ( $post->post_status === 'private' ) {
482 return 'private';
483 }
484
485 return 'publish';
486 }
487
488 /**
489 * Deletes the original post meta that flags it as having a copy when the copy is manually deleted.
490 *
491 * @param int $post_id Post ID of a post that is going to be deleted.
492 *
493 * @return void
494 */
495 public function clean_up_when_copy_manually_deleted( $post_id ) {
496 $post = \get_post( $post_id );
497
498 if ( ! $this->permissions_helper->is_rewrite_and_republish_copy( $post ) ) {
499 return;
500 }
501
502 $original_post_id = Utils::get_original_post_id( $post_id );
503 \delete_post_meta( $original_post_id, '_dp_has_rewrite_republish_copy' );
504 }
505 }
506