Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 3
CRAP
n/a
0 / 0
wpcom_post_has_changed_since_last_revision
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
wpcom_is_big_edit
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
wpcom_create_autosave_revision
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2/**
3 * Auto-save revisions file.
4 *
5 * @package wpcomsh
6 */
7
8/**
9 * Compare the proposed update with the last stored revision verifying that
10 * they are different, unless a plugin tells us to always save regardless.
11 *
12 * TODO: this function is extracted from Core's wp_save_post_revision function. Submit it as
13 *       a Core patch and then delete if from here after it's merged.
14 *
15 * @param int     $post_id The ID of the post to save as a revision.
16 * @param WP_Post $post    The proposed post update.
17 * @return bool Whether the proposed update is different from the last saved revision.
18 */
19function wpcom_post_has_changed_since_last_revision( $post_id, $post ) {
20    $revisions = wp_get_post_revisions( $post_id );
21    // If no previous revisions, save one
22    if ( ! $revisions ) {
23        return true;
24    }
25
26    // grab the last revision, but not an autosave
27    foreach ( $revisions as $revision ) {
28        if ( false !== strpos( $revision->post_name, "{$revision->post_parent}-revision" ) ) {
29            $last_revision = $revision;
30            break;
31        }
32    }
33
34    if ( ! isset( $last_revision ) ) {
35        return true;
36    }
37
38    $check_for_changes = true;
39
40    /**
41     * Filters whether the post has changed since the last revision.
42     *
43     * By default a revision is saved only if one of the revisioned fields has changed.
44     * This filter can override that so a revision is saved even if nothing has changed.
45     *
46     * @since 3.6.0
47     *
48     * @param bool    $check_for_changes Whether to check for changes before saving a new revision.
49     *                                   Default true.
50     * @param WP_Post $last_revision     The last revision post object.
51     * @param WP_Post $post              The post object.
52     */
53    if ( ! apply_filters( 'wp_save_post_revision_check_for_changes', $check_for_changes, $last_revision, $post ) ) {
54        return true;
55    }
56
57    $post_has_changed = false;
58
59    foreach ( array_keys( _wp_post_revision_fields( $post ) ) as $field ) {
60        if ( normalize_whitespace( $post->$field ) != normalize_whitespace( $last_revision->$field ) ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
61            $post_has_changed = true;
62            break;
63        }
64    }
65
66    /**
67     * Filters whether a post has changed.
68     *
69     * By default a revision is saved only if one of the revisioned fields has changed.
70     * This filter allows for additional checks to determine if there were changes.
71     *
72     * @since 4.1.0
73     *
74     * @param bool    $post_has_changed Whether the post has changed.
75     * @param WP_Post $last_revision    The last revision post object.
76     * @param WP_Post $post             The post object.
77     */
78    $post_has_changed = (bool) apply_filters( 'wp_save_post_revision_post_has_changed', $post_has_changed, $last_revision, $post );
79
80    return $post_has_changed;
81}
82
83/**
84 * Determine if the two versions (autosave revisions) of a post are different enough to warrant
85 * saving the old autosave as a separate post revision.
86 *
87 * @param WP_Post $post_before Post from before this edit.
88 * @param WP_Post $post_after  Post with the current edit.
89 *
90 * @return bool
91 */
92function wpcom_is_big_edit( $post_before, $post_after ) {
93    // TODO: make the criteria more reasonable, maybe even make a text diff and look at its +-.
94    $before_len = strlen( $post_before->post_content );
95    $after_len  = strlen( $post_after->post_content );
96    $size_diff  = absint( $after_len - $before_len );
97
98    /*
99     * Depends on size: Starts at 50 chars (approx one line) for smallest posts, and ends up
100     * being at least 250 chars for 1000 chars posts and bigger.
101     */
102    $size_threshold = 50 + min( $before_len, 1000 ) / 5;
103
104    return $size_diff > $size_threshold;
105}
106
107/**
108 * When a post is autosaved, we don't create a post revision for that save. On multiple
109 * consecutive autosaves, we overwrite the old autosave (i.e., the post itself in case of drafts or
110 * an autosave revision in case of published posts) and its content is lost forever.
111 *
112 * This function will compare the old and new autosave, determine if they are significantly
113 * from each other and if they are, saves the old autosave as a separate post revision.
114 * This prevents losing valuable unsaved content in case an autosave goes awry, e.g., it empties
115 * the post content due to an editor bug or unwanted edit.
116 *
117 * @param int     $post_ID     Post ID.
118 * @param WP_Post $post_after  Post with the current edit.
119 * @param WP_Post $post_before Post from before this edit.
120 */
121function wpcom_create_autosave_revision( $post_ID, $post_after, $post_before ) {
122    // We are only interested in post changes done during autosave.
123    if ( ! defined( 'DOING_AUTOSAVE' ) || ! DOING_AUTOSAVE ) {
124        return;
125    }
126
127    /*
128     * Get the actual post whose revision is to be saved: we might need to reach out for the parent
129     * post if the update is for autosave revision. It's simply the $post_before for draft autosave.
130     */
131    $revision_post = $post_before;
132
133    if ( $post_before->post_type === 'revision' ) {
134        if ( strpos( $post_before->post_name, "{$post_before->post_parent}-autosave" ) === false ) {
135            // Update of a non-autosave revision: we're not interested in this kind of update.
136            return;
137        }
138
139        // It's an update of autosave revision, retrieve the parent post.
140        $revision_post = get_post( $post_before->post_parent );
141    }
142
143    // Bail out if the post type doesn't support revisions.
144    if ( ! wp_revisions_enabled( $revision_post ) ) {
145        return;
146    }
147
148    /*
149     * The autosave revision can either have fresh content, if it's newer than the saved post, or
150     * be stale: we don't delete the autosave revision when saving the post. We'll reuse it later
151     * on the next autosave instead of creating a new one.
152     * If the autosave is indeed fresh, update the parent post with its content and timestamp before
153     * saving it as revision.
154     */
155    if ( $post_before->post_modified > $revision_post->post_modified ) {
156        foreach ( array_keys( _wp_post_revision_fields( $revision_post ) ) as $field ) {
157            $revision_post->$field = $post_before->$field;
158        }
159        $revision_post->post_modified     = $post_before->post_modified;
160        $revision_post->post_modified_gmt = $post_before->post_modified_gmt;
161    }
162
163    // Don't save a revision if it would be identical to the last saved revision.
164    if ( ! wpcom_post_has_changed_since_last_revision( $revision_post->ID, $revision_post ) ) {
165        return;
166    }
167
168    /*
169     * We'll save a post revision only if the difference between the old and new autosave is big.
170     * then the old autosave is worth preserving: it would be overwritten and lost otherwise.
171     */
172    if ( ! wpcom_is_big_edit( $revision_post, $post_after ) ) {
173        return;
174    }
175
176    _wp_put_post_revision( $revision_post );
177}
178add_action( 'post_updated', 'wpcom_create_autosave_revision', 10, 3 );