From 0457cd7672d994a25fa84ff97c39091fe63f852d Mon Sep 17 00:00:00 2001 From: ArkaPrabhaChowdhury Date: Mon, 6 Jul 2026 17:59:30 +0530 Subject: [PATCH] Handle empty comments in AJAX batches --- src/js/_enqueues/admin/edit-comments.js | 78 +++++++- src/wp-admin/admin-ajax.php | 1 + src/wp-admin/includes/ajax-actions.php | 77 +++++++ .../tests/ajax/wpAjaxDeleteComment.php | 188 +++++++++++++++++- 4 files changed, 342 insertions(+), 2 deletions(-) diff --git a/src/js/_enqueues/admin/edit-comments.js b/src/js/_enqueues/admin/edit-comments.js index 78ee78de759ff..326ba28aa576e 100644 --- a/src/js/_enqueues/admin/edit-comments.js +++ b/src/js/_enqueues/admin/edit-comments.js @@ -10,7 +10,7 @@ /* global commentReply, theExtraList, theList, setCommentsList */ (function($) { -var getCount, updateCount, updateCountText, updatePending, updateApproved, +var getCount, updateCount, updateCountText, updatePending, updateApproved, emptyComments, updateHtmlTitle, updateDashboardText, updateInModerationText, adminTitle = document.title, isDashboard = $('#dashboard_right_now').length, titleDiv, titleRegEx, @@ -178,6 +178,80 @@ var getCount, updateCount, updateCountText, updatePending, updateApproved, } }; + /** + * Empty spam or trash comments in AJAX batches. + * + * @since 6.9.0 + * + * @param {Event} event Submit button click event. + * + * @return {void} + */ + emptyComments = function( event ) { + var button = $( event.currentTarget ), + form = button.closest( 'form' ), + status = form.find( 'input[name="comment_status"]' ).val(), + nonce = form.find( 'input[name="_wpnonce"]' ).val(), + timestamp = form.find( 'input[name="pagegen_timestamp"]' ).val(), + originalText = button.val(), + totalDeleted = 0, + notice; + + if ( -1 === $.inArray( status, [ 'spam', 'trash' ] ) ) { + return; + } + + event.preventDefault(); + + button.prop( 'disabled', true ); + notice = $( '', { + 'class': 'empty-comments-progress screen-reader-text', + 'aria-live': 'polite' + } ).insertAfter( button ); + + function runBatch() { + $.post( + ajaxurl, + { + action: 'empty-comments', + _ajax_nonce: nonce, + comment_status: status, + pagegen_timestamp: timestamp + } + ).done( function( response ) { + var deleted, message; + + if ( ! response.success ) { + button.prop( 'disabled', false ).val( originalText ); + notice.remove(); + window.alert( response.data && response.data.message ? response.data.message : __( 'An error occurred while emptying comments.' ) ); + return; + } + + deleted = parseInt( response.data.deleted, 10 ) || 0; + totalDeleted += deleted; + message = __( 'Emptying comments...' ) + ' ' + totalDeleted; + + button.val( message ); + notice.text( message ); + wp.a11y.speak( message ); + + if ( response.data.done ) { + window.location.reload(); + return; + } + + runBatch(); + } ).fail( function() { + button.prop( 'disabled', false ).val( originalText ); + notice.remove(); + window.alert( __( 'An error occurred while emptying comments.' ) ); + } ); + } + + runBatch(); + }; + /** * Updates the title of the document with the number comments to be approved. * @@ -1258,6 +1332,8 @@ $( function(){ e.preventDefault(); }); + $( 'input[name="delete_all"]' ).on( 'click', emptyComments ); + if ( typeof $.table_hotkeys != 'undefined' ) { /** * Creates a function that navigates to a previous or next page. diff --git a/src/wp-admin/admin-ajax.php b/src/wp-admin/admin-ajax.php index 3ad60f95766e3..2dda559142ecc 100644 --- a/src/wp-admin/admin-ajax.php +++ b/src/wp-admin/admin-ajax.php @@ -60,6 +60,7 @@ 'oembed-cache', 'image-editor', 'delete-comment', + 'empty-comments', 'delete-tag', 'delete-link', 'delete-meta', diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..b271a1aea5e64 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -720,6 +720,7 @@ function _wp_ajax_add_hierarchical_term() { * @since 3.1.0 */ function wp_ajax_delete_comment() { + $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0; $comment = get_comment( $id ); @@ -784,6 +785,82 @@ function wp_ajax_delete_comment() { wp_die( 0 ); } +/** + * Handles emptying spam or trash comments via AJAX. + * + * @since 6.9.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + */ +function wp_ajax_empty_comments() { + + global $wpdb; + + check_ajax_referer( 'bulk-comments' ); + + if ( ! current_user_can( 'moderate_comments' ) ) { + wp_send_json_error( array( 'message' => __( 'Sorry, you are not allowed to moderate comments on this site.' ) ), 403 ); + } + + $comment_status = isset( $_POST['comment_status'] ) ? sanitize_key( wp_unslash( $_POST['comment_status'] ) ) : ''; + + if ( ! in_array( $comment_status, array( 'spam', 'trash' ), true ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid comment status.' ) ), 400 ); + } + + $delete_time = isset( $_POST['pagegen_timestamp'] ) ? sanitize_text_field( wp_unslash( $_POST['pagegen_timestamp'] ) ) : ''; + + if ( '' === $delete_time ) { + wp_send_json_error( array( 'message' => __( 'Missing delete timestamp.' ) ), 400 ); + } + + $batch_size = (int) apply_filters( 'wp_empty_comments_batch_size', 20, $comment_status ); + $batch_size = max( 1, min( 100, $batch_size ) ); + $deleted = 0; + $comment_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT comment_ID FROM $wpdb->comments + WHERE comment_approved = %s AND %s > comment_date_gmt + ORDER BY comment_ID ASC + LIMIT %d", + $comment_status, + $delete_time, + $batch_size + ) + ); + + wp_defer_comment_counting( true ); + + foreach ( $comment_ids as $comment_id ) { + if ( ! current_user_can( 'edit_comment', $comment_id ) ) { + continue; + } + + if ( wp_delete_comment( $comment_id ) ) { + ++$deleted; + } + } + + wp_defer_comment_counting( false ); + + $remaining = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->comments + WHERE comment_approved = %s AND %s > comment_date_gmt", + $comment_status, + $delete_time + ) + ); + + wp_send_json_success( + array( + 'deleted' => $deleted, + 'remaining' => $remaining, + 'done' => 0 === $remaining || ( 0 === $deleted && ! empty( $comment_ids ) ), + ) + ); +} + /** * Handles deleting a tag via AJAX. * diff --git a/tests/phpunit/tests/ajax/wpAjaxDeleteComment.php b/tests/phpunit/tests/ajax/wpAjaxDeleteComment.php index 26b14dd221b79..3c3983bd0c7d6 100644 --- a/tests/phpunit/tests/ajax/wpAjaxDeleteComment.php +++ b/tests/phpunit/tests/ajax/wpAjaxDeleteComment.php @@ -32,6 +32,12 @@ class Tests_Ajax_wpAjaxDeleteComment extends WP_Ajax_UnitTestCase { */ protected static $post_id; + public function set_up() { + parent::set_up(); + + add_action( 'wp_ajax_empty-comments', 'wp_ajax_empty_comments', 1 ); + } + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$post_id = $factory->post->create(); @@ -43,14 +49,41 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { * Clears the POST actions in between requests. */ protected function _clear_post_action() { + unset( $_POST['trash'] ); unset( $_POST['untrash'] ); unset( $_POST['spam'] ); unset( $_POST['unspam'] ); unset( $_POST['delete'] ); + unset( $_POST['comment_status'] ); + unset( $_POST['pagegen_timestamp'] ); $this->_last_response = ''; } + /** + * Makes an AJAX request to empty spam or trash comments. + * + * @param string $comment_status Comment status. + * @param string $delete_time Delete timestamp. + * @param string $nonce Nonce. + * @return array Decoded response. + */ + protected function _empty_comments( $comment_status, $delete_time, $nonce = '' ) { + + $this->_clear_post_action(); + + $_POST['comment_status'] = $comment_status; + $_POST['pagegen_timestamp'] = $delete_time; + $_POST['_ajax_nonce'] = $nonce ? $nonce : wp_create_nonce( 'bulk-comments' ); + + try { + $this->_handleAjax( 'empty-comments' ); + } catch ( WPAjaxDieContinueException $e ) { + unset( $e ); + } + + return json_decode( $this->_last_response, true ); + } /* * Test prototype */ @@ -338,10 +371,10 @@ public function test_ajax_trash_comment_bad_nonce() { * Tests trashing an already trashed comment, etc. */ public function test_ajax_trash_double_action() { + // Test trash/untrash. $this->_test_double_action( self::$comments[0], 'trash' ); $this->_test_double_action( self::$comments[0], 'untrash' ); - // Test spam/unspam. $this->_test_double_action( self::$comments[1], 'spam' ); $this->_test_double_action( self::$comments[1], 'unspam' ); @@ -349,4 +382,157 @@ public function test_ajax_trash_double_action() { // Test delete. $this->_test_double_action( self::$comments[2], 'delete' ); } + + /** + * Tests emptying spam comments in batches. + * + * @covers ::wp_ajax_empty_comments + */ + public function test_ajax_empty_comments_deletes_spam_in_batches() { + + $this->_setRole( 'administrator' ); + + $comment_ids = self::factory()->comment->create_many( + 3, + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 'spam', + ) + ); + + add_filter( 'wp_empty_comments_batch_size', array( $this, 'filter_empty_comments_batch_size' ) ); + + $response = $this->_empty_comments( 'spam', gmdate( 'Y-m-d H:i:s', time() + 10 ) ); + + remove_filter( 'wp_empty_comments_batch_size', array( $this, 'filter_empty_comments_batch_size' ) ); + + $this->assertTrue( $response['success'] ); + $this->assertSame( 2, $response['data']['deleted'] ); + $this->assertSame( 1, $response['data']['remaining'] ); + $this->assertFalse( $response['data']['done'] ); + $this->assertNull( get_comment( $comment_ids[0] ) ); + $this->assertNull( get_comment( $comment_ids[1] ) ); + $this->assertInstanceOf( WP_Comment::class, get_comment( $comment_ids[2] ) ); + } + + /** + * Tests emptying trash comments returns done when all matching comments are deleted. + * + * @covers ::wp_ajax_empty_comments + */ + public function test_ajax_empty_comments_returns_done_when_trash_is_empty() { + + $this->_setRole( 'administrator' ); + + self::factory()->comment->create_many( + 2, + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 'trash', + ) + ); + + $response = $this->_empty_comments( 'trash', gmdate( 'Y-m-d H:i:s', time() + 10 ) ); + + $this->assertTrue( $response['success'] ); + $this->assertSame( 2, $response['data']['deleted'] ); + $this->assertSame( 0, $response['data']['remaining'] ); + $this->assertTrue( $response['data']['done'] ); + } + + /** + * Tests emptying comments does not delete comments newer than the page generation timestamp. + * + * @covers ::wp_ajax_empty_comments + */ + public function test_ajax_empty_comments_preserves_comments_after_pagegen_timestamp() { + + $this->_setRole( 'administrator' ); + + $delete_time = gmdate( 'Y-m-d H:i:s', time() ); + $old_comment = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 'spam', + 'comment_date' => gmdate( 'Y-m-d H:i:s', time() - 10 ), + 'comment_date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 10 ), + ) + ); + $new_comment = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => 'spam', + 'comment_date' => gmdate( 'Y-m-d H:i:s', time() + 10 ), + 'comment_date_gmt' => gmdate( 'Y-m-d H:i:s', time() + 10 ), + ) + ); + + $response = $this->_empty_comments( 'spam', $delete_time ); + + $this->assertTrue( $response['success'] ); + $this->assertSame( 1, $response['data']['deleted'] ); + $this->assertSame( 0, $response['data']['remaining'] ); + $this->assertNull( get_comment( $old_comment ) ); + $this->assertInstanceOf( WP_Comment::class, get_comment( $new_comment ) ); + } + + /** + * Tests emptying comments rejects unsupported statuses. + * + * @covers ::wp_ajax_empty_comments + */ + public function test_ajax_empty_comments_rejects_invalid_status() { + + $this->_setRole( 'administrator' ); + + $response = $this->_empty_comments( 'approved', gmdate( 'Y-m-d H:i:s', time() + 10 ) ); + + $this->assertFalse( $response['success'] ); + $this->assertSame( 'Invalid comment status.', $response['data']['message'] ); + } + + /** + * Tests emptying comments requires moderation permissions. + * + * @covers ::wp_ajax_empty_comments + */ + public function test_ajax_empty_comments_requires_moderation_permission() { + + $this->_setRole( 'subscriber' ); + + $response = $this->_empty_comments( 'spam', gmdate( 'Y-m-d H:i:s', time() + 10 ) ); + + $this->assertFalse( $response['success'] ); + $this->assertSame( 'Sorry, you are not allowed to moderate comments on this site.', $response['data']['message'] ); + } + + /** + * Tests emptying comments requires a valid nonce. + * + * @covers ::wp_ajax_empty_comments + */ + public function test_ajax_empty_comments_requires_valid_nonce() { + + $this->_setRole( 'administrator' ); + + $this->_clear_post_action(); + + $_POST['comment_status'] = 'spam'; + $_POST['pagegen_timestamp'] = gmdate( 'Y-m-d H:i:s', time() + 10 ); + $_POST['_ajax_nonce'] = wp_create_nonce( uniqid() ); + + $this->expectException( 'WPAjaxDieStopException' ); + $this->expectExceptionMessage( '-1' ); + $this->_handleAjax( 'empty-comments' ); + } + + /** + * Filters the empty comments batch size for tests. + * + * @return int Batch size. + */ + public function filter_empty_comments_batch_size() { + + return 2; + } }