post_status ) { return new \WP_Error( 'adminkeep_live_draft_not_published', __( 'Only a published post needs a working copy — edit an unpublished one directly.', 'adminkeep' ) ); } if ( ! current_user_can( 'edit_post', $original->ID ) ) { return new \WP_Error( 'adminkeep_live_draft_forbidden', __( 'You are not allowed to edit this post.', 'adminkeep' ) ); } // A second open returns the draft already in progress. Two working copies of one page is // a guaranteed lost edit — whichever merges second silently discards the other. $existing = $this->find_draft_for( $original->ID ); if ( null !== $existing ) { return $existing; } $draft_id = ( new PostDuplicator() )->duplicate( $original->ID, array( 'title_suffix' => '', 'status' => 'draft', ) ); if ( is_wp_error( $draft_id ) ) { return $draft_id; } add_post_meta( $draft_id, self::POINTER_META, $original->ID, true ); return $draft_id; } /** * Merge a working copy back into the post it came from. * * @param int $draft_id Draft to merge. * @return true|\WP_Error */ public function merge( $draft_id ) { $draft = get_post( $draft_id ); if ( ! $draft instanceof \WP_Post ) { return new \WP_Error( 'adminkeep_live_draft_not_found', __( 'That working copy no longer exists.', 'adminkeep' ) ); } $original_id = (int) get_post_meta( $draft->ID, self::POINTER_META, true ); $original = $original_id ? get_post( $original_id ) : null; if ( ! $original instanceof \WP_Post ) { return new \WP_Error( 'adminkeep_live_draft_orphaned', __( 'The post this copy belonged to no longer exists.', 'adminkeep' ) ); } if ( ! current_user_can( 'edit_post', $original->ID ) ) { return new \WP_Error( 'adminkeep_live_draft_forbidden', __( 'You are not allowed to edit this post.', 'adminkeep' ) ); } // Captured before anything is written, so a failure can put the page back exactly. $snapshot = $this->snapshot( $original->ID ); // A revision of what is being replaced, so the pre-merge page is recoverable from the // editor afterwards. Taken before the write, so it holds the old content. wp_save_post_revision( $original->ID ); try { $this->write_content( $draft, $original ); /** * Fires mid-merge, after the content is written and before terms are. * * A callback that throws aborts the merge and the original is restored exactly as it * was. This is the seam the all-or-nothing test uses, and the one an integration * would use to veto a merge. * * @param int $original_id Post being merged into. * @param int $draft_id Working copy being merged. */ do_action( 'adminkeep_live_draft_merging', $original->ID, $draft->ID ); $this->write_meta( $draft, $original ); $this->write_terms( $draft, $original ); } catch ( \Exception $e ) { $this->restore( $original->ID, $snapshot ); return new \WP_Error( 'adminkeep_live_draft_merge_failed', $e->getMessage() ); } // The copy is trashed here, not deleted. This method runs in the middle of the block // editor's publish request, and the editor is not done with this id: core prepares the // REST response for it next, and an editor with metaboxes sends a second save for it // moments later — the request that used to fatal on a deleted row. In the trash the copy // is invisible everywhere that matters, still present for those stragglers, and // hard-deleted shortly by the scheduled cleanup — with core's own trash purge as the // backstop if cron never fires. update_post_meta( $draft->ID, self::MERGED_META, time() ); if ( wp_trash_post( $draft->ID ) ) { wp_schedule_single_event( time() + 10 * MINUTE_IN_SECONDS, self::CLEANUP_EVENT, array( $draft->ID ) ); } elseif ( get_post( $draft->ID ) instanceof \WP_Post ) { // Trashing can be vetoed (pre_trash_post). A copy left published is a second live // page — the one outcome this feature must never produce — so fall back to the old // immediate delete and accept the rough edge on this request. On a site with // EMPTY_TRASH_DAYS=0, wp_trash_post itself already hard-deleted, and this branch is a no-op. wp_delete_post( $draft->ID, true ); } /** * Fires after a working copy has been merged and taken out of circulation. * * The copy is normally in the trash at this point, awaiting deferred deletion. * * @param int $original_id Post that was updated. * @param int $draft_id Working copy that was merged and removed. */ do_action( 'adminkeep_live_draft_merged', $original->ID, $draft->ID ); return true; } /** * Throw away a working copy without merging it. * * @param int $draft_id Draft to discard. * @return void */ public function abandon( $draft_id ) { $draft = get_post( $draft_id ); if ( ! $draft instanceof \WP_Post || ! $this->is_live_draft( $draft ) ) { return; } wp_delete_post( $draft->ID, true ); } /** * Hard-delete a merged copy that has served out its grace period. * * Only a copy still in the trash and still carrying the merged marker is deleted. A copy * someone restored from the trash is theirs again — it is an ordinary working copy and none * of cleanup's business. * * @param int $draft_id Copy to delete. * @return void */ public function cleanup( $draft_id ) { $draft = get_post( $draft_id ); if ( ! $draft instanceof \WP_Post || 'trash' !== $draft->post_status ) { return; } if ( '' === (string) get_post_meta( $draft->ID, self::MERGED_META, true ) ) { return; } wp_delete_post( $draft->ID, true ); } /** * Carry meta written to a merged copy after its merge over to the original. * * The block editor's metabox save (Yoast and friends) posts against the copy's id right * after the publish request that merged it — so those last-second values land on the trashed * copy, one save too late for the merge itself. This brings them across, minus the trash * bookkeeping and merge plumbing. * * @param int $draft_id Merged, trashed copy that just received a save. * @return void */ public function sync_late_meta( $draft_id ) { $draft = get_post( $draft_id ); if ( ! $draft instanceof \WP_Post || '' === (string) get_post_meta( $draft->ID, self::MERGED_META, true ) ) { return; } $original = get_post( $this->original_for( $draft->ID ) ); if ( ! $original instanceof \WP_Post || ! current_user_can( 'edit_post', $original->ID ) ) { return; } $this->write_meta( $draft, $original, self::SYNC_EXCLUDED_META ); } /** * Cut a draft loose from an original that is going away. * * Called when the original is trashed or deleted. The draft survives as an ordinary draft * rather than being destroyed — someone wrote that text — but it no longer points anywhere, * so it can never merge into a post that no longer exists. * * @param int $original_id Post being trashed or deleted. * @return void */ public function detach_drafts_for( $original_id ) { $draft_id = $this->find_draft_for( $original_id ); if ( null !== $draft_id ) { delete_post_meta( $draft_id, self::POINTER_META ); } } /** * Whether a post is a working copy. * * @param \WP_Post $post Post to check. * @return bool */ public function is_live_draft( \WP_Post $post ) { return '' !== (string) get_post_meta( $post->ID, self::POINTER_META, true ); } /** * The post a working copy will merge into. * * @param int $draft_id Draft id. * @return int Original id, or 0. */ public function original_for( $draft_id ) { return (int) get_post_meta( $draft_id, self::POINTER_META, true ); } /** * The working copy already open for a post, if any. * * @param int $original_id Original post id. * @return int|null */ public function find_draft_for( $original_id ) { $found = get_posts( array( 'post_type' => 'any', 'post_status' => array( 'draft', 'pending' ), 'meta_key' => self::POINTER_META, // phpcs:ignore WordPress.DB.SlowDBQuery -- Indexed lookup by pointer is the only way to find the pair. 'meta_value' => (int) $original_id, // phpcs:ignore WordPress.DB.SlowDBQuery 'posts_per_page' => 1, 'fields' => 'ids', 'suppress_filters' => false, 'no_found_rows' => true, ) ); return empty( $found ) ? null : (int) $found[0]; } /** * Write the draft's editorial fields onto the original. * * Deliberately narrow. post_name, post_date, post_status, post_author, post_parent and * menu_order are the original's identity and placement — merging those would change the * address, which is the one thing this feature exists to protect. * * @param \WP_Post $draft Working copy. * @param \WP_Post $original Post being merged into. * @return void * @throws \RuntimeException If the update fails. */ private function write_content( \WP_Post $draft, \WP_Post $original ) { $result = wp_update_post( wp_slash( array( 'ID' => $original->ID, 'post_title' => $draft->post_title, 'post_content' => $draft->post_content, 'post_content_filtered' => $draft->post_content_filtered, 'post_excerpt' => $draft->post_excerpt, ) ), true ); if ( is_wp_error( $result ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Caught in merge() and returned as a WP_Error; escaping here would double-escape at render. throw new \RuntimeException( $result->get_error_message() ); } } /** * Replace the original's meta with the draft's. * * @param \WP_Post $draft Working copy. * @param \WP_Post $original Post being merged into. * @param string[] $except Additional draft meta keys to leave behind. * @return void */ private function write_meta( \WP_Post $draft, \WP_Post $original, array $except = array() ) { global $wpdb; $preserved = $this->raw_meta_rows( $original->ID, self::PRESERVED_META ); $incoming = $this->raw_meta_rows( $draft->ID, array(), array_merge( array( self::POINTER_META ), $except ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery -- Raw rows are written back verbatim; meta_key/meta_value here are column names, not query arguments. $wpdb->delete( $wpdb->postmeta, array( 'post_id' => $original->ID ), array( '%d' ) ); foreach ( array_merge( $preserved, $incoming ) as $row ) { $wpdb->insert( $wpdb->postmeta, array( 'post_id' => $original->ID, 'meta_key' => $row['meta_key'], 'meta_value' => $row['meta_value'], ), array( '%d', '%s', '%s' ) ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery wp_cache_delete( $original->ID, 'post_meta' ); } /** * Replace the original's terms with the draft's. * * @param \WP_Post $draft Working copy. * @param \WP_Post $original Post being merged into. * @return void */ private function write_terms( \WP_Post $draft, \WP_Post $original ) { foreach ( get_object_taxonomies( $original->post_type ) as $taxonomy ) { $terms = wp_get_object_terms( $draft->ID, $taxonomy, array( 'fields' => 'ids' ) ); if ( is_wp_error( $terms ) ) { continue; } wp_set_object_terms( $original->ID, array_map( 'intval', $terms ), $taxonomy, false ); } } /** * Raw meta rows for a post. * * Raw, because these are written straight back with $wpdb->insert() and must not be unslashed * or re-serialized on the way through. * * @param int $post_id Post id. * @param string[] $only If non-empty, only these keys. * @param string[] $except Keys to exclude. * @return array[] */ private function raw_meta_rows( $post_id, array $only = array(), array $except = array() ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d ORDER BY meta_id ASC", $post_id ), ARRAY_A ); if ( empty( $rows ) ) { return array(); } return array_values( array_filter( $rows, function ( $row ) use ( $only, $except ) { if ( in_array( $row['meta_key'], $except, true ) ) { return false; } return empty( $only ) || in_array( $row['meta_key'], $only, true ); } ) ); } /** * Capture everything a merge could damage. * * @param int $post_id Post id. * @return array */ private function snapshot( $post_id ) { global $wpdb; $terms = array(); foreach ( get_object_taxonomies( get_post_type( $post_id ) ) as $taxonomy ) { $ids = wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) ); $terms[ $taxonomy ] = is_wp_error( $ids ) ? array() : array_map( 'intval', $ids ); } return array( 'post' => $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID = %d", $post_id ), ARRAY_A ), 'meta' => $this->raw_meta_rows( $post_id ), 'terms' => $terms, ); } /** * Put a post back exactly as the snapshot found it. * * @param int $post_id Post id. * @param array $snapshot Snapshot from snapshot(). * @return void */ private function restore( $post_id, array $snapshot ) { global $wpdb; if ( ! empty( $snapshot['post'] ) ) { $row = $snapshot['post']; unset( $row['ID'] ); $wpdb->update( $wpdb->posts, $row, array( 'ID' => $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery -- Restoring raw rows verbatim; meta_key/meta_value here are column names, not query arguments. $wpdb->delete( $wpdb->postmeta, array( 'post_id' => $post_id ), array( '%d' ) ); foreach ( $snapshot['meta'] as $meta_row ) { $wpdb->insert( $wpdb->postmeta, array( 'post_id' => $post_id, 'meta_key' => $meta_row['meta_key'], 'meta_value' => $meta_row['meta_value'], ), array( '%d', '%s', '%s' ) ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery foreach ( $snapshot['terms'] as $taxonomy => $term_ids ) { wp_set_object_terms( $post_id, $term_ids, $taxonomy, false ); } clean_post_cache( $post_id ); wp_cache_delete( $post_id, 'post_meta' ); } }