original id for a merge completed this request. * * @var array */ private $merged = array(); /** * Service instance. * * @var LiveDraftService|null */ private $service = null; /** * Feature id. * * @return string */ public function id() { return 'live_draft'; } /** * Label. * * @return string */ public function label() { return __( 'Live Draft', 'adminkeep' ); } /** * Description. * * @return string */ public function description() { return __( 'Rework a published page privately. You get a working copy to edit; when you publish it, the words replace the live page and everything else about it — address, date, comments, links — stays exactly as it was.', 'adminkeep' ); } /** * A word for what this feature is doing right now. * * @return string */ public function state_label() { return __( 'Available', 'adminkeep' ); } /** * Settings schema. No translated strings — this runs before init. * * @return array */ public function settings_schema() { return array( 'post_types' => array( 'type' => 'array', 'default' => array( 'post', 'page' ), 'ui' => 'post_types', ), ); } /** * Field labels, resolved at render time. * * @param string $key Field key. * @return string */ public function field_label( $key ) { if ( 'post_types' === $key ) { return __( 'Offer a working copy on these post types', 'adminkeep' ); } return ''; } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'Adds an "Edit a copy" link to published posts. The copy is a draft, so visitors never see it.', 'adminkeep' ), __( 'Publish the working copy and its content replaces the live page. The page keeps its ID, its web address, its publish date, its comments and every link pointing at it.', 'adminkeep' ), __( 'The version you replaced is saved as a revision first, so you can go back to it from the editor.', 'adminkeep' ), __( 'If anything goes wrong mid-merge, the live page is restored exactly as it was. A half-updated page is never left behind.', 'adminkeep' ), __( 'Only one working copy exists per page at a time — opening it again takes you back to the one you started.', 'adminkeep' ), __( 'Delete the working copy at any time and the live page is untouched.', 'adminkeep' ), ); } /** * Register hooks. * * @param array $settings Resolved settings. * @return void */ public function boot( array $settings ) { $this->settings = $settings; add_action( 'admin_action_' . self::ACTION_OPEN, array( $this, 'handle_open' ) ); // wp_after_insert_post rather than transition_post_status: it fires at the very end of // the save, so deleting the working copy here cannot pull the post out from under hooks // that still have to run. add_action( 'wp_after_insert_post', array( $this, 'maybe_merge' ), 10, 2 ); // After maybe_merge, so a merge settles before a late save of its copy is considered. add_action( 'wp_after_insert_post', array( $this, 'maybe_sync_late_meta' ), 20, 2 ); add_action( LiveDraftService::CLEANUP_EVENT, array( $this, 'cleanup_merged_copy' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) ); add_action( 'load-post.php', array( $this, 'maybe_leave_merged_copy_screen' ) ); add_action( 'wp_trash_post', array( $this, 'detach' ) ); add_action( 'before_delete_post', array( $this, 'detach' ) ); add_filter( 'redirect_post_location', array( $this, 'redirect_after_merge' ), 10, 2 ); add_filter( 'display_post_states', array( $this, 'post_state' ), 10, 2 ); } /** * Declare the row action. * * @param AdminSurface $surface Surface to declare onto. * @return void */ public function register_surface( AdminSurface $surface ) { $post_types = isset( $this->settings['post_types'] ) ? (array) $this->settings['post_types'] : array(); if ( empty( $post_types ) ) { return; } $surface->register_row_action( new RowAction( array( 'id' => 'adminkeep-live-draft', 'label' => array( $this, 'row_action_label' ), 'post_types' => $post_types, 'capability' => 'edit_post', 'url' => array( $this, 'open_url' ), // Only a published post needs one. An unpublished post is already private. 'is_available' => array( $this, 'is_published' ), ) ) ); } /** * Row action label. * * @return string */ public function row_action_label() { return __( 'Edit a copy', 'adminkeep' ); } /** * Whether a post is published. * * @param \WP_Post $post Post being rendered. * @return bool */ public function is_published( $post ) { return 'publish' === $post->post_status; } /** * The nonced URL that opens a working copy. * * @param \WP_Post $post Post the link is rendered for. * @return string */ public function open_url( $post ) { return wp_nonce_url( admin_url( 'admin.php?action=' . self::ACTION_OPEN . '&post=' . $post->ID ), self::ACTION_OPEN . '_' . $post->ID ); } /** * Open a working copy and go straight to it. * * @return void */ public function handle_open() { $post_id = isset( $_REQUEST['post'] ) ? absint( $_REQUEST['post'] ) : 0; check_admin_referer( self::ACTION_OPEN . '_' . $post_id ); $draft_id = $this->service()->open( $post_id ); if ( is_wp_error( $draft_id ) ) { wp_die( esc_html( $draft_id->get_error_message() ), esc_html__( 'Could not open a working copy', 'adminkeep' ), array( 'response' => 403 ) ); } wp_safe_redirect( get_edit_post_link( $draft_id, 'raw' ) ); exit; } /** * If a working copy was just published, merge it instead of leaving it live. * * @param int $post_id Post that was saved. * @param \WP_Post $post The post. * @return void */ public function maybe_merge( $post_id, $post ) { if ( $this->merging || ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) { return; } if ( ! $this->service()->is_live_draft( $post ) ) { return; } $original_id = $this->service()->original_for( $post_id ); $this->merging = true; $result = $this->service()->merge( $post_id ); if ( is_wp_error( $result ) ) { // The copy was published a moment ago and the merge did not happen, so right now // there is a second public page with the same content. Put it back to draft — the // original is already restored by the service, so this returns the site to exactly // its pre-save state. wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft', ) ); $this->merging = false; return; } $this->merging = false; $this->merged[ $post_id ] = $original_id; if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { // The editor script will land this user on the original's edit screen in a moment; // leave a short-lived note there so the arrival explains itself. set_transient( self::landing_key( $original_id ), 1, 2 * MINUTE_IN_SECONDS ); } // The block editor publishes the copy over the REST API, and this hook fires in the // middle of that request — core has still to prepare a response for a post the merge // just deleted. Registered here, not in boot(), so the filter exists exactly and only // on a request that merged. add_filter( 'rest_prepare_' . $post->post_type, array( $this, 'rest_response_after_merge' ), 10, 2 ); } /** * After a merge, send the editor to the post that was updated. * * The working copy no longer exists, so the default redirect would land on a missing post. * message=1 is core's own "Post updated." string, not a notice of ours — the zero-notice * guarantee is about advertisements, and this reuses the editor's built-in feedback. * * @param string $location Redirect target. * @param int $post_id Post that was saved. * @return string */ public function redirect_after_merge( $location, $post_id ) { if ( ! isset( $this->merged[ $post_id ] ) ) { return $location; } return add_query_arg( 'message', 1, get_edit_post_link( $this->merged[ $post_id ], 'raw' ) ); } /** * After a merge, make the REST response describe the live result. * * The REST twin of redirect_after_merge(). The block editor publishes the working copy over * the REST API, and the merge deletes that copy before the controller prepares its response. * Core then computes `link` as get_permalink() of the deleted id, which is `false` — and the * editor renders that value straight into ExternalLink, where `false.startsWith` throws and * the whole editor crashes on publish. Point the address fields at the post the reader will * actually see: the merged original. * * @param \WP_REST_Response $response Response for the just-published copy. * @param \WP_Post $post The copy, as core last held it in memory. * @return \WP_REST_Response */ public function rest_response_after_merge( $response, $post ) { if ( ! $post instanceof \WP_Post || ! isset( $this->merged[ $post->ID ] ) || ! $response instanceof \WP_REST_Response ) { return $response; } $original = get_post( $this->merged[ $post->ID ] ); if ( ! $original instanceof \WP_Post ) { return $response; } $data = $response->get_data(); if ( ! is_array( $data ) ) { return $response; } $data['link'] = get_permalink( $original ); // The signal the editor script waits for: the copy this session holds is dead, and this // is the post its content now lives on. $data['adminkeep_live_draft'] = array( 'merged_into' => (int) $original->ID, 'edit_url' => get_edit_post_link( $original->ID, 'raw' ), ); if ( array_key_exists( 'slug', $data ) ) { $data['slug'] = $original->post_name; } if ( array_key_exists( 'generated_slug', $data ) || array_key_exists( 'permalink_template', $data ) ) { if ( ! function_exists( 'get_sample_permalink' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } list( $permalink_template, $generated_slug ) = get_sample_permalink( $original->ID ); if ( array_key_exists( 'permalink_template', $data ) ) { $data['permalink_template'] = $permalink_template; } if ( array_key_exists( 'generated_slug', $data ) ) { $data['generated_slug'] = $generated_slug; } } $response->set_data( $data ); return $response; } /** * Carry a post-merge save of the copy over to the original. * * An editor with metaboxes follows its publish request with a second save against the same * id, and by then the merge has already run — those last-second metabox values (an SEO * title typed right before Publish) land on the trashed copy. Sync them across so they * reach the page the reader sees. * * @param int $post_id Post that was saved. * @param \WP_Post $post The post. * @return void */ public function maybe_sync_late_meta( $post_id, $post ) { if ( $this->merging || ! $post instanceof \WP_Post || 'trash' !== $post->post_status ) { return; } if ( ! $this->service()->is_live_draft( $post ) ) { return; } $this->service()->sync_late_meta( $post_id ); } /** * Send a request for a merged copy's edit screen to the original instead. * * The copy's editor URL outlives the copy — the browser's back button, the metabox save's * trailing redirect, a stale tab. Core answers those with "You can't edit this item because * it is in the Trash", a dead end. The content lives on the original; go there. * * @return void */ public function maybe_leave_merged_copy_screen() { if ( 'GET' !== strtoupper( isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) { return; } // A redirect based on nothing but the URL of a screen the user asked to see. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; $url = $post_id ? $this->stale_copy_redirect_url( $post_id ) : null; if ( null !== $url ) { wp_safe_redirect( $url ); exit; } } /** * Where a request for a merged copy's edit screen should land, if anywhere. * * Only a copy that is trashed AND marked merged redirects — that pair means "awaiting * deferred deletion". A restored copy is a live working copy again, and an ordinary * trashed post is none of this feature's business. * * @param int $post_id Post whose edit screen was requested. * @return string|null Edit URL of the original, or null to leave the request alone. */ public function stale_copy_redirect_url( $post_id ) { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || 'trash' !== $post->post_status ) { return null; } if ( '' === (string) get_post_meta( $post->ID, LiveDraftService::MERGED_META, true ) ) { return null; } $original_id = $this->service()->original_for( $post->ID ); if ( ! $original_id || ! get_post( $original_id ) instanceof \WP_Post || ! current_user_can( 'edit_post', $original_id ) ) { return null; } $url = get_edit_post_link( $original_id, 'raw' ); return $url ? $url : null; } /** * Deferred deletion of a merged copy, on the cron event the merge booked. * * @param int $draft_id Copy to delete. * @return void */ public function cleanup_merged_copy( $draft_id ) { $this->service()->cleanup( (int) $draft_id ); } /** * Editor-side behaviour, enqueued only where it applies. * * On a working copy: the script that leaves for the original's edit screen once a publish * save comes back carrying the merge signal. On an original that a merge just pointed a * user at: a one-time snackbar explaining the arrival. * * @return void */ public function enqueue_editor_assets() { $post = get_post(); if ( ! $post instanceof \WP_Post ) { return; } if ( $this->service()->is_live_draft( $post ) ) { wp_enqueue_script( 'adminkeep-live-draft-editor', ADMINKEEP_URL . 'assets/js/live-draft-editor.js', array( 'wp-data' ), \Adminkeep\Assets::version( 'assets/js/live-draft-editor.js' ), true ); return; } $key = self::landing_key( $post->ID ); if ( get_transient( $key ) ) { delete_transient( $key ); wp_add_inline_script( 'wp-edit-post', 'window.wp && wp.domReady && wp.domReady( function () {' . 'wp.data && wp.data.dispatch( "core/notices" ).createSuccessNotice( ' . wp_json_encode( __( 'Your changes were merged into this post.', 'adminkeep' ) ) . ', { type: "snackbar", id: "adminkeep-live-draft-merged" } );' . '} );' ); } } /** * Transient key for the one-time "merged" note on an original's edit screen. * * Per user as well as per post: the note belongs to whoever performed the merge, not to the * next colleague who happens to open the post. * * @param int $original_id Post that was merged into. * @return string */ private static function landing_key( $original_id ) { return 'adminkeep_live_draft_landed_' . (int) $original_id . '_' . get_current_user_id(); } /** * Mark a working copy in the list table so it is never mistaken for a stray draft. * * @param string[] $states Existing post states. * @param \WP_Post $post Post being listed. * @return string[] */ public function post_state( $states, $post ) { if ( $post instanceof \WP_Post && $this->service()->is_live_draft( $post ) ) { $states['adminkeep_live_draft'] = __( 'Working copy', 'adminkeep' ); } return $states; } /** * Cut drafts loose from an original that is going away. * * @param int $post_id Post being trashed or deleted. * @return void */ public function detach( $post_id ) { $this->service()->detach_drafts_for( $post_id ); } /** * Offer opening and merging a working copy to the Abilities API. * * Two abilities rather than one, because they are not the same kind of act. Opening is * additive and reversible — abandon the copy and nothing happened. Merging overwrites a page * that is live, which is annotated destructive so an agent that reads annotations knows to * ask first. * * @return array> */ public function abilities() { return array( 'open-live-draft' => array( 'label' => __( 'Open a live draft', 'adminkeep' ), 'description' => __( 'Creates an editable working copy of a published post. The published version stays online and unchanged until the copy is merged back.', 'adminkeep' ), 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer', 'description' => __( 'The published post to make a working copy of.', 'adminkeep' ), ), ), 'required' => array( 'post_id' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'draft_id' => array( 'type' => 'integer', 'description' => __( 'The working copy. Opening a second time returns the copy already in progress.', 'adminkeep' ), ), ), ), 'permission_callback' => array( $this, 'ability_can_open' ), 'execute_callback' => array( $this, 'ability_open' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true, ), ), ), 'merge-live-draft' => array( 'label' => __( 'Merge a live draft', 'adminkeep' ), 'description' => __( 'Writes a working copy back over the post it came from, keeping the same post ID and address, and removes the copy. All or nothing: if any part fails the published post is left exactly as it was.', 'adminkeep' ), 'input_schema' => array( 'type' => 'object', 'properties' => array( 'draft_id' => array( 'type' => 'integer', 'description' => __( 'The working copy to merge.', 'adminkeep' ), ), ), 'required' => array( 'draft_id' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer', 'description' => __( 'The published post that was updated.', 'adminkeep' ), ), ), ), 'permission_callback' => array( $this, 'ability_can_merge' ), 'execute_callback' => array( $this, 'ability_merge' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => true, 'idempotent' => false, ), ), ), ); } /** * Whether the current user may open a working copy of the requested post. * * @param array $input Ability input. * @return bool */ public function ability_can_open( $input = array() ) { $post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 ); if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) { return false; } $post_types = isset( $this->settings['post_types'] ) ? (array) $this->settings['post_types'] : array(); if ( ! in_array( $post->post_type, $post_types, true ) ) { return false; } return current_user_can( 'edit_post', $post->ID ); } /** * Open a working copy. * * @param array $input Ability input. * @return array|\WP_Error */ public function ability_open( $input = array() ) { $draft_id = $this->service()->open( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 ); if ( is_wp_error( $draft_id ) ) { return $draft_id; } return array( 'draft_id' => (int) $draft_id ); } /** * Whether the current user may merge the requested working copy. * * The capability is checked against the post being written to, not the copy — that is the * post that actually changes. * * @param array $input Ability input. * @return bool */ public function ability_can_merge( $input = array() ) { $draft = get_post( isset( $input['draft_id'] ) ? (int) $input['draft_id'] : 0 ); if ( ! $draft instanceof \WP_Post || ! $this->service()->is_live_draft( $draft ) ) { return false; } $original_id = $this->service()->original_for( $draft->ID ); return $original_id > 0 && current_user_can( 'edit_post', $original_id ); } /** * Merge a working copy back into its published post. * * @param array $input Ability input. * @return array|\WP_Error */ public function ability_merge( $input = array() ) { $draft_id = isset( $input['draft_id'] ) ? (int) $input['draft_id'] : 0; // Read the pointer before merging: a successful merge removes the working copy, and with // it the only link back to the post that was updated. $original_id = $this->service()->original_for( $draft_id ); $this->merging = true; $merged = $this->service()->merge( $draft_id ); $this->merging = false; if ( is_wp_error( $merged ) ) { return $merged; } return array( 'post_id' => (int) $original_id ); } /** * The service. * * @return LiveDraftService */ private function service() { if ( null === $this->service ) { $this->service = new LiveDraftService(); } return $this->service; } }