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 |