← All changes
|
lib/experimental/collaboration/class-gutenberg-rest-autosaves-controller.php
+110
-52
23.7.0
→
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 ) ); |