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
110 changes: 106 additions & 4 deletions js/src/duplicate-post-edit-script.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/* global duplicatePost, duplicatePostNotices */

import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { registerPlugin } from "@wordpress/plugins";
import { PluginDocumentSettingPanel, PluginPostStatusInfo } from "@wordpress/editor";
import { Fragment } from "@wordpress/element";
import { Button, ExternalLink, Modal } from '@wordpress/components';
import { __ } from "@wordpress/i18n";
import { select, subscribe, dispatch } from "@wordpress/data";
import { select, subscribe, dispatch, useSelect } from "@wordpress/data";
import apiFetch from "@wordpress/api-fetch";
import { redirectOnSaveCompletion } from "./duplicate-post-functions";

Expand Down Expand Up @@ -123,13 +123,62 @@ function DuplicatePostPanel() {
*
* @returns {JSX.Element|null} The rendered component or null.
*/
/**
* Interval in milliseconds for polling the R&R copy meta.
*
* The R&R copy is created via a server-side admin action, so the meta change
* does not enter the RTC CRDT document. Periodic cache invalidation ensures
* the editor refetches the entity record and picks up the change.
*
* @type {number}
*/
const RR_COPY_POLL_INTERVAL = 15000;

function DuplicatePostRender() {
// Don't try to render anything if there is no store.
if ( ! select( 'core/editor' ) || ! ( wp.editor && wp.editor.PluginPostStatusInfo ) ) {
return null;
}

const currentPostStatus = select( 'core/editor' ).getEditedPostAttribute( 'status' );
const { currentPostStatus, hasRewriteAndRepublishCopy, postType, postId } = useSelect( ( sel ) => {
const editor = sel( 'core/editor' );
const type = editor.getCurrentPostType();
const id = editor.getCurrentPostId();
const record = sel( 'core' ).getEntityRecord( 'postType', type, id );

return {
currentPostStatus: editor.getEditedPostAttribute( 'status' ),
hasRewriteAndRepublishCopy: !! record?.meta?._dp_has_rewrite_republish_copy,
postType: type,
postId: id,
};
}, [] );

const prevHasRRCopy = useRef( hasRewriteAndRepublishCopy );

// Periodically invalidate the entity record cache to detect server-side
// meta changes (e.g. when another user creates an R&R copy via admin action).
useEffect( () => {
if ( hasRewriteAndRepublishCopy || ! postType || ! postId ) {
return;
}
const interval = setInterval( () => {
dispatch( 'core' ).invalidateResolution( 'getEntityRecord', [ 'postType', postType, postId ] );
}, RR_COPY_POLL_INTERVAL );
return () => clearInterval( interval );
}, [ hasRewriteAndRepublishCopy, postType, postId ] );

// Notify the user when another collaborator creates an R&R copy.
useEffect( () => {
if ( hasRewriteAndRepublishCopy && ! prevHasRRCopy.current ) {
dispatch( 'core/notices' ).createNotice(
'info',
__( 'Another user has started a Rewrite & Republish for this post.', 'duplicate-post' ),
{ isDismissible: true, type: 'snackbar' },
);
}
prevHasRRCopy.current = hasRewriteAndRepublishCopy;
}, [ hasRewriteAndRepublishCopy ] );

return (
<Fragment>
Expand All @@ -146,7 +195,7 @@ function DuplicatePostRender() {
</Button>
</PluginPostStatusInfo>
}
{ ( currentPostStatus === 'publish' && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
{ ( currentPostStatus === 'publish' && ! hasRewriteAndRepublishCopy && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
<PluginPostStatusInfo>
<Button
variant="secondary"
Expand Down Expand Up @@ -200,6 +249,59 @@ class DuplicatePost {
wasSavingMetaboxes = completed.isSavingMetaBoxes;
wasAutoSavingPost = completed.isAutosavingPost;
} );

// When another collaborator republishes, the copy is deleted server-side.
// The RTC status change may not propagate (User A navigates away too quickly),
// so periodically check if the copy still exists via the REST API.
this.detectCopyDeletion();
}

/**
* Periodically checks if the R&R copy still exists.
*
* When another collaborator republishes, the copy is deleted after cleanup.
* A 404 response means the copy is gone and we should redirect to the original.
*
* @returns {void}
*/
detectCopyDeletion() {
const originalEditUrl = duplicatePost.originalItem?.editUrl;
if ( ! originalEditUrl || ! duplicatePost.restBase ) {
return;
}

const path = `/wp/v2/${ duplicatePost.restBase }/${ duplicatePost.postId }?_fields=id,status&context=edit`;

const redirectToOriginal = () => {
clearInterval( interval );
dispatch( 'core/notices' ).createNotice(
'info',
__( 'Another user has republished this post. Redirecting to the original…', 'duplicate-post' ),
{ isDismissible: false, type: 'snackbar' },
);
const separator = originalEditUrl.includes( '?' ) ? '&' : '?';
setTimeout( () => window.location.assign( originalEditUrl + separator + 'dpcollabredirected=1' ), 3000 );
};

const checkCopy = async () => {
try {
const response = await apiFetch( { path } );

// The copy was republished but not yet cleaned up.
if ( response.status === 'dp-rewrite-republish' || response.status === 'trash' ) {
redirectToOriginal();
}
} catch ( error ) {
// Only redirect on confirmed deletion/permission errors, not transient network failures.
const status = error?.data?.status ?? error?.status;
if ( status === 404 || status === 410 || status === 403 ) {
redirectToOriginal();
}
}
};

const interval = setInterval( checkCopy, 2000 );
checkCopy();
}

/**
Expand Down
29 changes: 22 additions & 7 deletions src/post-duplicator.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ public function set_modified( $data ) {
* @return int|WP_Error The copy ID, or a WP_Error object on failure.
*/
public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
// Claim the slot on the original before creating the copy to prevent
// a race condition where two concurrent requests both pass the
// permission check before either has set the meta.
$claimed = \add_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', 'pending', true );
if ( ! $claimed ) {
return new WP_Error(
'duplicate_post_already_has_copy',
\__( 'A Rewrite & Republish copy already exists for this post.', 'duplicate-post' ),
);
}

$options = [
'copy_title' => true,
'copy_date' => true,
Expand All @@ -164,15 +175,19 @@ public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {

$new_post_id = $this->create_duplicate( $post, $options );

if ( ! \is_wp_error( $new_post_id ) ) {
$this->copy_post_taxonomies( $new_post_id, $post, $options );
$this->copy_post_meta_info( $new_post_id, $post, $options );

\update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
\update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
\update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );
if ( \is_wp_error( $new_post_id ) ) {
// Roll back the claim if copy creation failed.
\delete_post_meta( $post->ID, '_dp_has_rewrite_republish_copy' );
return $new_post_id;
}

$this->copy_post_taxonomies( $new_post_id, $post, $options );
$this->copy_post_meta_info( $new_post_id, $post, $options );

\update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
\update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
\update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );

return $new_post_id;
}

Expand Down
37 changes: 37 additions & 0 deletions src/ui/block-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,43 @@ public function __construct( Link_Builder $link_builder, Permissions_Helper $per
* @return void
*/
public function register_hooks() {
\add_action( 'init', [ $this, 'register_post_meta' ] );
\add_action( 'elementor/editor/after_enqueue_styles', [ $this, 'hide_elementor_post_status' ] );
\add_action( 'elementor/editor/before_enqueue_scripts', [ $this, 'enqueue_elementor_script' ], 9 );
\add_action( 'admin_enqueue_scripts', [ $this, 'should_previously_used_keyword_assessment_run' ], 9 );
\add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_block_editor_scripts' ] );
\add_filter( 'wpseo_link_suggestions_indexables', [ $this, 'remove_original_from_wpseo_link_suggestions' ], 10, 3 );
}

/**
* Registers the _dp_has_rewrite_republish_copy post meta for the REST API.
*
* This allows the block editor to reactively check whether a post already
* has an R&R copy, enabling dynamic button state updates when collaborators
* create a copy via Real-Time Collaboration.
*
* @return void
*/
public function register_post_meta() {
$enabled_post_types = $this->permissions_helper->get_enabled_post_types();

foreach ( $enabled_post_types as $post_type ) {
\register_post_meta(
$post_type,
'_dp_has_rewrite_republish_copy',
[
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function ( $allowed, $meta_key, $post_id ) {
return \current_user_can( 'edit_post', $post_id );
},
],
);
}
}

/**
* Enqueues the necessary Elementor script for the current post.
*
Expand Down Expand Up @@ -241,8 +271,15 @@ protected function generate_js_object( WP_Post $post ) {
];
}

$post_type_object = \get_post_type_object( $post->post_type );
$rest_base = '';
if ( $post_type_object ) {
$rest_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name;
}

return [
'postId' => $post->ID,
'restBase' => $rest_base,
'newDraftLink' => $this->get_new_draft_permalink(),
'rewriteAndRepublishLink' => $this->get_rewrite_republish_permalink(),
'showLinks' => Utils::get_option( 'duplicate_post_show_link' ),
Expand Down
33 changes: 33 additions & 0 deletions src/watchers/republished-post-watcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public function add_removable_query_args( $removable_query_args ) {
$removable_query_args[] = 'dprepublished';
$removable_query_args[] = 'dpcopy';
$removable_query_args[] = 'dpnonce';
$removable_query_args[] = 'dpcollabredirected';
}
return $removable_query_args;
}
Expand All @@ -68,6 +69,18 @@ public function get_notice_text() {
);
}

/**
* Generates the translated text for the collaborator redirect notice.
*
* @return string The translated text for the collaborator redirect notice.
*/
public function get_collab_redirect_notice_text() {
return \__(
'Another user has republished the Rewrite & Republish copy you were editing. You are now viewing the original post.',
'duplicate-post',
);
}

/**
* Shows a notice on the Classic editor.
*
Expand All @@ -83,6 +96,12 @@ public function add_admin_notice() {
. \esc_html( $this->get_notice_text() )
. '</p></div>';
}

if ( ! empty( $_REQUEST['dpcollabredirected'] ) ) {
echo '<div id="message" class="notice notice-info is-dismissible"><p>'
. \esc_html( $this->get_collab_redirect_notice_text() )
. '</p></div>';
}
}

/**
Expand All @@ -104,5 +123,19 @@ public function add_block_editor_notice() {
'before',
);
}

if ( ! empty( $_REQUEST['dpcollabredirected'] ) ) {
$notice = [
'text' => $this->get_collab_redirect_notice_text(),
'status' => 'info',
'isDismissible' => true,
];

\wp_add_inline_script(
'duplicate_post_edit_script',
'duplicatePostNotices.collab_redirect_notice = ' . \wp_json_encode( $notice ) . ';',
'before',
);
}
}
}
Loading
Loading