PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
← All changes | lib/experimental/collaboration/class-gutenberg-rest-autosaves-controller.php +110 -52 23.7.2 → trunk View file →
@@ -14,8 +14,36 @@
14 14 */
15 15 class Gutenberg_REST_Autosaves_Controller extends WP_REST_Autosaves_Controller {
16 16
17 17 /**
18 + * Meta key holding the CRDT snapshot describing an autosave's content.
19 + *
20 + * @var string
21 + */
22 + public const CRDT_SNAPSHOT_META_KEY = '_crdt_autosave_snapshot';
23 +
24 + /**
25 + * Request parameter holding the CRDT snapshot describing an autosave's
26 + * content.
27 + *
28 + * This string must match CRDT_AUTOSAVE_SNAPSHOT_KEY in @wordpress/core-data.
29 + *
30 + * @var string
31 + */
32 + public const CRDT_SNAPSHOT_PARAM = 'crdt_snapshot';
33 +
34 + /**
35 + * Maximum accepted length of a stored CRDT snapshot, in bytes.
36 + *
37 + * A snapshot is a state vector plus a delete set, so it grows with the
38 + * much slower than content. Unlikely to exceed 1 MB, but use as a safety
39 + * backstop.
40 + *
41 + * @var int
42 + */
43 + private const MAX_CRDT_SNAPSHOT_LENGTH = MB_IN_BYTES;
44 +
45 + /**
18 46 * Parent post controller.
19 47 *
20 48 * @since 5.0.0
21 49 * @var WP_REST_Controller
@@ -52,11 +80,12 @@
52 80 * @param WP_REST_Request $request Full details about the request.
53 81 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
54 82 */
55 83 public function create_item( $request ) {
56 -
57 - if ( ! defined( 'WP_RUN_CORE_TESTS' ) && ! defined( 'DOING_AUTOSAVE' ) ) {
58 - define( 'DOING_AUTOSAVE', true );
84 + // Gutenberg selects this controller only when RTC is enabled. Preserve
85 + // Core behavior if it is registered explicitly or instantiated directly.
86 + if ( ! wp_is_collaboration_enabled() ) {
87 + return parent::create_item( $request );
59 88 }
60 89
61 90 $post = $this->get_parent( $request['id'] );
62 91
@@ -63,64 +92,57 @@
63 92 if ( is_wp_error( $post ) ) {
64 93 return $post;
65 94 }
66 95
67 - $prepared_post = $this->gutenberg_parent_controller->prepare_item_for_database( $request );
68 - $prepared_post->ID = $post->ID;
69 - $user_id = get_current_user_id();
96 + // Autosave creation may fire this callback for revisioned post meta.
97 + if ( ! function_exists( 'wp_autosave_post_revisioned_meta_fields' ) ) {
98 + require_once ABSPATH . 'wp-admin/includes/post.php';
99 + }
70 100
71 - // We need to check post lock to ensure the original author didn't leave their browser tab open.
72 - if ( ! function_exists( 'wp_check_post_lock' ) ) {
73 - require_once ABSPATH . 'wp-admin/includes/post.php';
101 + // Post-type collaboration support is determined after the autosaves
102 + // controller is selected, so disabled post types must delegate to Core.
103 + if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) {
104 + return parent::create_item( $request );
74 105 }
75 106
76 - $post_lock_is_active = wp_check_post_lock( $post->ID );
77 - $is_auto_draft = 'auto-draft' === $post->post_status;
78 - $is_draft = 'draft' === $post->post_status || $is_auto_draft;
79 - $is_collaboration_enabled = wp_is_collaboration_enabled();
107 + if ( ! defined( 'WP_RUN_CORE_TESTS' ) && ! defined( 'DOING_AUTOSAVE' ) ) {
108 + define( 'DOING_AUTOSAVE', true );
109 + }
80 110
111 + $prepared_post = $this->gutenberg_parent_controller->prepare_item_for_database( $request );
112 + $prepared_post->ID = $post->ID;
113 + $post_data = (array) $prepared_post;
114 + $meta = (array) $request->get_param( 'meta' );
115 +
81 116 /*
82 - * When a post is still in draft form, updates from the author can directly update the post.
83 - * Other autosaves must be stored as per-user autosave revisions.
117 + * Regular draft autosaves must not update the parent post directly under
118 + * RTC. All peers share a persisted editing state in the CRDT, so their
119 + * autosaved changes must be stored in revisions. Applying those edits to
120 + * the parent post would make them appear to be external changes when the
121 + * editor next reloads, causing the same changes to be reapplied to the
122 + * CRDT and duplicated.
84 123 *
85 - * When RTC is active, however, regular draft autosaves must not update the parent post directly.
86 - * Since all peers are sharing a persisted editing state (a shared CRDT), it’s important that
87 - * they all store updates in a revision. If edits were applied to the post, then upon the next
88 - * editor reload, it would appear as though the post had been updated externally, and those same
89 - * changes would be re-applied to the CRDT, duplicating the edits.
90 - *
91 - * The one caveat for RTC is that the first peer to store an edit must promote an auto-draft
92 - * into a real draft post. If this doesn’t happen then the peers may continue to make edits
93 - * but the draft will be lost, as auto-drafts are not listed in post views.
124 + * The first peer to store an edit must still promote an auto-draft into
125 + * a real draft. Otherwise, peers could continue editing while the post
126 + * remains an unlisted auto-draft and may be lost.
94 127 */
95 - $can_update_author_draft_post = (
96 - $is_draft &&
97 - (int) $post->post_author === $user_id &&
98 - ! $is_collaboration_enabled
99 - );
100 - $can_promote_auto_draft_post = (
101 - $is_auto_draft &&
102 - $is_collaboration_enabled &&
128 + $should_promote_auto_draft = (
129 + 'auto-draft' === $post->post_status &&
103 130 current_user_can( 'edit_post', $post->ID )
104 131 );
105 132
106 - $should_update_parent_draft_post = (
107 - $can_promote_auto_draft_post ||
108 - ( ! $post_lock_is_active && $can_update_author_draft_post )
109 - );
110 -
111 - if ( $should_update_parent_draft_post ) {
112 - $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true );
113 - } elseif ( $this->is_redundant_autosave( $post, (array) $prepared_post, (array) $request->get_param( 'meta' ) ) ) {
133 + if ( $should_promote_auto_draft ) {
134 + $autosave_id = wp_update_post( wp_slash( $post_data ), true );
135 + } elseif ( $this->is_redundant_autosave( $post, $post_data, $meta ) ) {
114 136 /*
115 - * Nothing changed and there is no existing autosave to update, so
116 - * storing a revision would only create one that is identical to the
117 - * post. Avoid a no-op revision because WordPress decides whether to warn
118 - * about "a more recent autosave" by comparing timestamps.
137 + * Nothing changed from the latest shared state, so storing a
138 + * revision would only create an identical one. Avoid a no-op
139 + * revision because WordPress decides whether to warn about "a more
140 + * recent autosave" by comparing timestamps.
119 141 */
120 142 $autosave_id = $post->ID;
121 143 } else {
122 - $autosave_id = $this->create_post_autosave( (array) $prepared_post, (array) $request->get_param( 'meta' ) );
144 + $autosave_id = $this->create_post_autosave( $post_data, $meta );
123 145 }
124 146
125 147 if ( is_wp_error( $autosave_id ) ) {
126 148 return $autosave_id;
@@ -125,8 +147,13 @@
125 147 if ( is_wp_error( $autosave_id ) ) {
126 148 return $autosave_id;
127 149 }
128 150
151 + // Only save a CRDT snapshot with an autosave (not with parent post)
152 + if ( $autosave_id !== $post->ID ) {
153 + $this->store_crdt_snapshot( $autosave_id, $request );
154 + }
155 +
129 156 $autosave = get_post( $autosave_id );
130 157 $request->set_param( 'context', 'edit' );
131 158
132 159 $response = $this->prepare_item_for_response( $autosave, $request );
@@ -135,8 +162,46 @@
135 162 return $response;
136 163 }
137 164
138 165 /**
166 + * Stores the CRDT snapshot describing the content an autosave captured.
167 + *
168 + * The snapshot allows an editor session verify that a shared document
169 + * already contains everything the autosave holds, so the "more recent
170 + * autosave" notice can be suppressed as redundant.
171 + *
172 + * Anything invalid is dropped rather than erroring, and any previously
173 + * stored snapshot is cleared, because a missing snapshot only means the
174 + * editor falls back to showing the "newer autosave" notice.
175 + *
176 + * @param int $autosave_id Autosave revision ID.
177 + * @param WP_REST_Request $request Full details about the request.
178 + * @return void
179 + */
180 + private function store_crdt_snapshot( $autosave_id, $request ) {
181 + $snapshot = $request->get_param( self::CRDT_SNAPSHOT_PARAM );
182 +
183 + $is_valid_snapshot = (
184 + is_string( $snapshot ) &&
185 + '' !== $snapshot &&
186 + strlen( $snapshot ) <= self::MAX_CRDT_SNAPSHOT_LENGTH
187 + );
188 +
189 + if ( ! $is_valid_snapshot ) {
190 + /*
191 + * The autosave revision is reused across autosaves, so a snapshot
192 + * stored by an earlier request would otherwise remain attached to
193 + * this request's newer content and could wrongly vouch for it.
194 + * Clear it so the editor falls back to showing the notice.
195 + */
196 + delete_metadata( 'post', $autosave_id, self::CRDT_SNAPSHOT_META_KEY );
197 + return;
198 + }
199 +
200 + update_metadata( 'post', $autosave_id, self::CRDT_SNAPSHOT_META_KEY, wp_slash( $snapshot ) );
201 + }
202 +
203 + /**
139 204 * Determines whether an incoming autosave would be a redundant no-op.
140 205 *
141 206 * Core's WP_REST_Autosaves_Controller::create_post_autosave() avoids a
142 207 * redundant write by comparing the incoming autosave against the parent post.
@@ -199,15 +264,8 @@
199 264 * @param WP_Post $post The saved parent post.
200 265 * @return WP_Post The post or revision to compare against.
201 266 */
202 267 private function get_autosave_comparison_baseline( $post ) {
203 - // Outside RTC the parent post is kept up to date (author draft autosaves
204 - // update it directly), so core's parent comparison is already correct.
205 - // Keep the default behavior for non-RTC.
206 - if ( ! wp_is_collaboration_enabled() ) {
207 - return $post;
208 - }
209 -
210 268 // wp_get_post_revisions() returns revisions newest-first by default, and
211 269 // includes per-user autosaves (they are revisions), so the first entry is
212 270 // the most recent shared content.
213 271 $revisions = wp_get_post_revisions( $post->ID, array( 'posts_per_page' => 1 ) );