diff --git a/composer.json b/composer.json index 0c743040..caed252e 100644 --- a/composer.json +++ b/composer.json @@ -114,6 +114,9 @@ "post meta patch", "post meta pluck", "post meta update", + "post revision", + "post revision diff", + "post revision restore", "post term", "post term add", "post term list", diff --git a/entity-command.php b/entity-command.php index 0cecf202..cdad9235 100644 --- a/entity-command.php +++ b/entity-command.php @@ -46,6 +46,7 @@ ) ); WP_CLI::add_command( 'post meta', 'Post_Meta_Command' ); +WP_CLI::add_command( 'post revision', 'Post_Revision_Command' ); WP_CLI::add_command( 'post term', 'Post_Term_Command' ); WP_CLI::add_command( 'post-type', 'Post_Type_Command' ); WP_CLI::add_command( 'site', 'Site_Command' ); diff --git a/features/post-revision.feature b/features/post-revision.feature new file mode 100644 index 00000000..9a63289b --- /dev/null +++ b/features/post-revision.feature @@ -0,0 +1,151 @@ +Feature: Manage WordPress post revisions + + Background: + Given a WP install + + # Creating a published post doesn't create an initial revision, + # so we update it twice here and restore the middle version. + # See https://github.com/wp-cli/entity-command/issues/564. + Scenario: Restore a post revision + When I run `wp post create --post_title='Original Post' --post_content='Original content' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post update {POST_ID} --post_content='Updated content'` + Then STDOUT should contain: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=ids` + Then STDOUT should not be empty + And save STDOUT as {REVISION_ID} + + When I run `wp post update {POST_ID} --post_content='Another one'` + Then STDOUT should contain: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post get {POST_ID} --field=post_content` + Then STDOUT should contain: + """ + Another one + """ + + When I run `wp post revision restore {REVISION_ID}` + Then STDOUT should contain: + """ + Success: Restored revision + """ + + When I run `wp post get {POST_ID} --field=post_content` + Then STDOUT should contain: + """ + Updated content + """ + + Scenario: Restore invalid revision should fail + When I try `wp post revision restore 99999` + Then STDERR should contain: + """ + Error: Invalid revision ID + """ + And the return code should be 1 + + Scenario: Show diff between two revisions + When I run `wp post create --post_title='Test Post' --post_content='First version' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post update {POST_ID} --post_content='Second version'` + Then STDOUT should contain: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post update {POST_ID} --post_title='New Title' --post_content='Third version'` + Then STDOUT should contain: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC` + Then STDOUT should not be empty + And save STDOUT as {REVISION_IDS} + + When I run `echo "{REVISION_IDS}" | awk '{print $1}'` + Then save STDOUT as {REVISION_ID_1} + + When I run `echo "{REVISION_IDS}" | awk '{print $2}'` + Then save STDOUT as {REVISION_ID_2} + + When I run `wp post revision diff {REVISION_ID_1} {REVISION_ID_2}` + Then STDOUT should contain: + """ + - Second version + + Third version + """ + And STDOUT should contain: + """ + --- Test Post + """ + And STDOUT should contain: + """ + +++ New Title + """ + + Scenario: Show diff between revision and current post + When I run `wp post create --post_title='Diff Test' --post_content='Original text' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post update {POST_ID} --post_content='Modified text'` + Then STDOUT should contain: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC` + Then STDOUT should not be empty + And save STDOUT as {REVISION_ID} + + When I run `wp post revision diff {REVISION_ID}` + Then STDOUT should contain: + """ + Success: No difference found. + """ + + Scenario: Diff with invalid revision should fail + When I try `wp post revision diff 99999` + Then STDERR should contain: + """ + Error: Invalid 'from' ID + """ + And the return code should be 1 + + Scenario: Diff between two invalid revisions should fail + When I try `wp post revision diff 99998 99999` + Then STDERR should contain: + """ + Error: Invalid 'from' ID + """ + And the return code should be 1 + + Scenario: Diff with specific field + When I run `wp post create --post_title='Field Test' --post_content='Some content' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID} + + When I run `wp post update {POST_ID} --post_title='Modified Field Test'` + Then STDOUT should contain: + """ + Success: Updated post {POST_ID}. + """ + + When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC` + Then STDOUT should not be empty + And save STDOUT as {REVISION_ID} + + When I run `wp post revision diff {REVISION_ID} --field=post_title` + Then the return code should be 0 diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0df76141..6ab1a682 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -65,7 +65,7 @@ */src/Network_Meta_Command\.php$ */src/Network_Namespace\.php$ */src/Option_Command\.php$ - */src/Post(_Block|_Meta|_Term|_Type)?_Command\.php$ + */src/Post(_Block|_Meta|_Revision|_Term|_Type)?_Command\.php$ */src/Signup_Command\.php$ */src/Site(_Meta|_Option)?_Command\.php$ */src/Term(_Meta)?_Command\.php$ diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php new file mode 100644 index 00000000..71931831 --- /dev/null +++ b/src/Post_Revision_Command.php @@ -0,0 +1,248 @@ + + */ + private $valid_fields = [ + 'post_title', + 'post_content', + 'post_excerpt', + 'post_name', + 'post_status', + 'post_type', + 'post_author', + 'post_date', + 'post_date_gmt', + 'post_modified', + 'post_modified_gmt', + 'post_parent', + 'menu_order', + 'comment_status', + 'ping_status', + ]; + + /** + * Restores a post revision. + * + * ## OPTIONS + * + * + * : The revision ID to restore. + * + * ## EXAMPLES + * + * # Restore a post revision + * $ wp post revision restore 123 + * Success: Restored revision 123. + * + * @subcommand restore + */ + public function restore( $args ) { + $revision_id = (int) $args[0]; + + // Get the revision post + $revision = wp_get_post_revision( $revision_id ); + + if ( ! $revision ) { + WP_CLI::error( "Invalid revision ID {$revision_id}." ); + } + + // Restore the revision + $restored_post_id = wp_restore_post_revision( $revision_id ); + + // wp_restore_post_revision() returns post ID on success, false on failure, or null if revision is same as current + if ( false === $restored_post_id ) { + WP_CLI::error( "Failed to restore revision {$revision_id}." ); + } + + WP_CLI::success( "Restored revision {$revision_id}." ); + } + + /** + * Shows the difference between two revisions. + * + * ## OPTIONS + * + * + * : The 'from' revision ID or post ID. + * + * [] + * : The 'to' revision ID or post ID. If not provided, compares with the current post. + * + * [--field=] + * : Compare specific field(s). Default: post_content + * + * ## EXAMPLES + * + * # Show diff between two revisions + * $ wp post revision diff 123 456 + * + * # Show diff between a revision and the current post + * $ wp post revision diff 123 + * + * @subcommand diff + */ + public function diff( $args, $assoc_args ) { + $from_id = (int) $args[0]; + $to_id = isset( $args[1] ) ? (int) $args[1] : null; + $field = Utils\get_flag_value( $assoc_args, 'field', 'post_content' ); + + // Get the 'from' revision or post + $from_revision = wp_get_post_revision( $from_id ); + if ( ! $from_revision instanceof \WP_Post ) { + // Try as a regular post + $from_revision = get_post( $from_id ); + if ( ! $from_revision instanceof \WP_Post ) { + WP_CLI::error( "Invalid 'from' ID {$from_id}." ); + } + } + + // Get the 'to' revision or post + $to_revision = null; + if ( $to_id ) { + $to_revision = wp_get_post_revision( $to_id ); + if ( ! $to_revision instanceof \WP_Post ) { + // Try as a regular post + $to_revision = get_post( $to_id ); + if ( ! $to_revision instanceof \WP_Post ) { + WP_CLI::error( "Invalid 'to' ID {$to_id}." ); + } + } + } elseif ( 'revision' === $from_revision->post_type ) { + // If no 'to' ID provided, use the parent post of the revision + $to_revision = get_post( $from_revision->post_parent ); + if ( ! $to_revision instanceof \WP_Post ) { + WP_CLI::error( "Could not find parent post for revision {$from_id}." ); + } + } else { + WP_CLI::error( "Please provide a 'to' revision ID when comparing posts." ); + } + + // Validate field + if ( ! in_array( $field, $this->valid_fields, true ) ) { + WP_CLI::error( "Invalid field '{$field}'. Valid fields: " . implode( ', ', $this->valid_fields ) ); + } + + // Get the field values - use isset to check if field exists on the object + if ( ! isset( $from_revision->{$field} ) ) { + WP_CLI::error( "Field '{$field}' not found on post/revision {$from_id}." ); + } + + // $to_revision is guaranteed to be non-null at this point due to earlier validation + if ( ! isset( $to_revision->{$field} ) ) { + $to_error_id = $to_id ?? $to_revision->ID; + WP_CLI::error( "Field '{$field}' not found on revision/post {$to_error_id}." ); + } + + $left_string = $from_revision->{$field}; + $right_string = $to_revision->{$field}; + + // Split content into lines for diff + $left_lines = explode( "\n", $left_string ); + $right_lines = explode( "\n", $right_string ); + + if ( ! class_exists( 'Text_Diff', false ) ) { + // @phpstan-ignore constant.notFound + require ABSPATH . WPINC . '/wp-diff.php'; + } + + // Create Text_Diff object + $text_diff = new \Text_Diff( 'auto', [ $left_lines, $right_lines ] ); + + // Check if there are any changes + if ( 0 === $text_diff->countAddedLines() && 0 === $text_diff->countDeletedLines() ) { + WP_CLI::success( 'No difference found.' ); + return; + } + + // Display header + WP_CLI::line( + WP_CLI::colorize( + sprintf( + '%%y--- %s (%s) - ID %d%%n', + $from_revision->post_title, + $from_revision->post_modified, + $from_revision->ID + ) + ) + ); + WP_CLI::line( + WP_CLI::colorize( + sprintf( + '%%y+++ %s (%s) - ID %d%%n', + $to_revision->post_title, + $to_revision->post_modified, + $to_revision->ID + ) + ) + ); + WP_CLI::line( '' ); + + // Render the diff using CLI-friendly format + $this->render_cli_diff( $text_diff ); + } + + /** + * Renders a diff in CLI-friendly format with colors. + * + * @param \Text_Diff $diff The diff object to render. + */ + private function render_cli_diff( $diff ) { + $edits = $diff->getDiff(); + + foreach ( $edits as $edit ) { + switch ( get_class( $edit ) ) { + case 'Text_Diff_Op_copy': + // Unchanged lines - show in default color + foreach ( $edit->orig as $line ) { + WP_CLI::line( ' ' . $line ); + } + break; + + case 'Text_Diff_Op_add': + // Added lines - show in green + foreach ( $edit->final as $line ) { + WP_CLI::line( WP_CLI::colorize( '%g+ ' . $line . '%n' ) ); + } + break; + + case 'Text_Diff_Op_delete': + // Deleted lines - show in red + foreach ( $edit->orig as $line ) { + WP_CLI::line( WP_CLI::colorize( '%r- ' . $line . '%n' ) ); + } + break; + + case 'Text_Diff_Op_change': + // Changed lines - show deletions in red, additions in green + foreach ( $edit->orig as $line ) { + WP_CLI::line( WP_CLI::colorize( '%r- ' . $line . '%n' ) ); + } + foreach ( $edit->final as $line ) { + WP_CLI::line( WP_CLI::colorize( '%g+ ' . $line . '%n' ) ); + } + break; + } + } + } +}