From 89a99dba2fab4e3db02cd11d5954252274096e57 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 26 Sep 2023 15:32:19 +0000 Subject: [PATCH] Revisions: framework for storing post meta revisions. Enable the storing of post meta in revisions including autosaves and previews: Add a new argument `revisions_enabled` to the `register_meta` function which enables storing meta in revisions. Add a new `wp_post_revision_meta_keys` filter which developers can use to control which meta is revisioned - it passes an array of the meta keys with revisions enabled as well as the post type. Meta keys with revisions enabled are also stored for autosaves, and are restored when a revision or autosave is restored. In addition, meta values are now stored with the autosave revision used for previews. Changes to meta can now be previewed correctly without overwriting the published meta (see #20299) or passing data as a query variable, as the editor currently does to preview changes to the featured image. Changes to meta with revisions enabled are considered when determining if a new revision should be created. A new revision is created if the meta value has changed since the last revision. Revisions are now saved on the `wp_after_insert_post` hook instead of `post_updated`. The `wp_after_insert_post` action is fired after post meta has been saved by the REST API which enables attaching meta to the revision. To ensure backwards compatibility with existing action uses, `wp_save_post_revision_on_insert` function exits early if plugins have removed the previous `do_action( 'post_updated', 'wp_save_post_revision' )` call. Props: alexkingorg, johnbillion, markjaquith, WraithKenny, kovshenin, azaozz, tv-productions, p51labs, mattheu, mikeschroder, Mamaduka, ellatrix, timothyblynjacobs, jakemgold, bookwyrm, ryanduff, mintindeed, wonderboymusic, sanchothefat, westonruter, spacedmonkey, hellofromTonya, drewapicture, adamsilverstein, swisspiddy. Fixes #20564, #20299. Built from https://develop.svn.wordpress.org/trunk@56714 git-svn-id: http://core.svn.wordpress.org/trunk@56226 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-admin/includes/post.php | 71 ++++++- wp-includes/default-filters.php | 13 ++ wp-includes/meta.php | 15 ++ .../class-wp-rest-autosaves-controller.php | 44 +++- .../class-wp-rest-revisions-controller.php | 15 ++ wp-includes/revision.php | 190 +++++++++++++++++- wp-includes/version.php | 2 +- 7 files changed, 339 insertions(+), 11 deletions(-) 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.