diff --git a/editor/components/autosave-monitor/index.js b/editor/components/autosave-monitor/index.js index f47f126ab054d3..80102a066b15ff 100644 --- a/editor/components/autosave-monitor/index.js +++ b/editor/components/autosave-monitor/index.js @@ -11,18 +11,22 @@ import { Component } from '@wordpress/element'; /** * Internal dependencies */ -import { autosave } from '../../store/actions'; +import { doAutosave } from '../../store/actions'; import { isEditedPostDirty, isEditedPostSaveable, + isPostAutosavable, } from '../../store/selectors'; export class AutosaveMonitor extends Component { componentDidUpdate( prevProps ) { - const { isDirty, isSaveable } = this.props; - if ( prevProps.isDirty !== isDirty || - prevProps.isSaveable !== isSaveable ) { - this.toggleTimer( isDirty && isSaveable ); + const { isDirty, isSaveable, isAutosavable } = this.props; + if ( + prevProps.isDirty !== isDirty || + prevProps.isSaveable !== isSaveable || + prevProps.isAutosavable !== isAutosavable + ) { + this.toggleTimer( isDirty && isSaveable && isAutosavable ); } } @@ -51,7 +55,8 @@ export default connect( return { isDirty: isEditedPostDirty( state ), isSaveable: isEditedPostSaveable( state ), + isAutosavable: isPostAutosavable( state ), }; }, - { autosave } + { doAutosave } )( AutosaveMonitor ); diff --git a/editor/components/autosave-monitor/test/index.js b/editor/components/autosave-monitor/test/index.js index 14c843f4ce5d25..a9e1790d8cd2ea 100644 --- a/editor/components/autosave-monitor/test/index.js +++ b/editor/components/autosave-monitor/test/index.js @@ -23,7 +23,7 @@ describe( 'AutosaveMonitor', () => { describe( '#componentDidUpdate()', () => { it( 'should start autosave timer when having become dirty and saveable', () => { - wrapper.setProps( { isDirty: true, isSaveable: true } ); + wrapper.setProps( { isDirty: true, isSaveable: true, isAutosavable: true } ); expect( toggleTimer ).toHaveBeenCalledWith( true ); } ); diff --git a/editor/components/post-publish-button/index.js b/editor/components/post-publish-button/index.js index 070b55b8af1d14..4cdef0ed3fe786 100644 --- a/editor/components/post-publish-button/index.js +++ b/editor/components/post-publish-button/index.js @@ -23,6 +23,7 @@ import { getEditedPostVisibility, isEditedPostSaveable, isEditedPostPublishable, + isAutosavingPost, getCurrentPostType, } from '../../store/selectors'; @@ -34,6 +35,7 @@ export function PostPublishButton( { visibility, isPublishable, isSaveable, + isAutosaving, user, onSubmit = noop, } ) { @@ -52,7 +54,7 @@ export function PostPublishButton( { } const className = classnames( 'editor-post-publish-button', { - 'is-saving': isSaving, + 'is-saving': isSaving && ! isAutosaving, } ); const onClick = () => { @@ -81,6 +83,7 @@ const applyConnect = connect( visibility: getEditedPostVisibility( state ), isSaveable: isEditedPostSaveable( state ), isPublishable: isEditedPostPublishable( state ), + isAutosaving: isAutosavingPost( state ), postType: getCurrentPostType( state ), } ), { diff --git a/editor/components/post-saved-state/index.js b/editor/components/post-saved-state/index.js index a24b0f29a95a91..610eb3f1a8dc78 100644 --- a/editor/components/post-saved-state/index.js +++ b/editor/components/post-saved-state/index.js @@ -23,20 +23,21 @@ import { isEditedPostSaveable, getCurrentPost, getEditedPostAttribute, + isAutosavingPost, } from '../../store/selectors'; -export function PostSavedState( { isNew, isPublished, isDirty, isSaving, isSaveable, status, onStatusChange, onSave } ) { +export function PostSavedState( { isNew, isPublished, isDirty, isSaving, isSaveable, status, onStatusChange, onSave, isAutosaving } ) { const className = 'editor-post-saved-state'; if ( isSaving ) { return ( - { __( 'Saving' ) } + { isAutosaving ? __( 'Autosaving' ) : __( 'Saving' ) } ); } - if ( ! isSaveable || isPublished ) { + if ( ! isSaveable ) { return null; } @@ -59,8 +60,8 @@ export function PostSavedState( { isNew, isPublished, isDirty, isSaving, isSavea return ( ); } @@ -74,6 +75,7 @@ export default connect( isSaving: isSavingPost( state ), isSaveable: isEditedPostSaveable( state ), status: getEditedPostAttribute( state, 'status' ), + isAutosaving: isAutosavingPost( state ), } ), { onStatusChange: ( status ) => editPost( { status } ), diff --git a/editor/components/provider/index.js b/editor/components/provider/index.js index 7a0342faf30d3e..def2d74bd44965 100644 --- a/editor/components/provider/index.js +++ b/editor/components/provider/index.js @@ -19,7 +19,7 @@ import { /** * Internal Dependencies */ -import { setupEditor, undo } from '../../store/actions'; +import { setupEditor, undo, showAutosaveAlert } from '../../store/actions'; import store from '../../store'; /** @@ -52,6 +52,10 @@ class EditorProvider extends Component { // Assume that we don't need to initialize in the case of an error recovery. if ( ! props.recovery ) { this.store.dispatch( setupEditor( props.post, this.settings ) ); + + if ( props.autosave ) { + this.store.dispatch( showAutosaveAlert( props.autosave ) ); + } } } diff --git a/editor/index.js b/editor/index.js index 2cd98d2b73bfb6..1e67e90be7b792 100644 --- a/editor/index.js +++ b/editor/index.js @@ -78,14 +78,15 @@ export function recreateEditorInstance( target, settings ) { * @param {String} id Unique identifier for editor instance * @param {Object} post API entity for post to edit * @param {?Object} settings Editor settings object + * @param {?Object} autosave The autosave data * @return {Object} Editor interface */ -export function createEditorInstance( id, post, settings ) { +export function createEditorInstance( id, post, settings, autosave ) { const target = document.getElementById( id ); const reboot = recreateEditorInstance.bind( null, target, settings ); render( - + diff --git a/editor/store/actions.js b/editor/store/actions.js index b5004e4db6e2c8..429dfef8f4ada4 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -220,9 +220,10 @@ export function editPost( edits ) { }; } -export function savePost() { +export function savePost( options ) { return { type: 'REQUEST_POST_UPDATE', + options, }; } @@ -246,12 +247,26 @@ export function mergeBlocks( blockA, blockB ) { * * @return {Object} Action object */ -export function autosave() { +export function doAutosave() { return { type: 'AUTOSAVE', }; } +export function toggleAutosave( isAutosaving ) { + return { + type: 'DOING_AUTOSAVE', + isAutosaving, + }; +} + +export function showAutosaveAlert( autosave ) { + return { + type: 'REQUEST_AUTOSAVE_EXISTS', + autosave, + }; +} + /** * Returns an action object used in signalling that undo history should * restore last popped state. diff --git a/editor/store/effects.js b/editor/store/effects.js index c6777602c4f244..480403efecee46 100644 --- a/editor/store/effects.js +++ b/editor/store/effects.js @@ -30,8 +30,10 @@ import { replaceBlocks, createSuccessNotice, createErrorNotice, + createWarningNotice, removeNotice, savePost, + toggleAutosave, editPost, requestMetaBoxUpdates, updateReusableBlock, @@ -44,7 +46,8 @@ import { getDirtyMetaBoxes, getEditedPostContent, getPostEdits, - isCurrentPostPublished, + getEditedPostTitle, + getEditedPostExcerpt, isEditedPostDirty, isEditedPostNew, isEditedPostSaveable, @@ -57,6 +60,7 @@ import { * Module Constants */ const SAVE_POST_NOTICE_ID = 'SAVE_POST_NOTICE_ID'; +const AUTOSAVE_POST_NOTICE_ID = 'AUTOSAVE_POST_NOTICE_ID'; const TRASH_POST_NOTICE_ID = 'TRASH_POST_NOTICE_ID'; const SAVE_REUSABLE_BLOCK_NOTICE_ID = 'SAVE_REUSABLE_BLOCK_NOTICE_ID'; @@ -71,26 +75,65 @@ export default { content: getEditedPostContent( state ), id: post.id, }; + const isAutosave = action.options && action.options.autosave; + let Model, newModel; - dispatch( { - type: 'UPDATE_POST', - edits: toSend, - optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, - } ); - dispatch( removeNotice( SAVE_POST_NOTICE_ID ) ); - const Model = wp.api.getPostTypeModel( getCurrentPostType( state ) ); - new Model( toSend ).save().done( ( newPost ) => { - dispatch( { - type: 'RESET_POST', - post: newPost, - } ); + if ( isAutosave ) { + toSend.parent = post.id; + delete toSend.id; + Model = wp.api.getPostTypeAutosaveModel( getCurrentPostType( state ) ); + newModel = new Model( toSend ); + } else { dispatch( { - type: 'REQUEST_POST_UPDATE_SUCCESS', - previousPost: post, - post: newPost, - optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID }, + type: 'UPDATE_POST', + edits: toSend, + optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, } ); + dispatch( removeNotice( SAVE_POST_NOTICE_ID ) ); + dispatch( removeNotice( AUTOSAVE_POST_NOTICE_ID ) ); + Model = wp.api.getPostTypeModel( getCurrentPostType( state ) ); + newModel = new Model( toSend ); + } + + newModel.save().done( ( newPost ) => { + if ( isAutosave ) { + const autosave = { + id: newPost.id, + title: getEditedPostTitle( state ), + excerpt: getEditedPostExcerpt( state ), + content: getEditedPostContent( state ), + }; + dispatch( { + type: 'RESET_AUTOSAVE', + post: autosave, + } ); + dispatch( toggleAutosave( false ) ); + + dispatch( { + type: 'REQUEST_POST_UPDATE_SUCCESS', + previousPost: post, + post: post, + isAutosave: true, + } ); + } else { + // dispatch post autosaved false + // delete the autosave + dispatch( { + type: 'RESET_POST', + post: newPost, + } ); + dispatch( { + type: 'REQUEST_POST_UPDATE_SUCCESS', + previousPost: post, + post: newPost, + optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID }, + } ); + } } ).fail( ( err ) => { + if ( isAutosave ) { + dispatch( toggleAutosave( false ) ); + } + dispatch( { type: 'REQUEST_POST_UPDATE_FAILURE', error: get( err, 'responseJSON', { @@ -99,12 +142,28 @@ export default { } ), post, edits, - optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, + optimist: isAutosave ? false : { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, } ); } ); }, + REQUEST_AUTOSAVE_EXISTS( action, store ) { + const { autosave } = action; + const { dispatch } = store; + if ( autosave ) { + dispatch( createWarningNotice( +

+ { __( 'There is an autosave of this post that is more recent than the version below.' ) } + { ' ' } + { { __( 'View the autosave' ) } } +

, + { + id: AUTOSAVE_POST_NOTICE_ID, + } + ) ); + } + }, REQUEST_POST_UPDATE_SUCCESS( action, store ) { - const { previousPost, post } = action; + const { previousPost, post, isAutosave } = action; const { dispatch, getState } = store; const publishStatus = [ 'publish', 'private', 'future' ]; @@ -128,7 +187,7 @@ export default { private: __( 'Post published privately!' ), future: __( 'Post scheduled!' ), }[ post.status ]; - } else { + } else if ( ! isAutosave ) { // Generic fallback notice noticeMessage = __( 'Post updated!' ); } @@ -263,25 +322,19 @@ export default { return; } - if ( ! isEditedPostNew( state ) && ! isEditedPostDirty( state ) ) { + // Change status from auto-draft to draft, saving the post. + if ( isEditedPostNew( state ) ) { + dispatch( editPost( { status: 'draft' } ) ); + dispatch( savePost() ); return; } - if ( isCurrentPostPublished( state ) ) { - // TODO: Publish autosave. - // - Autosaves are created as revisions for published posts, but - // the necessary REST API behavior does not yet exist - // - May need to check for whether the status of the edited post - // has changed from the saved copy (i.e. published -> pending) + if ( ! isEditedPostDirty( state ) ) { return; } - // Change status from auto-draft to draft - if ( isEditedPostNew( state ) ) { - dispatch( editPost( { status: 'draft' } ) ); - } - - dispatch( savePost() ); + dispatch( toggleAutosave( true ) ); + dispatch( savePost( { autosave: true } ) ); }, SETUP_EDITOR( action ) { const { post, settings } = action; diff --git a/editor/store/reducer.js b/editor/store/reducer.js index 9d7e53178658fa..f4a2dd9f745a0a 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -77,6 +77,15 @@ export const editor = flow( [ // resetting at each post save. partialRight( withChangeDetection, { resetTypes: [ 'SETUP_EDITOR', 'RESET_POST' ] } ), ] )( { + autosave( state = false, action ) { + const { post } = action; + switch ( action.type ) { + case 'RESET_AUTOSAVE': + return post; + } + + return state; + }, edits( state = {}, action ) { switch ( action.type ) { case 'EDIT_POST': @@ -473,6 +482,16 @@ export function blocksMode( state = {}, action ) { return state; } +export function currentlyAutosaving( state = false, action ) { + switch ( action.type ) { + case 'DOING_AUTOSAVE': + const { isAutosaving } = action; + return isAutosaving; + } + + return state; +} + /** * Reducer returning the block insertion point * @@ -780,6 +799,7 @@ export const reusableBlocks = combineReducers( { export default optimist( combineReducers( { editor, + currentlyAutosaving, currentPost, isTyping, blockSelection, diff --git a/editor/store/selectors.js b/editor/store/selectors.js index 99dd17ce588f66..bac8a2b558343d 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -364,6 +364,44 @@ export function isEditedPostSaveable( state ) { ); } +/** + * Returns true if the post can be autosaved, or false otherwise. + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post can be autosaved + */ +export function isPostAutosavable( state ) { + // If the post is autosaving, it is not autosavable. + if ( state.currentlyAutosaving ) { + return false; + } + + // If we don't already have an autosave, the post is autosavable. + if ( ! hasAutosave( state ) ) { + return true; + } + + const title = getEditedPostTitle( state ); + const excerpt = getEditedPostExcerpt( state ); + const content = getEditedPostContent( state ); + const autosave = state.editor.present.autosave; + + // If the title, excerpt or content has changed, the post is autosavable. + if ( + ( autosave.title && title !== autosave.title ) || + ( autosave.excerpt && excerpt !== autosave.excerpt ) || + ( autosave.content && content !== autosave.content ) + ) { + return true; + } + + return false; +} + +export function hasAutosave( state ) { + return !! state.editor.present.autosave; +} + /** * Return true if the post being edited is being scheduled. Preferring the * unsaved status values. @@ -958,6 +996,16 @@ export function didPostSaveRequestFail( state ) { return !! state.saving.error; } +/** + * Is the post autosaving? + * + * @param {Object} state Global application state + * @return {Boolean} Whether the post is autosaving + */ +export function isAutosavingPost( state ) { + return !! state.currentlyAutosaving; +} + /** * Returns a suggested post format for the current post, inferred only if there * is a single block within the post and it is of a type known to match a diff --git a/editor/store/test/actions.js b/editor/store/test/actions.js index 146a38b92ff8b2..0dab8a3393f15c 100644 --- a/editor/store/test/actions.js +++ b/editor/store/test/actions.js @@ -36,7 +36,7 @@ import { savePost, trashPost, mergeBlocks, - autosave, + doAutosave, redo, undo, removeBlocks, @@ -306,9 +306,9 @@ describe( 'actions', () => { } ); } ); - describe( 'autosave', () => { + describe( 'doAutosave', () => { it( 'should return AUTOSAVE action', () => { - expect( autosave() ).toEqual( { + expect( doAutosave() ).toEqual( { type: 'AUTOSAVE', } ); } ); diff --git a/editor/store/test/effects.js b/editor/store/test/effects.js index 95ec8b946cbc7f..82ec2fbc66f650 100644 --- a/editor/store/test/effects.js +++ b/editor/store/test/effects.js @@ -194,6 +194,7 @@ describe( 'effects', () => { beforeAll( () => { selectors.isEditedPostSaveable = jest.spyOn( selectors, 'isEditedPostSaveable' ); selectors.isEditedPostDirty = jest.spyOn( selectors, 'isEditedPostDirty' ); + selectors.isPostAutosavable = jest.spyOn( selectors, 'isPostAutosavable' ); selectors.isCurrentPostPublished = jest.spyOn( selectors, 'isCurrentPostPublished' ); selectors.isEditedPostNew = jest.spyOn( selectors, 'isEditedPostNew' ); } ); @@ -202,6 +203,7 @@ describe( 'effects', () => { dispatch.mockReset(); selectors.isEditedPostSaveable.mockReset(); selectors.isEditedPostDirty.mockReset(); + selectors.isPostAutosavable.mockReset(); selectors.isCurrentPostPublished.mockReset(); selectors.isEditedPostNew.mockReset(); } ); @@ -209,6 +211,7 @@ describe( 'effects', () => { afterAll( () => { selectors.isEditedPostSaveable.mockRestore(); selectors.isEditedPostDirty.mockRestore(); + selectors.isPostAutosavable.mockRestore(); selectors.isCurrentPostPublished.mockRestore(); selectors.isEditedPostNew.mockRestore(); } ); @@ -270,13 +273,14 @@ describe( 'effects', () => { it( 'should return update action for saveable, dirty draft', () => { selectors.isEditedPostSaveable.mockReturnValue( true ); selectors.isEditedPostDirty.mockReturnValue( true ); + selectors.isPostAutosavable.mockReturnValue( true ); selectors.isCurrentPostPublished.mockReturnValue( false ); selectors.isEditedPostNew.mockReturnValue( false ); handler( {}, store ); - expect( dispatch ).toHaveBeenCalledTimes( 1 ); - expect( dispatch ).toHaveBeenCalledWith( savePost() ); + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + expect( dispatch ).toHaveBeenCalledWith( savePost( { autosave: true } ) ); } ); } ); diff --git a/lib/class-wp-rest-autosaves-controller.php b/lib/class-wp-rest-autosaves-controller.php new file mode 100644 index 00000000000000..d964e29f20a173 --- /dev/null +++ b/lib/class-wp-rest-autosaves-controller.php @@ -0,0 +1,307 @@ +parent_post_type = $parent_post_type; + $this->parent_controller = new WP_REST_Posts_Controller( $parent_post_type ); + $this->revision_controller = new WP_REST_Revisions_Controller( $parent_post_type ); + $this->rest_namespace = 'wp/v2'; + $this->rest_base = 'autosaves'; + $post_type_object = get_post_type_object( $parent_post_type ); + $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; + } + + /** + * Registers routes for autosaves based on post types supporting autosaves. + * + * @since 5.0.0 + * + * @see register_rest_route() + */ + public function register_routes() { + register_rest_route( + $this->rest_namespace, '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The ID for the parent of the object.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this->revision_controller, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this->parent_controller, 'create_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->rest_namespace, '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base . '/(?P[\d]+)', array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The ID for the parent of the object.', 'gutenberg' ), + 'type' => 'integer', + ), + 'id' => array( + 'description' => __( 'Unique identifier for the object.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this->revision_controller, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this->revision_controller, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Required to be true, as autosaves do not support trashing.', 'gutenberg' ), + ), + ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this->parent_controller, 'create_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + } + + /** + * Creates a single autosave. + * + * @since 5.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + + // Map new fields onto the existing post data. + $parent = $this->revision_controller->get_parent( $request['parent'] ); + $prepared_post = $this->parent_controller->prepare_item_for_database( $request ); + $prepared_post->ID = $parent->ID; + + // If the parent post a draft, autosaving updates it and does not create a revision. + if ( 'draft' === $parent->post_status ) { + + // Disable revisions. + remove_action( 'post_updated', 'wp_save_post_revision' ); + + $autosave_id = wp_update_post( (array) $prepared_post, true ); + + // Re-enable revisions. + add_action( 'post_updated', 'wp_save_post_revision' ); + if ( ! is_wp_error( $autosave_id ) ) { + $post = get_post( $autosave_id ); + } + } else { + + // Non-draft posts - update the post, creating an autosave. + $autosave_id = $this->create_post_autosave( (array) $prepared_post ); + $post = get_post( $autosave_id ); + } + $request->set_param( 'context', 'edit' ); + + $response = $this->prepare_item_for_response( $post, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->rest_namespace, $this->rest_base, $autosave_id ) ) ); + + return $response; + } + + /** + * Get the autosave, if the ID is valid. + * + * @since 5.0.0 + * + * @param int $id Supplied ID. + * @return WP_Post|WP_Error Revision post object if ID is valid, WP_Error otherwise. + */ + public function get_item( $id ) { + $error = new WP_Error( 'rest_post_invalid_id', __( 'Invalid autosave ID.', 'gutenberg' ), array( 'status' => 404 ) ); + if ( (int) $id <= 0 ) { + return $error; + } + + $autosave = get_post( (int) $id ); + if ( empty( $autosave ) || empty( $autosave->ID ) || 'autosave' !== $autosave->post_type ) { + return $error; + } + + return $autosave; + } + + /** + * Gets a collection of autosaves using wp_get_post_autosave. + * + * Contains the user's autosave, for empty if it doesn't exist. + * + * @since 5.0.0 + * + * @param WP_REST_Request $request Full data about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $parent = $this->revision_controller->get_parent( $request['parent'] ); + if ( is_wp_error( $parent ) ) { + return $parent; + } + + $autosave = wp_get_post_autosave( $request['parent'] ); + + if ( ! $autosave ) { + return array(); + } + + $response = array(); + $data = $this->prepare_item_for_response( $autosave, $request ); + $response[] = $this->prepare_response_for_collection( $data ); + + return rest_ensure_response( $response ); + } + + + /** + * Retrieves the autosave's schema, conforming to JSON Schema. + * + * @since 5.0.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + return $this->revision_controller->get_item_schema(); + } + + /** + * Creates autosave data for the specified post from $_POST data. + * + * From core post.php. + * + * @since 2.6.0 + * + * @param mixed $post_data Associative array containing the post data or int post ID. + * @return mixed The autosave revision ID. WP_Error or 0 on error. + */ + public function create_post_autosave( $post_data ) { + + $post_id = (int) $post_data['ID']; + $post_author = get_current_user_id(); + + // Store one autosave per author. If there is already an autosave, overwrite it. + $old_autosave = wp_get_post_autosave( $post_id, $post_author ); + if ( $old_autosave ) { + $new_autosave = _wp_post_revision_data( $post_data, true ); + $new_autosave['ID'] = $old_autosave->ID; + $new_autosave['post_author'] = $post_author; + + // If the new autosave has the same content as the post, delete the autosave. + $post = get_post( $post_id ); + $autosave_is_different = false; + foreach ( array_intersect( array_keys( $new_autosave ), array_keys( _wp_post_revision_fields( $post ) ) ) as $field ) { + if ( normalize_whitespace( $new_autosave[ $field ] ) != normalize_whitespace( $post->$field ) ) { + $autosave_is_different = true; + break; + } + } + + if ( ! $autosave_is_different ) { + wp_delete_post_revision( $old_autosave->ID ); + return 0; + } + + /** + * Fires before an autosave is stored. + * + * @since 4.1.0 + * + * @param array $new_autosave Post array - the autosave that is about to be saved. + */ + do_action( 'wp_creating_autosave', $new_autosave ); + + return wp_update_post( $new_autosave ); + } + + // _wp_put_post_revision() expects unescaped. + $post_data = wp_unslash( $post_data ); + + // Otherwise create the new autosave as a special post revision. + return _wp_put_post_revision( $post_data, true ); + } +} diff --git a/lib/client-assets.php b/lib/client-assets.php index 513e886551d811..62b4990c564938 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -479,6 +479,12 @@ function gutenberg_extend_wp_api_backbone_client() { return model.prototype.route && route === model.prototype.route.index; } ); }; + wp.api.getPostTypeAutosaveModel = function( postType ) { + var route = '/' + wpApiSettings.versionString + wp.api.postTypeRestBaseMapping[ postType ] + '/(?P[\\\\d]+)/autosaves/(?P[\\\\d]+)'; + return _.find( wp.api.models, function( collection ) { + return collection.prototype.route && route === collection.prototype.route.index; + } ); + }; wp.api.getTaxonomyModel = function( taxonomy ) { var route = '/' + wpApiSettings.versionString + this.taxonomyRestBaseMapping[ taxonomy ] + '/(?P[\\\\d]+)'; return _.find( wp.api.models, function( model ) { @@ -727,6 +733,39 @@ function gutenberg_editor_scripts_and_styles( $hook ) { wp_die( $post_to_edit->get_error_message() ); } + // Add autosave data if it is newer and changed. + $autosave = wp_get_post_autosave( $post->ID ); + $show_autosave = false; + + // Is the autosave newer than the post? + if ( + $autosave && + mysql2date( 'U', $autosave->post_modified_gmt, false ) > mysql2date( 'U', $post->post_modified_gmt, false ) + ) { + foreach ( _wp_post_revision_fields( $post ) as $autosave_field => $_autosave_field ) { + if ( normalize_whitespace( $autosave->$autosave_field ) != normalize_whitespace( $post->$autosave_field ) ) { + $show_autosave = true; + break; + } + } + } + + // If this autosave isn't newer and different from the current post, remove. + if ( $autosave && ! $show_autosave ) { + wp_delete_post_revision( $autosave->ID ); + } + + if ( $show_autosave ) { + wp_localize_script( + 'wp-editor', + '_wpAutosave', + array( + 'id' => $autosave->ID, + 'edit_link' => add_query_arg( 'gutenberg', true, get_edit_post_link( $autosave->ID ) ), + ) + ); + } + // Set initial title to empty string for auto draft for duration of edit. // Otherwise, title defaults to and displays as "Auto Draft". $is_new_post = 'auto-draft' === $post_to_edit['status']; @@ -833,7 +872,7 @@ function gutenberg_editor_scripts_and_styles( $hook ) { $script .= sprintf( 'var editorSettings = %s;', wp_json_encode( $editor_settings ) ); $script .= <<rest_base ) ) { + if ( post_type_supports( $post_type_object->name, 'revisions' ) ) { + $autosaves = new WP_REST_Autosaves_Controller( $post_type_object->name ); + $autosaves->register_routes(); + } + } + } +} +add_action( 'rest_api_init', 'gutenberg_register_rest_routes' ); + /** * Gets revisions details for the selected post. *