diff --git a/features/comment.feature b/features/comment.feature index 290954e02..483847a8e 100644 --- a/features/comment.feature +++ b/features/comment.feature @@ -472,3 +472,32 @@ Feature: Manage WordPress comments And I run `wp comment unspam {COMMENT_ID} --url=www.example.com` And I run `wp comment trash {COMMENT_ID} --url=www.example.com` And I run `wp comment untrash {COMMENT_ID} --url=www.example.com` + + Scenario: Delete comments using ID ranges + Given a WP install + + When I run `wp comment create --comment_post_ID=1 --comment_content='Comment A' --comment_author='A' --porcelain` + Then STDOUT should be a number + And save STDOUT as {COMMENT_ID_1} + + When I run `wp comment create --comment_post_ID=1 --comment_content='Comment B' --comment_author='B' --porcelain` + Then STDOUT should be a number + And save STDOUT as {COMMENT_ID_2} + + When I run `wp comment create --comment_post_ID=1 --comment_content='Comment C' --comment_author='C' --porcelain` + Then STDOUT should be a number + And save STDOUT as {COMMENT_ID_3} + + When I run `wp comment delete {COMMENT_ID_1}-{COMMENT_ID_3} --force` + Then STDOUT should contain: + """ + Deleted comment {COMMENT_ID_1}. + """ + And STDOUT should contain: + """ + Deleted comment {COMMENT_ID_2}. + """ + And STDOUT should contain: + """ + Deleted comment {COMMENT_ID_3}. + """ diff --git a/features/post.feature b/features/post.feature index c480a6f93..3826aaeea 100644 --- a/features/post.feature +++ b/features/post.feature @@ -602,3 +602,61 @@ Feature: Manage WordPress posts """ {"block_version":1} """ + + Scenario: Delete posts using ID ranges + Given a WP install + + When I run `wp post create --post_title='Post A' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_1} + + When I run `wp post create --post_title='Post B' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_2} + + When I run `wp post create --post_title='Post C' --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_3} + + When I run `wp post delete {POST_ID_1}-{POST_ID_3} --force` + Then STDOUT should contain: + """ + Deleted post {POST_ID_1}. + """ + And STDOUT should contain: + """ + Deleted post {POST_ID_2}. + """ + And STDOUT should contain: + """ + Deleted post {POST_ID_3}. + """ + + Scenario: Update posts using ID ranges + Given a WP install + + When I run `wp post create --post_title='Post A' --post_status=draft --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_1} + + When I run `wp post create --post_title='Post B' --post_status=draft --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_2} + + When I run `wp post create --post_title='Post C' --post_status=draft --porcelain` + Then STDOUT should be a number + And save STDOUT as {POST_ID_3} + + When I run `wp post update {POST_ID_1}-{POST_ID_3} --post_status=publish` + Then STDOUT should contain: + """ + Updated post {POST_ID_1}. + """ + And STDOUT should contain: + """ + Updated post {POST_ID_2}. + """ + And STDOUT should contain: + """ + Updated post {POST_ID_3}. + """ diff --git a/features/term.feature b/features/term.feature index 574d11219..c96b4498f 100644 --- a/features/term.feature +++ b/features/term.feature @@ -298,3 +298,32 @@ Feature: Manage WordPress terms "term_group":0 }] """ + + Scenario: Delete terms using ID ranges + Given a WP install + + When I run `wp term create category 'Range Term A' --porcelain` + Then STDOUT should be a number + And save STDOUT as {TERM_ID_1} + + When I run `wp term create category 'Range Term B' --porcelain` + Then STDOUT should be a number + And save STDOUT as {TERM_ID_2} + + When I run `wp term create category 'Range Term C' --porcelain` + Then STDOUT should be a number + And save STDOUT as {TERM_ID_3} + + When I run `wp term delete category {TERM_ID_1}-{TERM_ID_3}` + Then STDOUT should contain: + """ + Deleted category {TERM_ID_1}. + """ + And STDOUT should contain: + """ + Deleted category {TERM_ID_2}. + """ + And STDOUT should contain: + """ + Deleted category {TERM_ID_3}. + """ diff --git a/features/user.feature b/features/user.feature index cd22acdd6..46430f053 100644 --- a/features/user.feature +++ b/features/user.feature @@ -774,3 +774,32 @@ Feature: Manage WordPress users """ newtestuser """ + + Scenario: Delete users using ID ranges + Given a WP install + + When I run `wp user create testrange1 testrange1@example.com --role=subscriber --porcelain` + Then STDOUT should be a number + And save STDOUT as {USER_ID_1} + + When I run `wp user create testrange2 testrange2@example.com --role=subscriber --porcelain` + Then STDOUT should be a number + And save STDOUT as {USER_ID_2} + + When I run `wp user create testrange3 testrange3@example.com --role=subscriber --porcelain` + Then STDOUT should be a number + And save STDOUT as {USER_ID_3} + + When I run `wp user delete {USER_ID_1}-{USER_ID_3} --yes` + Then STDOUT should contain: + """ + Removed user {USER_ID_1} + """ + And STDOUT should contain: + """ + Removed user {USER_ID_2} + """ + And STDOUT should contain: + """ + Removed user {USER_ID_3} + """ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 5edb5334a..4879736fe 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -56,6 +56,7 @@ */src/WP_CLI/Fetchers/(Comment|Post|Signup|Site|User)\.php$ */src/WP_CLI/CommandWith(DBObject|Meta|Terms)\.php$ + */src/WP_CLI/ExpandsIdRanges\.php$ diff --git a/src/Comment_Command.php b/src/Comment_Command.php index 3b1578017..ba32097cd 100644 --- a/src/Comment_Command.php +++ b/src/Comment_Command.php @@ -150,6 +150,7 @@ function ( $params ) { * @param array $assoc_args Associative arguments. */ public function update( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); $assoc_args = wp_slash( $assoc_args ); parent::_update( $args, @@ -486,6 +487,7 @@ function ( $comment ) { * Success: Deleted comment 2341. */ public function delete( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); parent::_delete( $args, $assoc_args, @@ -556,6 +558,7 @@ private function check_server_name() { * Success: Trashed comment 1337. */ public function trash( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); foreach ( $args as $id ) { $this->call( $id, __FUNCTION__, 'Trashed %s.', 'Failed trashing %s.' ); } @@ -577,6 +580,7 @@ public function trash( $args, $assoc_args ) { */ public function untrash( $args, $assoc_args ) { $this->check_server_name(); + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); foreach ( $args as $id ) { $this->call( $id, __FUNCTION__, 'Untrashed %s.', 'Failed untrashing %s.' ); } @@ -597,6 +601,7 @@ public function untrash( $args, $assoc_args ) { * Success: Marked as spam comment 1337. */ public function spam( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); foreach ( $args as $id ) { $this->call( $id, __FUNCTION__, 'Marked %s as spam.', 'Failed marking %s as spam.' ); } @@ -618,6 +623,7 @@ public function spam( $args, $assoc_args ) { */ public function unspam( $args, $assoc_args ) { $this->check_server_name(); + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); foreach ( $args as $id ) { $this->call( $id, __FUNCTION__, 'Unspammed %s.', 'Failed unspamming %s.' ); } @@ -639,6 +645,7 @@ public function unspam( $args, $assoc_args ) { */ public function approve( $args, $assoc_args ) { $this->check_server_name(); + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); foreach ( $args as $id ) { $this->set_status( $id, 'approve', 'Approved' ); } @@ -660,6 +667,7 @@ public function approve( $args, $assoc_args ) { */ public function unapprove( $args, $assoc_args ) { $this->check_server_name(); + $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] ); foreach ( $args as $id ) { $this->set_status( $id, 'hold', 'Unapproved' ); } @@ -786,4 +794,27 @@ public function exists( $args ) { WP_CLI::success( "Comment with ID {$args[0]} exists." ); } } + + /** + * Returns existing comment IDs within the given range. + * + * @param int $start Start of the ID range (inclusive). + * @param int|null $end End of the ID range (inclusive), or null for no upper bound. + * @return int[] List of existing comment IDs. + */ + protected function get_comment_ids_in_range( int $start, ?int $end ): array { + global $wpdb; + + if ( null === $end ) { + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_ID >= %d ORDER BY comment_ID ASC", $start ) ) + ); + } + + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_ID BETWEEN %d AND %d ORDER BY comment_ID ASC", $start, $end ) ) + ); + } } diff --git a/src/Post_Command.php b/src/Post_Command.php index 5e10c546b..d8c3577b9 100644 --- a/src/Post_Command.php +++ b/src/Post_Command.php @@ -371,6 +371,7 @@ function ( $params ) { * Success: Updated post 456. */ public function update( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_post_ids_in_range' ] ); foreach ( $args as $key => $arg ) { if ( is_numeric( $arg ) ) { @@ -561,6 +562,7 @@ public function get( $args, $assoc_args ) { * Success: Deleted post 1294. */ public function delete( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_post_ids_in_range' ] ); $defaults = [ 'force' => false ]; $assoc_args = array_merge( $defaults, $assoc_args ); @@ -1241,4 +1243,27 @@ private function maybe_convert_hyphenated_date_format( $date_string ) { } return $date_string; } + + /** + * Returns existing post IDs within the given range. + * + * @param int $start Start of the ID range (inclusive). + * @param int|null $end End of the ID range (inclusive), or null for no upper bound. + * @return int[] List of existing post IDs. + */ + protected function get_post_ids_in_range( int $start, ?int $end ): array { + global $wpdb; + + if ( null === $end ) { + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID >= %d ORDER BY ID ASC", $start ) ) + ); + } + + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID BETWEEN %d AND %d ORDER BY ID ASC", $start, $end ) ) + ); + } } diff --git a/src/Site_Command.php b/src/Site_Command.php index 11c0d6ebf..b2234eeb8 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -1463,7 +1463,7 @@ private function get_sites_ids( $args, $assoc_args ) { return [ $blog_id ]; } - return $args; + return self::expand_id_ranges( $args, [ $this, 'get_site_ids_in_range' ] ); } /** @@ -1482,4 +1482,27 @@ private function check_site_ids_and_slug( $args, $assoc_args ) { return true; } + + /** + * Returns existing site IDs within the given range. + * + * @param int $start Start of the ID range (inclusive). + * @param int|null $end End of the ID range (inclusive), or null for no upper bound. + * @return int[] List of existing site IDs. + */ + protected function get_site_ids_in_range( int $start, ?int $end ): array { + global $wpdb; + + if ( null === $end ) { + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id >= %d ORDER BY blog_id ASC", $start ) ) + ); + } + + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id BETWEEN %d AND %d ORDER BY blog_id ASC", $start, $end ) ) + ); + } } diff --git a/src/Term_Command.php b/src/Term_Command.php index a3dbfac40..a1bb3f1d0 100644 --- a/src/Term_Command.php +++ b/src/Term_Command.php @@ -1,5 +1,6 @@ get_term_ids_in_range( $taxonomy, $start, $end ); + } + ); + } + $successes = 0; $errors = 0; foreach ( $args as $term_id ) { @@ -896,4 +909,41 @@ private function maybe_reset_depth() { private function get_formatter( &$assoc_args ) { return new Formatter( $assoc_args, $this->fields, 'term' ); } + + /** + * Returns existing term IDs within the given range for a specific taxonomy. + * + * @param string $taxonomy Taxonomy to filter terms by. + * @param int $start Start of the ID range (inclusive). + * @param int|null $end End of the ID range (inclusive), or null for no upper bound. + * @return int[] List of existing term IDs. + */ + protected function get_term_ids_in_range( string $taxonomy, int $start, ?int $end ): array { + global $wpdb; + + if ( null === $end ) { + return array_map( + 'intval', + $wpdb->get_col( + $wpdb->prepare( + "SELECT t.term_id FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id WHERE t.term_id >= %d AND tt.taxonomy = %s ORDER BY t.term_id ASC", + $start, + $taxonomy + ) + ) + ); + } + + return array_map( + 'intval', + $wpdb->get_col( + $wpdb->prepare( + "SELECT t.term_id FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id WHERE t.term_id BETWEEN %d AND %d AND tt.taxonomy = %s ORDER BY t.term_id ASC", + $start, + $end, + $taxonomy + ) + ) + ); + } } diff --git a/src/User_Command.php b/src/User_Command.php index e0c8654be..f7d1cfbe6 100644 --- a/src/User_Command.php +++ b/src/User_Command.php @@ -282,6 +282,31 @@ public function get( $args, $assoc_args ) { * $ wp user delete $(wp user list --role=contributor --field=ID | head -n 100) */ public function delete( $args, $assoc_args ) { + // Only expand arguments that look like numeric ID ranges, and only if no user + // exists with that exact login or email. This avoids misinterpreting a valid + // user_login like "12-24" as an ID range. + $expanded_args = []; + + foreach ( $args as $arg ) { + if ( is_string( $arg ) && preg_match( '/^\d+-\d+$/', $arg ) ) { + $user_by_login = get_user_by( 'login', $arg ); + $user_by_email = get_user_by( 'email', $arg ); + + if ( $user_by_login || $user_by_email ) { + // Treat as login/email, do not expand as an ID range. + $expanded_args[] = $arg; + } else { + $range_expanded = self::expand_id_ranges( [ $arg ], [ $this, 'get_user_ids_in_range' ] ); + foreach ( $range_expanded as $expanded_arg ) { + $expanded_args[] = $expanded_arg; + } + } + } else { + $expanded_args[] = $arg; + } + } + + $args = $expanded_args; $network = Utils\get_flag_value( $assoc_args, 'network' ) && is_multisite(); /** @@ -559,6 +584,8 @@ public function create( $args, $assoc_args ) { * @param array $assoc_args Associative arguments. */ public function update( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); + if ( isset( $assoc_args['user_login'] ) ) { WP_CLI::warning( "User logins can't be changed." ); unset( $assoc_args['user_login'] ); @@ -1327,6 +1354,7 @@ public function import_csv( $args, $assoc_args ) { * @subcommand reset-password */ public function reset_password( $args, $assoc_args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); $porcelain = Utils\get_flag_value( $assoc_args, 'porcelain' ); $skip_email = (bool) Utils\get_flag_value( $assoc_args, 'skip-email' ); $show_new_pass = Utils\get_flag_value( $assoc_args, 'show-password' ); @@ -1410,6 +1438,7 @@ public static function wp_new_user_notification( $user_id, $password ) { * Success: Spammed 1 of 1 users. */ public function spam( $args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); $this->update_msuser_status( $args, 'spam', '1' ); } @@ -1429,6 +1458,7 @@ public function spam( $args ) { * Success: Unspamed 1 of 1 users. */ public function unspam( $args ) { + $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); $this->update_msuser_status( $args, 'spam', '0' ); } @@ -1548,4 +1578,27 @@ public function check_password( $args, $assoc_args ) { WP_CLI::halt( 1 ); } } + + /** + * Returns existing user IDs within the given range. + * + * @param int $start Start of the ID range (inclusive). + * @param int|null $end End of the ID range (inclusive), or null for no upper bound. + * @return int[] List of existing user IDs. + */ + protected function get_user_ids_in_range( int $start, ?int $end ): array { + global $wpdb; + + if ( null === $end ) { + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->users} WHERE ID >= %d ORDER BY ID ASC", $start ) ) + ); + } + + return array_map( + 'intval', + $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->users} WHERE ID BETWEEN %d AND %d ORDER BY ID ASC", $start, $end ) ) + ); + } } diff --git a/src/WP_CLI/CommandWithDBObject.php b/src/WP_CLI/CommandWithDBObject.php index 767a0bb27..d268fc839 100644 --- a/src/WP_CLI/CommandWithDBObject.php +++ b/src/WP_CLI/CommandWithDBObject.php @@ -14,6 +14,8 @@ */ abstract class CommandWithDBObject extends WP_CLI_Command { + use ExpandsIdRanges; + /** * @var string $object_type WordPress' expected name for the object. */ diff --git a/src/WP_CLI/ExpandsIdRanges.php b/src/WP_CLI/ExpandsIdRanges.php new file mode 100644 index 000000000..244321cb4 --- /dev/null +++ b/src/WP_CLI/ExpandsIdRanges.php @@ -0,0 +1,56 @@ + $end ) { + // Normalize reversed ranges like "35-15" to "15-35". + $temp = $start; + $start = $end; + $end = $temp; + } + $ids = array_merge( $ids, $get_ids_in_range( $start, $end ) ); + } elseif ( preg_match( '/^(\d+)-$/', $arg, $matches ) ) { + // Open-ended range: "34-" + $ids = array_merge( $ids, $get_ids_in_range( (int) $matches[1], null ) ); + } elseif ( preg_match( '/^-(\d+)$/', $arg, $matches ) ) { + // Lower-bounded range: "-35" + $ids = array_merge( $ids, $get_ids_in_range( 1, (int) $matches[1] ) ); + } else { + $ids[] = $arg; + } + } + + return array_values( array_unique( $ids ) ); + } +}