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
gutenberg / lib / experimental / collaboration / class-gutenberg-rest-autosaves-controller.php

class-gutenberg-rest-autosaves-controller.php in Gutenberg trunk, at lib/experimental/collaboration/class-gutenberg-rest-autosaves-controller.php

288 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API: Gutenberg_REST_Autosaves_Controller class
4 *
5 * @package gutenberg
6 */
7
8 /**
9 * Controller which provides REST endpoint for autosaves.
10 * This overrides the core WP_REST_Autosaves_Controller to add support for
11 * real-time collaboration fixes on draft posts.
12 *
13 * @see WP_REST_Autosaves_Controller
14 */
15 class Gutenberg_REST_Autosaves_Controller extends WP_REST_Autosaves_Controller {
16
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 /**
46 * Parent post controller.
47 *
48 * @since 5.0.0
49 * @var WP_REST_Controller
50 */
51 private $gutenberg_parent_controller;
52
53 /**
54 * Constructor.
55 *
56 * @since 5.0.0
57 *
58 * @param string $parent_post_type Post type of the parent.
59 */
60 public function __construct( $parent_post_type ) {
61 parent::__construct( $parent_post_type );
62
63 // Create an instance of the parent post type controller that is accessible
64 // by this extended class.
65 $post_type_object = get_post_type_object( $parent_post_type );
66 $parent_controller = $post_type_object->get_rest_controller();
67
68 if ( ! $parent_controller ) {
69 $parent_controller = new WP_REST_Posts_Controller( $parent_post_type );
70 }
71
72 $this->gutenberg_parent_controller = $parent_controller;
73 }
74
75 /**
76 * Creates, updates or deletes an autosave revision.
77 *
78 * @since 5.0.0
79 *
80 * @param WP_REST_Request $request Full details about the request.
81 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
82 */
83 public function create_item( $request ) {
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 );
88 }
89
90 $post = $this->get_parent( $request['id'] );
91
92 if ( is_wp_error( $post ) ) {
93 return $post;
94 }
95
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 }
100
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 );
105 }
106
107 if ( ! defined( 'WP_RUN_CORE_TESTS' ) && ! defined( 'DOING_AUTOSAVE' ) ) {
108 define( 'DOING_AUTOSAVE', true );
109 }
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
116 /*
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.
123 *
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.
127 */
128 $should_promote_auto_draft = (
129 'auto-draft' === $post->post_status &&
130 current_user_can( 'edit_post', $post->ID )
131 );
132
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 ) ) {
136 /*
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.
141 */
142 $autosave_id = $post->ID;
143 } else {
144 $autosave_id = $this->create_post_autosave( $post_data, $meta );
145 }
146
147 if ( is_wp_error( $autosave_id ) ) {
148 return $autosave_id;
149 }
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
156 $autosave = get_post( $autosave_id );
157 $request->set_param( 'context', 'edit' );
158
159 $response = $this->prepare_item_for_response( $autosave, $request );
160 $response = rest_ensure_response( $response );
161
162 return $response;
163 }
164
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 /**
204 * Determines whether an incoming autosave would be a redundant no-op.
205 *
206 * Core's WP_REST_Autosaves_Controller::create_post_autosave() avoids a
207 * redundant write by comparing the incoming autosave against the parent post.
208 * That baseline is wrong under RTC. The parent draft is intentionally never
209 * updated, so peer autosaves can accumulate as revisions while the parent stays
210 * stale. An autosave that matches the latest shared revision still looks different
211 * from the parent, so core stores another revision identical to the previous
212 * one, which renders as a blank diff on the revisions page.
213 *
214 * The correct baseline is the most recent revision when it is newer
215 * than the parent (the RTC case), falling back to the parent when no revision
216 * exists yet. The revisioned post fields (title, content, excerpt) and
217 * revisioned meta (e.g. `footnotes`) are compared, matching the fields core
218 * itself diffs. Non-revisioned meta (e.g. `_crdt_document`) is excluded.
219 *
220 * @since 7.2.0
221 *
222 * @param WP_Post $post The saved parent post.
223 * @param array $post_data Prepared autosave post data.
224 * @param array $meta Meta values submitted with the autosave.
225 * @return bool Whether the autosave can be skipped without losing anything.
226 */
227 private function is_redundant_autosave( $post, $post_data, $meta ) {
228 $baseline = $this->get_autosave_comparison_baseline( $post );
229
230 $new_autosave = _wp_post_revision_data( $post_data, true );
231 $revision_fields = _wp_post_revision_fields( $baseline );
232
233 foreach ( array_intersect( array_keys( $new_autosave ), array_keys( $revision_fields ) ) as $field ) {
234 if ( normalize_whitespace( $new_autosave[ $field ] ) !== normalize_whitespace( $baseline->$field ) ) {
235 return false;
236 }
237 }
238
239 foreach ( wp_post_revision_meta_keys( $post->post_type ) as $meta_key ) {
240 // get_metadata_raw avoids the registered default. Treat an unset value
241 // and an empty string as equivalent so the editor's empty default for
242 // an unset key is not mistaken for a change.
243 $old_meta = get_metadata_raw( 'post', $baseline->ID, $meta_key, true ) ?? '';
244 $new_meta = $meta[ $meta_key ] ?? '';
245
246 if ( $old_meta !== $new_meta ) {
247 return false;
248 }
249 }
250
251 return true;
252 }
253
254 /**
255 * Returns the post to compare an incoming autosave against.
256 *
257 * Under RTC the parent draft is not updated with an autosave, so its latest content
258 * lives in the most recent revision. When that revision is newer than the parent it is
259 * the correct baseline; otherwise (no revisions yet, or the parent was updated
260 * directly) the parent post is used.
261 *
262 * @since 7.2.0
263 *
264 * @param WP_Post $post The saved parent post.
265 * @return WP_Post The post or revision to compare against.
266 */
267 private function get_autosave_comparison_baseline( $post ) {
268 // wp_get_post_revisions() returns revisions newest-first by default, and
269 // includes per-user autosaves (they are revisions), so the first entry is
270 // the most recent shared content.
271 $revisions = wp_get_post_revisions( $post->ID, array( 'posts_per_page' => 1 ) );
272 $latest_revision = empty( $revisions ) ? null : array_shift( $revisions );
273
274 // Note that a draft which has never been updated keeps the floating
275 // post_modified_gmt of '0000-00-00 00:00:00', so any revision compares
276 // as newer than it. That is the desired outcome: under RTC the
277 // revisions hold newer content than the untouched parent draft.
278 if (
279 $latest_revision &&
280 $latest_revision->post_modified_gmt >= $post->post_modified_gmt
281 ) {
282 return $latest_revision;
283 }
284
285 return $post;
286 }
287 }
288