diff --git a/bin/phpcs-whitelist.js b/bin/phpcs-whitelist.js index a01603b773748..e7934c7a3f009 100644 --- a/bin/phpcs-whitelist.js +++ b/bin/phpcs-whitelist.js @@ -12,6 +12,7 @@ module.exports = [ '_inc/lib/class.jetpack-password-checker.php', '_inc/lib/admin-pages/class-jetpack-about-page.php', 'load-jetpack.php', + 'modules/autosave-revisions.php', 'modules/masterbar/', 'modules/memberships/', 'modules/module-extras.php', diff --git a/modules/autosave-revisions.php b/modules/autosave-revisions.php new file mode 100644 index 0000000000000..d9e97ffaedbb0 --- /dev/null +++ b/modules/autosave-revisions.php @@ -0,0 +1,137 @@ +post_name, "{$revision->post_parent}-revision" ) ) { + $last_revision = $revision; + break; + } + } + + if ( ! isset( $last_revision ) ) { + return true; + } + + if ( ! apply_filters( 'wp_save_post_revision_check_for_changes', $check_for_changes = true, $last_revision, $post ) ) { + return true; + } + + $post_has_changed = false; + + foreach ( array_keys( _wp_post_revision_fields( $post ) ) as $field ) { + if ( normalize_whitespace( $post->$field ) != normalize_whitespace( $last_revision->$field ) ) { + $post_has_changed = true; + break; + } + } + + $post_has_changed = (bool) apply_filters( 'wp_save_post_revision_post_has_changed', $post_has_changed, $last_revision, $post ); + + return $post_has_changed; +} + +/** + * Determine if the two versions (autosave revisions) of a post are different enough to warrant + * saving the old autosave as a separate post revision. + */ +function jetpack_is_big_edit( $post_before, $post_after ) { + // TODO: make the criteria more reasonable, maybe even make a text diff and look at its +-. + $before_len = strlen( $post_before->post_content ); + $after_len = strlen( $post_after->post_content ); + $size_diff = absint( $after_len - $before_len ); + // depends on size: starts at 50 chars (approx one line) for smallest posts, and ends up + // being at least 250 chars for 1000 chars posts and bigger. + $size_threshold = 50 + min( $before_len, 1000 ) / 5; + return $size_diff > $size_threshold; +} + +/** + * When a post is autosaved, we don't create a post revision for that save. On multiple + * consecutive autosaves, we overwrite the old autosave (i.e., the post itself in case of drafts or + * an autosave revision in case of published posts) and its content is lost forever. + * + * This function will compare the old and new autosave, determine if they are significantly + * from each other and if they are, saves the old autosave as a separate post revision. + * This prevents losing valuable unsaved content in case an autosave goes awry, e.g., it empties + * the post content due to an editor bug or unwanted edit. + */ +function jetpack_create_autosave_revision( $post_ID, $post_after, $post_before ) { + // we are only interested in post changes done during autosave + if ( ! defined( 'DOING_AUTOSAVE' ) || ! DOING_AUTOSAVE ) { + return; + } + + // get the actual post whose revision is to be saved: we might need to reach out for the parent + // post if the update is for autosave revision. It's simply the $post_before for draft autosave. + $revision_post = $post_before; + + if ( $post_before->post_type === 'revision' ) { + if ( strpos( $post_before->post_name, "{$post_before->post_parent}-autosave" ) === false ) { + // update of a non-autosave revision: we're not interested in this kind of update + return; + } + + // it's an update of autosave revision, retrieve the parent post + $revision_post = get_post( $post_before->post_parent ); + } + + // bail out if the post type doesn't support revisions + if ( ! wp_revisions_enabled( $revision_post ) ) { + return; + } + + // the autosave revision can either have fresh content, if it's newer than the saved post, or + // be stale: we don't delete the autosave revision when saving the post. We'll reuse it later + // on the next autosave instead of creating a new one. + // If the autosave is indeed fresh, update the parent post with its content and timestamp before + // saving it as revision. + if ( $post_before->post_modified > $revision_post->post_modified) { + foreach ( array_keys( _wp_post_revision_fields( $revision_post ) ) as $field ) { + $revision_post->$field = $post_before->$field; + } + $revision_post->post_modified = $post_before->post_modified; + $revision_post->post_modified_gmt = $post_before->post_modified_gmt; + } + + // don't save a revision if it would be identical to the last saved revision + if ( ! jetpack_post_has_changed_since_last_revision( $revision_post->ID, $revision_post ) ) { + return; + } + + // we'll save a post revision only if the difference between the old and new autosave is big. + // then the old autosave is worth preserving: it would be overwritten and lost otherwise. + if ( ! jetpack_is_big_edit( $revision_post, $post_after ) ) { + return; + } + + _wp_put_post_revision( $revision_post ); + + // record stats about count and size of created autosave revisions + $revision_content_length = strlen( $revision_post->post_content ); + do_action( 'jetpack_stats_statsd', 'autosave_revision', "1|c" ); + do_action( 'jetpack_stats_statsd', 'autosave_revision', "{$revision_content_length}|g" ); +} + +add_action( 'post_updated', 'jetpack_create_autosave_revision', 10, 3 ); diff --git a/modules/module-extras.php b/modules/module-extras.php index 989512dcbf516..073d00b87a4e9 100644 --- a/modules/module-extras.php +++ b/modules/module-extras.php @@ -13,6 +13,7 @@ * - When connected to WordPress.com. */ $tools = array( + 'autosave-revisions.php', // Always loaded, but only registered if theme supports it. 'custom-post-types/comics.php', 'custom-post-types/testimonial.php', diff --git a/to-test.md b/to-test.md index eff187b8f4a64..dc2f1736ec956 100644 --- a/to-test.md +++ b/to-test.md @@ -1,3 +1,15 @@ +## 7.7 + +### Autosave Revisions + +This feature introduces more reliable autosaves that save a permanent revision whenever a large (measured by post content length) change is autosave that would overwrite the previous autosave. + +**Testing instructions:** +1. edit a draft or published post in various editors (Classic or Block) +2. add one character and wait for autosave. Check that permanent revision was not created. (best done with revisions page opened in a second browser tab) You see a revision for the post content before you started editing, and an autosave revision with the one new char. +3. add 250+ characters and wait for autosave. Check that permanent revision was created from the previous autosave (+1 char edit). +4. start editing again from a fully saved state. Add 250+ characters and wait for autosave. Check that permanent revision was not created: the content before the autosave already has a revision and the one created during autosave would be an identical one. + ## 7.5 ### Dashboard