user_can_duplicate( $post ) ) { return new \WP_Error( 'adminkeep_duplicate_forbidden', __( 'You are not allowed to duplicate this post.', 'adminkeep' ) ); } $args = array_merge( array( 'title_suffix' => ' (copy)', 'status' => 'draft', 'with_children' => false, 'parent' => null, ), $args ); $copy_id = $this->insert_copy( $post, $args ); if ( is_wp_error( $copy_id ) ) { return $copy_id; } $this->copy_meta( $post->ID, $copy_id ); $this->copy_terms( $post, $copy_id ); if ( $args['with_children'] && is_post_type_hierarchical( $post->post_type ) ) { $this->copy_children( $post, $copy_id, $args ); } /** * Fires after a post has been duplicated. * * @param int $copy_id New post id. * @param \WP_Post $post The post that was copied. * @param array $args Resolved arguments. */ do_action( 'adminkeep_post_duplicated', $copy_id, $post, $args ); return $copy_id; } /** * Whether the current user may duplicate this post. * * Two checks, because duplication is both a read of the original and a creation of a new * post: the user must be able to edit the source, and to create posts of its type. * * @param \WP_Post $post Source post. * @return bool */ public function user_can_duplicate( \WP_Post $post ) { $type = get_post_type_object( $post->post_type ); if ( null === $type ) { return false; } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return false; } $create = isset( $type->cap->create_posts ) ? $type->cap->create_posts : 'edit_posts'; return current_user_can( $create ); } /** * Create the copy row itself. * * The post_name field is deliberately absent: letting WordPress derive a fresh slug from the title * is what keeps the original's slug byte-identical. Handing the original's slug to * wp_insert_post() is how the incumbents end up renaming the post being copied. * * @param \WP_Post $post Source post. * @param array $args Resolved arguments. * @return int|\WP_Error */ private function insert_copy( \WP_Post $post, array $args ) { $data = array( 'post_title' => $post->post_title . $args['title_suffix'], 'post_content' => $post->post_content, 'post_content_filtered' => $post->post_content_filtered, 'post_excerpt' => $post->post_excerpt, 'post_type' => $post->post_type, 'post_status' => $args['status'], 'post_author' => $post->post_author, 'post_parent' => null === $args['parent'] ? $post->post_parent : (int) $args['parent'], 'menu_order' => $post->menu_order, 'comment_status' => $post->comment_status, 'ping_status' => $post->ping_status, 'post_password' => $post->post_password, 'to_ping' => $post->to_ping, 'pinged' => $post->pinged, ); // See the slashing rule in this class's docblock. Without this, every backslash in the // content is eaten and serialized payloads in post_content_filtered break. return wp_insert_post( wp_slash( $data ), true ); } /** * Copy every meta row. * * Read raw from the database rather than through get_post_meta(), for two reasons: ordering * of multi-value keys is preserved by meta_id, and the raw string is what we want to * reproduce. A naive update_post_meta() loop collapses three values into one. * * @param int $from_id Source post id. * @param int $to_id Destination post id. * @return void */ private function copy_meta( $from_id, $to_id ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Raw read is the point; see docblock. $wpdb->prepare( "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d ORDER BY meta_id ASC", $from_id ) ); if ( empty( $rows ) ) { return; } /** * Meta keys to leave behind when duplicating. * * @param string[] $skipped Meta keys. * @param int $from_id Source post id. * @param int $to_id Destination post id. */ $skipped = apply_filters( 'adminkeep_duplicate_skip_meta', self::SKIPPED_META, $from_id, $to_id ); $skipped = is_array( $skipped ) ? $skipped : self::SKIPPED_META; foreach ( $rows as $row ) { if ( in_array( $row->meta_key, $skipped, true ) ) { continue; } // maybe_unserialize then wp_slash then add_post_meta's internal unslash and // maybe_serialize round-trips back to the identical stored string. add_post_meta( $to_id, $row->meta_key, wp_slash( maybe_unserialize( $row->meta_value ) ) ); } } /** * Copy every term in every taxonomy attached to the post type. * * @param \WP_Post $post Source post. * @param int $copy_id Destination post id. * @return void */ private function copy_terms( \WP_Post $post, $copy_id ) { foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) { $terms = wp_get_object_terms( $post->ID, $taxonomy, array( 'fields' => 'ids' ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { continue; } wp_set_object_terms( $copy_id, array_map( 'intval', $terms ), $taxonomy, false ); } } /** * Recursively copy hierarchical descendants under the new parent. * * @param \WP_Post $post Source post. * @param int $copy_id Destination post id. * @param array $args Resolved arguments. * @return void */ private function copy_children( \WP_Post $post, $copy_id, array $args ) { $children = get_children( array( 'post_parent' => $post->ID, 'post_type' => $post->post_type, 'post_status' => 'any', 'numberposts' => -1, 'orderby' => 'menu_order', 'order' => 'ASC', ) ); foreach ( $children as $child ) { $this->duplicate( $child->ID, array_merge( $args, array( // Descendants keep their own titles; only the top copy is marked. 'title_suffix' => '', 'parent' => $copy_id, ) ) ); } } }