diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index f0cec04f191f8..97135e03da2da 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -229,41 +229,45 @@ public function create_item( $request ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } - $post_lock = wp_check_post_lock( $post->ID ); - $is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status; + $post_lock_is_active = wp_check_post_lock( $post->ID ); + $is_auto_draft = 'auto-draft' === $post->post_status; + $is_draft = 'draft' === $post->post_status || $is_auto_draft; + $is_collaboration_enabled = wp_is_collaboration_enabled(); /* - * In the context of real-time collaboration, all peers are effectively - * authors and we don't want to vary behavior based on whether they are the - * original author. Always target an autosave revision. - * - * This avoids the following issue when real-time collaboration is enabled: - * - * - Autosaves from the original author (if they have the post lock) will - * target the saved post. + * When a post is still in draft form, updates from the author can directly update the post. + * Other autosaves must be stored as per-user autosave revisions. * - * - Autosaves from other users are applied to a post revision. + * When RTC is active, however, regular draft autosaves must not update the parent post directly. + * Since all peers are sharing a persisted editing state (a shared CRDT), it’s important that + * they all store updates in a revision. If edits were applied to the post, then upon the next + * editor reload, it would appear as though the post had been updated externally, and those same + * changes would be re-applied to the CRDT, duplicating the edits. * - * - If any user reloads a post, they load changes from the author's autosave. - * - * - The saved post has now diverged from the persisted CRDT document. The - * content (and/or title or excerpt) are now "ahead" of the persisted CRDT - * document. - * - * - When the persisted CRDT document is loaded, a diff is computed against - * the saved post. This diff is then applied to the in-memory CRDT - * document, which can lead to duplicate inserts or deletions. + * The one caveat for RTC is that the first peer to store an edit must promote an auto-draft + * into a real draft post. If this doesn’t happen then the peers may continue to make edits + * but the draft will be lost, as auto-drafts are not listed in post views. */ - $is_collaboration_enabled = wp_is_collaboration_enabled(); + $can_update_author_draft_post = ( + $is_draft && + (int) $post->post_author === $user_id && + ! $is_collaboration_enabled + ); + + $can_promote_auto_draft_post = ( + $is_auto_draft && + $is_collaboration_enabled && + current_user_can( 'edit_post', $post->ID ) + ); + + $should_update_parent_draft_post = ( + $can_promote_auto_draft_post || + ( ! $post_lock_is_active && $can_update_author_draft_post ) + ); - if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) { - /* - * Draft posts for the same author: autosaving updates the post and does not create a revision. - * Convert the post object to an array and add slashes, wp_update_post() expects escaped array. - */ + if ( $should_update_parent_draft_post ) { $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true ); } else { - // Non-draft posts: create or update the post autosave. Pass the meta data. $autosave_id = $this->create_post_autosave( (array) $prepared_post, (array) $request->get_param( 'meta' ) ); } diff --git a/tests/phpunit/tests/collaboration/restAutosavesController.php b/tests/phpunit/tests/collaboration/restAutosavesController.php new file mode 100644 index 0000000000000..05b98da610445 --- /dev/null +++ b/tests/phpunit/tests/collaboration/restAutosavesController.php @@ -0,0 +1,168 @@ +user->create( array( 'role' => 'author' ) ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$author_id ); + self::delete_user( self::$editor_id ); + delete_option( 'wp_collaboration_enabled' ); + } + + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$author_id ); + } + + /** + * Creates an empty auto-draft post. + * + * @return int Post ID. + */ + private function create_auto_draft(): int { + return self::factory()->post->create( + array( + 'post_author' => self::$author_id, + 'post_content' => '', + 'post_status' => 'auto-draft', + 'post_title' => 'Auto Draft', + 'post_type' => 'post', + ) + ); + } + + /** + * Creates a draft post. + * + * @param string $title Post title. + * @param string $content Post content. + * @return int Post ID. + */ + private function create_draft( string $title, string $content ): int { + return self::factory()->post->create( + array( + 'post_author' => self::$author_id, + 'post_content' => $content, + 'post_status' => 'draft', + 'post_title' => $title, + 'post_type' => 'post', + ) + ); + } + + /** + * Dispatches an autosave request for a post. + * + * @param int $post_id Post ID. + * @param string $title Autosaved post title. + * @param string $content Autosaved post content. + * @return WP_REST_Response Autosave response. + */ + private function dispatch_autosave( int $post_id, string $title, string $content ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', "/wp/v2/posts/{$post_id}/autosaves" ); + $request->set_param( 'title', $title ); + $request->set_param( 'content', $content ); + $request->set_param( 'status', 'draft' ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * @ticket 65138 + */ + public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_disabled() { + update_option( 'wp_collaboration_enabled', 0 ); + + $post_id = $this->create_auto_draft(); + $title = 'No RTC autosaved title'; + $content = '

No RTC autosaved content

'; + + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $title, $post->post_title ); + $this->assertSame( $content, $post->post_content ); + } + + /** + * @ticket 65138 + */ + public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() { + update_option( 'wp_collaboration_enabled', 1 ); + + $post_id = $this->create_auto_draft(); + $title = 'RTC autosaved title'; + $content = '

RTC autosaved content

'; + + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $title, $post->post_title ); + $this->assertSame( $content, $post->post_content ); + } + + /** + * @ticket 65138 + */ + public function test_collaborator_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() { + update_option( 'wp_collaboration_enabled', 1 ); + + $post_id = $this->create_auto_draft(); + $title = 'RTC collaborator autosaved title'; + $content = '

RTC collaborator autosaved content

'; + + wp_set_current_user( self::$editor_id ); + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $title, $post->post_title ); + $this->assertSame( $content, $post->post_content ); + } + + /** + * @ticket 65138 + */ + public function test_draft_autosave_creates_revision_when_collaboration_is_enabled() { + update_option( 'wp_collaboration_enabled', 1 ); + + $original_title = 'Original RTC draft title'; + $original_content = '

Original RTC draft content

'; + $post_id = $this->create_draft( $original_title, $original_content ); + $title = 'RTC draft autosaved title'; + $content = '

RTC draft autosaved content

'; + + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $original_title, $post->post_title ); + $this->assertSame( $original_content, $post->post_content ); + + $autosave = wp_get_post_autosave( $post_id, self::$author_id ); + $this->assertInstanceOf( WP_Post::class, $autosave ); + $this->assertSame( $title, $autosave->post_title ); + $this->assertSame( $content, $autosave->post_content ); + } +}