diff --git a/wp-admin/includes/post.php b/wp-admin/includes/post.php index 8d7f76b3fe..dd447d9beb 100644 --- a/wp-admin/includes/post.php +++ b/wp-admin/includes/post.php @@ -1970,11 +1970,12 @@ function wp_create_post_autosave( $post_data ) { * Fires before an autosave is stored. * * @since 4.1.0 + * @since 6.4.0 The `$is_update` parameter was added to indicate if the autosave is being updated or was newly created. * * @param array $new_autosave Post array - the autosave that is about to be saved. + * @param bool $is_update Whether this is an existing autosave. */ - do_action( 'wp_creating_autosave', $new_autosave ); - + do_action( 'wp_creating_autosave', $new_autosave, true ); return wp_update_post( $new_autosave ); } @@ -1982,7 +1983,71 @@ function wp_create_post_autosave( $post_data ) { $post_data = wp_unslash( $post_data ); // Otherwise create the new autosave as a special post revision. - return _wp_put_post_revision( $post_data, true ); + $revision = _wp_put_post_revision( $post_data, true ); + + if ( ! is_wp_error( $revision ) && 0 !== $revision ) { + + /** This action is documented in wp-admin/includes/post.php */ + do_action( 'wp_creating_autosave', get_post( $revision, ARRAY_A ), false ); + } + + return $revision; +} + +/** + * Autosave the revisioned meta fields. + * + * Iterates through the revisioned meta fields and checks each to see if they are set, + * and have a changed value. If so, the meta value is saved and attached to the autosave. + * + * @since 6.4.0 + * + * @param array $new_autosave The new post data being autosaved. + */ +function wp_autosave_post_revisioned_meta_fields( $new_autosave ) { + /* + * The post data arrives as either $_POST['data']['wp_autosave'] or the $_POST + * itself. This sets $posted_data to the correct variable. + * + * Ignoring sanitization to avoid altering meta. Ignoring the nonce check because + * this is hooked on inner core hooks where a valid nonce was already checked. + * + * @phpcs:disable WordPress.Security + */ + $posted_data = isset( $_POST['data']['wp_autosave'] ) ? $_POST['data']['wp_autosave'] : $_POST; + // phpcs:enable + + $post_type = get_post_type( $new_autosave['post_parent'] ); + + /* + * Go thru the revisioned meta keys and save them as part of the autosave, if + * the meta key is part of the posted data, the meta value is not blank and + * the the meta value has changes from the last autosaved value. + */ + foreach ( wp_post_revision_meta_keys( $post_type ) as $meta_key ) { + + if ( + isset( $posted_data[ $meta_key ] ) && + get_post_meta( $new_autosave['ID'], $meta_key, true ) !== wp_unslash( $posted_data[ $meta_key ] ) + ) { + /* + * Use the underlying delete_metadata() and add_metadata() functions + * vs delete_post_meta() and add_post_meta() to make sure we're working + * with the actual revision meta. + */ + delete_metadata( 'post', $new_autosave['ID'], $meta_key ); + + /* + * One last check to ensure meta value not empty(). + */ + if ( ! empty( $posted_data[ $meta_key ] ) ) { + /* + * Add the revisions meta data to the autosave. + */ + add_metadata( 'post', $new_autosave['ID'], $meta_key, $posted_data[ $meta_key ] ); + } + } + } } /** diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index adba75fcd7..090ee61d51 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -411,6 +411,7 @@ add_action( 'plugins_loaded', 'wp_maybe_load_widgets', 0 ); add_action( 'plugins_loaded', 'wp_maybe_load_embeds', 0 ); add_action( 'shutdown', 'wp_ob_end_flush_all', 1 ); // Create a revision whenever a post is updated. +add_action( 'wp_after_insert_post', 'wp_save_post_revision_on_insert', 9, 3 ); add_action( 'post_updated', 'wp_save_post_revision', 10, 1 ); add_action( 'publish_post', '_publish_post_hook', 5, 1 ); add_action( 'transition_post_status', '_transition_post_status', 5, 3 ); @@ -719,6 +720,18 @@ add_action( 'init', 'wp_register_persisted_preferences_meta' ); // CPT wp_block custom postmeta field. add_action( 'init', 'wp_create_initial_post_meta' ); +// Include revisioned meta when considering whether a post revision has changed. +add_filter( 'wp_save_post_revision_post_has_changed', 'wp_check_revisioned_meta_fields_have_changed', 10, 3 ); + +// Save revisioned post meta immediately after a revision is saved +add_action( '_wp_put_post_revision', 'wp_save_revisioned_meta_fields', 10, 2 ); + +// Include revisioned meta when creating or updating an autosave revision. +add_action( 'wp_creating_autosave', 'wp_autosave_post_revisioned_meta_fields' ); + +// When restoring revisions, also restore revisioned meta. +add_action( 'wp_restore_post_revision', 'wp_restore_post_revision_meta', 10, 2 ); + // Font management. add_action( 'wp_head', 'wp_print_font_faces', 50 ); diff --git a/wp-includes/meta.php b/wp-includes/meta.php index 0b9f08544e..96ace4e972 100644 --- a/wp-includes/meta.php +++ b/wp-includes/meta.php @@ -1367,6 +1367,7 @@ function sanitize_meta( $meta_key, $meta_value, $object_type, $object_subtype = * @since 4.9.8 The `$object_subtype` argument was added to the arguments array. * @since 5.3.0 Valid meta types expanded to include "array" and "object". * @since 5.5.0 The `$default` argument was added to the arguments array. + * @since 6.4.0 The `$revisions_enabled` argument was added to the arguments array. * * @param string $object_type Type of object metadata is for. Accepts 'post', 'comment', 'term', 'user', * or any other object type with an associated meta table. @@ -1392,6 +1393,8 @@ function sanitize_meta( $meta_key, $meta_value, $object_type, $object_subtype = * support for custom fields for registered meta to be accessible via REST. * When registering complex meta values this argument may optionally be an * array with 'schema' or 'prepare_callback' keys instead of a boolean. + * @type bool $revisions_enabled Whether to enable revisions support for this meta_key. Can only be used when the + * object type is 'post'. * } * @param string|array $deprecated Deprecated. Use `$args` instead. * @return bool True if the meta key was successfully registered in the global array, false if not. @@ -1414,6 +1417,7 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) { 'sanitize_callback' => null, 'auth_callback' => null, 'show_in_rest' => false, + 'revisions_enabled' => false, ); // There used to be individual args for sanitize and auth callbacks. @@ -1460,6 +1464,17 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) { } $object_subtype = ! empty( $args['object_subtype'] ) ? $args['object_subtype'] : ''; + if ( $args['revisions_enabled'] ) { + if ( 'post' !== $object_type ) { + _doing_it_wrong( __FUNCTION__, __( 'Meta keys cannot enable revisions support unless the object type supports revisions.' ), '6.4.0' ); + + return false; + } elseif ( ! empty( $object_subtype ) && ! post_type_supports( $object_subtype, 'revisions' ) ) { + _doing_it_wrong( __FUNCTION__, __( 'Meta keys cannot enable revisions support unless the object subtype supports revisions.' ), '6.4.0' ); + + return false; + } + } // If `auth_callback` is not provided, fall back to `is_protected_meta()`. if ( empty( $args['auth_callback'] ) ) { diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index d2dc249615..cbc3dc4c90 100644 --- a/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -234,8 +234,8 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller { */ $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true ); } else { - // Non-draft posts: create or update the post autosave. - $autosave_id = $this->create_post_autosave( (array) $prepared_post ); + // Non-draft posts: create or update the post autosave. Pass the meta data. + $autosave_id = $this->create_post_autosave( (array) $prepared_post, (array) $request->get_param( 'meta' ) ); } if ( is_wp_error( $autosave_id ) ) { @@ -348,11 +348,13 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller { * From wp-admin/post.php. * * @since 5.0.0 + * @since 6.4.0 The `$meta` parameter was added. * * @param array $post_data Associative array containing the post data. + * @param array $meta Associative array containing the post meta data. * @return mixed The autosave revision ID or WP_Error. */ - public function create_post_autosave( $post_data ) { + public function create_post_autosave( $post_data, array $meta = array() ) { $post_id = (int) $post_data['ID']; $post = get_post( $post_id ); @@ -372,6 +374,21 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller { } } + // Check if meta values have changed. + if ( ! empty( $meta ) ) { + $revisioned_meta_keys = wp_post_revision_meta_keys( $post->post_type ); + foreach ( $revisioned_meta_keys as $meta_key ) { + // get_metadata_raw is used to avoid retrieving the default value. + $old_meta = get_metadata_raw( 'post', $post_id, $meta_key, true ); + $new_meta = isset( $meta[ $meta_key ] ) ? $meta[ $meta_key ] : ''; + + if ( $new_meta !== $old_meta ) { + $autosave_is_different = true; + break; + } + } + } + $user_id = get_current_user_id(); // Store one autosave per author. If there is already an autosave, overwrite it. @@ -390,11 +407,26 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller { do_action( 'wp_creating_autosave', $new_autosave ); // wp_update_post() expects escaped array. - return wp_update_post( wp_slash( $new_autosave ) ); + $revision_id = wp_update_post( wp_slash( $new_autosave ) ); + } else { + // Create the new autosave as a special post revision. + $revision_id = _wp_put_post_revision( $post_data, true ); } - // Create the new autosave as a special post revision. - return _wp_put_post_revision( $post_data, true ); + if ( is_wp_error( $revision_id ) || 0 === $revision_id ) { + return $revision_id; + } + + // Attached any passed meta values that have revisions enabled. + if ( ! empty( $meta ) ) { + foreach ( $revisioned_meta_keys as $meta_key ) { + if ( isset( $meta[ $meta_key ] ) ) { + update_metadata( 'post', $revision_id, $meta_key, $meta[ $meta_key ] ); + } + } + } + + return $revision_id; } /** diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 16aceb0d74..5501c190c1 100644 --- a/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -24,6 +24,14 @@ class WP_REST_Revisions_Controller extends WP_REST_Controller { */ private $parent_post_type; + /** + * Instance of a revision meta fields object. + * + * @since 6.4.0 + * @var WP_REST_Post_Meta_Fields + */ + protected $meta; + /** * Parent controller. * @@ -60,6 +68,7 @@ class WP_REST_Revisions_Controller extends WP_REST_Controller { $this->rest_base = 'revisions'; $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2'; + $this->meta = new WP_REST_Post_Meta_Fields( $parent_post_type ); } /** @@ -619,6 +628,10 @@ class WP_REST_Revisions_Controller extends WP_REST_Controller { ); } + if ( rest_is_field_included( 'meta', $fields ) ) { + $data['meta'] = $this->meta->get_value( $post->ID, $request ); + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -752,6 +765,8 @@ class WP_REST_Revisions_Controller extends WP_REST_Controller { $schema['properties']['guid'] = $parent_schema['properties']['guid']; } + $schema['properties']['meta'] = $this->meta->get_field_schema(); + $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); diff --git a/wp-includes/revision.php b/wp-includes/revision.php index d5a5829d7e..d17c79b1b6 100644 --- a/wp-includes/revision.php +++ b/wp-includes/revision.php @@ -95,6 +95,27 @@ function _wp_post_revision_data( $post = array(), $autosave = false ) { return $revision_data; } +/** + * Saves revisions for a post after all changes have been made. + * + * @since 6.4.0 + * + * @param int $post_id The post id that was inserted. + * @param WP_Post $post The post object that was inserted. + * @param bool $update Whether this insert is updating an existing post. + */ +function wp_save_post_revision_on_insert( $post_id, $post, $update ) { + if ( ! $update ) { + return; + } + + if ( ! has_action( 'post_updated', 'wp_save_post_revision' ) ) { + return; + } + + wp_save_post_revision( $post_id ); +} + /** * Creates a revision for the current version of a post. * @@ -111,6 +132,11 @@ function wp_save_post_revision( $post_id ) { return; } + // Prevent saving post revisions if revisions should be saved on wp_after_insert_post. + if ( doing_action( 'post_updated' ) && has_action( 'wp_after_insert_post', 'wp_save_post_revision_on_insert' ) ) { + return; + } + $post = get_post( $post_id ); if ( ! $post ) { @@ -361,15 +387,39 @@ function _wp_put_post_revision( $post = null, $autosave = false ) { * Fires once a revision has been saved. * * @since 2.6.0 + * @since 6.4.0 The post_id parameter was added. * * @param int $revision_id Post revision ID. + * @param int $post_id Post ID. */ - do_action( '_wp_put_post_revision', $revision_id ); + do_action( '_wp_put_post_revision', $revision_id, $post['post_parent'] ); } return $revision_id; } + +/** + * Save the revisioned meta fields. + * + * @since 6.4.0 + * + * @param int $revision_id The ID of the revision to save the meta to. + * @param int $post_id The ID of the post the revision is associated with. + */ +function wp_save_revisioned_meta_fields( $revision_id, $post_id ) { + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return; + } + + foreach ( wp_post_revision_meta_keys( $post_type ) as $meta_key ) { + if ( metadata_exists( 'post', $post_id, $meta_key ) ) { + _wp_copy_post_meta( $post_id, $revision_id, $meta_key ); + } + } +} + /** * Gets a post revision. * @@ -450,6 +500,9 @@ function wp_restore_post_revision( $revision, $fields = null ) { // Update last edit user. update_post_meta( $post_id, '_edit_last', get_current_user_id() ); + // Restore any revisioned meta fields. + wp_restore_post_revision_meta( $post_id, $revision['ID'] ); + /** * Fires after a post revision has been restored. * @@ -463,6 +516,105 @@ function wp_restore_post_revision( $revision, $fields = null ) { return $post_id; } +/** + * Restore the revisioned meta values for a post. + * + * @param int $post_id The ID of the post to restore the meta to. + * @param int $revision_id The ID of the revision to restore the meta from. + * + * @since 6.4.0 + */ +function wp_restore_post_revision_meta( $post_id, $revision_id ) { + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return; + } + + // Restore revisioned meta fields. + foreach ( wp_post_revision_meta_keys( $post_type ) as $meta_key ) { + + // Clear any existing meta. + delete_post_meta( $post_id, $meta_key ); + + _wp_copy_post_meta( $revision_id, $post_id, $meta_key ); + } +} + +/** + * Copy post meta for the given key from one post to another. + * + * @param int $source_post_id Post ID to copy meta value(s) from. + * @param int $target_post_id Post ID to copy meta value(s) to. + * @param string $meta_key Meta key to copy. + * + * @since 6.4.0 + */ +function _wp_copy_post_meta( $source_post_id, $target_post_id, $meta_key ) { + + foreach ( get_post_meta( $source_post_id, $meta_key ) as $meta_value ) { + /** + * We use add_metadata() function vs add_post_meta() here + * to allow for a revision post target OR regular post. + */ + add_metadata( 'post', $target_post_id, $meta_key, wp_slash( $meta_value ) ); + } +} + +/** + * Determine which post meta fields should be revisioned. + * + * @since 6.4.0 + * + * @param string $post_type The post type being revisioned. + * + * @return array An array of meta keys to be revisioned. + */ +function wp_post_revision_meta_keys( $post_type ) { + $registered_meta = array_merge( + get_registered_meta_keys( 'post' ), + get_registered_meta_keys( 'post', $post_type ) + ); + + $wp_revisioned_meta_keys = array(); + + foreach ( $registered_meta as $name => $args ) { + if ( $args['revisions_enabled'] ) { + $wp_revisioned_meta_keys[ $name ] = true; + } + } + + $wp_revisioned_meta_keys = array_keys( $wp_revisioned_meta_keys ); + + /** + * Filter the list of post meta keys to be revisioned. + * + * @since 6.4.0 + * + * @param array $keys An array of meta fields to be revisioned. + * @param string $post_type The post type being revisioned. + */ + return apply_filters( 'wp_post_revision_meta_keys', $wp_revisioned_meta_keys, $post_type ); +} + +/** + * Check whether revisioned post meta fields have changed. + * + * @param bool $post_has_changed Whether the post has changed. + * @param WP_Post $last_revision The last revision post object. + * @param WP_Post $post The post object. + * + * @since 6.4.0 + */ +function wp_check_revisioned_meta_fields_have_changed( $post_has_changed, WP_Post $last_revision, WP_Post $post ) { + foreach ( wp_post_revision_meta_keys( $post->post_type ) as $meta_key ) { + if ( get_post_meta( $post->ID, $meta_key ) !== get_post_meta( $last_revision->ID, $meta_key ) ) { + $post_has_changed = true; + break; + } + } + return $post_has_changed; +} + /** * Deletes a revision. * @@ -728,6 +880,7 @@ function _set_preview( $post ) { add_filter( 'get_the_terms', '_wp_preview_terms_filter', 10, 3 ); add_filter( 'get_post_metadata', '_wp_preview_post_thumbnail_filter', 10, 3 ); + add_filter( 'get_post_metadata', '_wp_preview_meta_filter', 10, 4 ); return $post; } @@ -946,3 +1099,38 @@ function _wp_upgrade_revisions_of_post( $post, $revisions ) { return true; } + +/** + * Filters preview post meta retrieval to get values from the autosave. + * + * Filters revisioned meta keys only. + * + * @since 6.4.0 + * + * @param mixed $value Meta value to filter. + * @param int $object_id Object ID. + * @param string $meta_key Meta key to filter a value for. + * @param bool $single Whether to return a single value. Default false. + * @return mixed Original meta value if the meta key isn't revisioned, the object doesn't exist, + * the post type is a revision or the post ID doesn't match the object ID. + * Otherwise, the revisioned meta value is returned for the preview. + */ +function _wp_preview_meta_filter( $value, $object_id, $meta_key, $single ) { + + $post = get_post(); + if ( + empty( $post ) || + $post->ID !== $object_id || + ! in_array( $meta_key, wp_post_revision_meta_keys( $post->post_type ), true ) || + 'revision' === $post->post_type + ) { + return $value; + } + + $preview = wp_get_post_autosave( $post->ID ); + if ( false === $preview ) { + return $value; + } + + return get_post_meta( $preview->ID, $meta_key, $single ); +} diff --git a/wp-includes/version.php b/wp-includes/version.php index 4b308f7e5e..900ddfa953 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '6.4-alpha-56713'; +$wp_version = '6.4-alpha-56714'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.