array]` or `['error' => array]`. */ public function run( array $input, string $phase ): array { $prepared = $this->prepare_source( $input ); if ( isset( $prepared['error'] ) ) { return $prepared; } $title = $this->clone_title( $input, $prepared['post'] ); if ( isset( $title['error'] ) ) { return $title; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } return $this->clone_post( $prepared['post'], $title['title'] ); } /** * Validate and load the source post. */ private function prepare_source( array $input ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; $id = $this->source_post_id( $target ); if ( $id <= 0 ) { return $this->error( 'invalid_target', 'wp.posts.clone requires target.source_post_id.', array( 'input', 'target', 'source_post_id' ) ); } $post = get_post( $id ); if ( null === $post ) { return $this->error( 'not_found', 'No source post exists for the requested id.', array( 'input', 'target', 'source_post_id' ) ); } $post_type = (string) $post->post_type; if ( ! $this->is_cloneable_post( $post ) ) { return $this->error( 'invalid_target', 'The source post type or status is not cloneable.', array( 'input', 'target', 'source_post_id' ) ); } $create_capability = $this->post_type_create_capability( $post_type ); if ( ! current_user_can( $create_capability ) ) { return $this->error( 'permission_denied', 'The current user cannot create posts of the source post type.', array( 'input', 'target', 'source_post_id' ), array( 'layer' => 'wp_capability', 'capability' => $create_capability, ) ); } return array( 'post' => $post ); } /** * Return the target source post id. */ private function source_post_id( ?array $target ): int { if ( null === $target ) { return 0; } foreach ( array( 'source_post_id', 'id', 'post_id' ) as $key ) { if ( isset( $target[ $key ] ) && is_numeric( $target[ $key ] ) ) { return max( 0, (int) $target[ $key ] ); } } return 0; } /** * Return whether the source is a normal content post. */ private function is_cloneable_post( object $post ): bool { $post_type = (string) $post->post_type; if ( in_array( $post_type, self::NON_CLONEABLE_POST_TYPES, true ) ) { return false; } if ( 'auto-draft' === (string) $post->post_status ) { return false; } if ( function_exists( 'get_post_type_object' ) && null === get_post_type_object( $post_type ) ) { return false; } return true; } /** * Resolve the clone title. */ private function clone_title( array $input, object $post ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : array(); if ( array() === $values ) { return array( 'title' => (string) $post->post_title . ' (copy)' ); } foreach ( array_keys( $values ) as $key ) { if ( 'title' !== (string) $key ) { return $this->error( 'invalid_command', 'wp.posts.clone values may contain only title.', array( 'input', 'values', (string) $key ) ); } } if ( ! array_key_exists( 'title', $values ) ) { return $this->error( 'invalid_command', 'wp.posts.clone values may contain only title.', array( 'input', 'values' ) ); } $title = $values['title']; if ( is_array( $title ) && isset( $title['raw'] ) && is_scalar( $title['raw'] ) ) { $title = (string) $title['raw']; } if ( ! is_string( $title ) || '' === trim( $title ) ) { return $this->error( 'invalid_value', 'wp.posts.clone title must be a non-empty string.', array( 'input', 'values', 'title' ) ); } return array( 'title' => trim( $title ) ); } /** * Create the draft clone and copy trusted source state. */ private function clone_post( object $source, string $title ): array { $postarr = array( 'post_type' => (string) $source->post_type, 'post_status' => 'draft', 'post_title' => $title, 'post_excerpt' => (string) $source->post_excerpt, 'post_content' => (string) $source->post_content, 'post_parent' => (int) ( $source->post_parent ?? 0 ), 'menu_order' => (int) ( $source->menu_order ?? 0 ), 'comment_status' => (string) ( $source->comment_status ?? 'open' ), 'ping_status' => (string) ( $source->ping_status ?? 'open' ), ); $new_id = wp_insert_post( $postarr, true ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $new_id ) ) { return $this->error( 'execution_failed', $new_id->get_error_message() ); } if ( ! is_numeric( $new_id ) || (int) $new_id <= 0 ) { return $this->error( 'execution_failed', 'WordPress failed to clone the post.' ); } $new_id = (int) $new_id; $meta = $this->copy_meta( (int) $source->ID, $new_id ); if ( isset( $meta['error'] ) ) { $this->delete_partial_clone( $new_id ); return $meta; } $terms = $this->copy_terms( (int) $source->ID, $new_id, (string) $source->post_type ); if ( isset( $terms['error'] ) ) { $this->delete_partial_clone( $new_id ); return $terms; } $fresh = get_post( $new_id ); return array( 'data' => array( 'cloned' => true, 'source' => $this->target_data( (int) $source->ID, (string) $source->post_type ), 'target' => $this->target_data( $new_id, (string) $source->post_type ), 'document' => array( 'title' => array( 'raw' => is_object( $fresh ) ? (string) $fresh->post_title : $title ), 'status' => is_object( $fresh ) ? (string) $fresh->post_status : 'draft', ), 'facts' => array( 'content_hash' => PostsGetCapability::content_hash( is_object( $fresh ) ? (string) $fresh->post_content : (string) $source->post_content ), ), ), ); } /** * Copy all non-transient meta entries to the clone. */ private function copy_meta( int $source_id, int $target_id ): array { $meta = get_post_meta( $source_id ); if ( ! is_array( $meta ) ) { return array( 'data' => array( 'valid' => true ) ); } foreach ( $meta as $key => $values ) { $key = (string) $key; if ( in_array( $key, self::EXCLUDED_META_KEYS, true ) ) { continue; } $values = is_array( $values ) ? array_values( $values ) : array( $values ); foreach ( $values as $value ) { $value = $this->meta_value_for_copy( $value ); if ( function_exists( 'add_post_meta' ) ) { $written = add_post_meta( $target_id, $key, $value ); } else { $written = update_post_meta( $target_id, $key, $value ); } if ( false === $written ) { return $this->error( 'execution_failed', 'WordPress failed to copy cloned post meta.', array( 'input', 'values', 'meta', $key ) ); } } } return array( 'data' => array( 'valid' => true ) ); } /** * Normalize structured meta values before writing them to the clone. */ private function meta_value_for_copy( mixed $value ): mixed { if ( function_exists( 'maybe_unserialize' ) ) { return maybe_unserialize( $value ); } if ( ! is_string( $value ) || ! $this->looks_serialized( $value ) ) { return $value; } $unserialized = @unserialize( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize if ( false === $unserialized && 'b:0;' !== $value ) { return $value; } return $unserialized; } /** * Return whether a string has a serialized PHP value shape. */ private function looks_serialized( string $value ): bool { $value = trim( $value ); if ( 'N;' === $value ) { return true; } return 1 === preg_match( '/^(?:a|O|s|i|d|b):/', $value ); } /** * Copy all assigned taxonomy terms to the clone. */ private function copy_terms( int $source_id, int $target_id, string $post_type ): array { if ( ! function_exists( 'get_object_taxonomies' ) || ! function_exists( 'wp_get_post_terms' ) || ! function_exists( 'wp_set_post_terms' ) ) { return array( 'data' => array( 'valid' => true ) ); } $taxonomies = get_object_taxonomies( $post_type, 'names' ); if ( ! is_array( $taxonomies ) ) { return array( 'data' => array( 'valid' => true ) ); } foreach ( $taxonomies as $taxonomy ) { $terms = wp_get_post_terms( $source_id, (string) $taxonomy, array( 'fields' => 'ids' ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $terms ) ) { return $this->error( 'execution_failed', $terms->get_error_message() ); } if ( ! is_array( $terms ) || array() === $terms ) { continue; } $result = wp_set_post_terms( $target_id, array_values( array_map( 'intval', $terms ) ), (string) $taxonomy, false ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) { return $this->error( 'execution_failed', $result->get_error_message() ); } if ( false === $result ) { return $this->error( 'execution_failed', 'WordPress failed to assign cloned post terms.' ); } } return array( 'data' => array( 'valid' => true ) ); } /** * Resolve the create capability for a post type. */ private function post_type_create_capability( string $post_type ): string { if ( function_exists( 'get_post_type_object' ) ) { $post_type_object = get_post_type_object( $post_type ); if ( is_object( $post_type_object ) && isset( $post_type_object->cap ) ) { return (string) ( $post_type_object->cap->create_posts ?? $post_type_object->cap->edit_posts ?? 'edit_posts' ); } } return 'page' === $post_type ? 'edit_pages' : 'edit_posts'; } /** * Remove a draft clone after a post-inserted copy step fails. */ private function delete_partial_clone( int $post_id ): void { if ( function_exists( 'wp_delete_post' ) ) { wp_delete_post( $post_id, true ); } } /** * Build a standard posts target identity. */ private function target_data( int $post_id, string $post_type ): array { return array( 'resource' => 'posts', 'id' => $post_id, 'post_type' => $post_type, ); } /** * Build a handler error. */ private function error( string $code, string $message, array $path = array(), array $details = array() ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( array() !== $path ) { $error['path'] = $path; } if ( array() !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }