diff --git a/i18n/en.json b/i18n/en.json index 7129be7d..a693432b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -23,9 +23,13 @@ "nuke-userorip": "Username, IP address or blank:", "nuke-maxpages": "Number of pages to retrieve (maximum 500):", "nuke-editby": "Created by [[Special:Contributions/$1|{{GENDER:$1|$1}}]].", + "nuke-delete-summary": "Queued '''{{PLURAL:$1|one page|$1 pages}}''' for deletion.", + "nuke-delete-summary-user": "Queued '''{{PLURAL:$1|one page|$1 pages}}''' {{GENDER:$2|created}} by [[{{ns:user}}:$2|$2]] ([[{{ns:user_talk}}:$2|{{int:talkpagelinktext}}]] {{int:pipe-separator}} [[{{ns:special}}:Contributions/$2|{{int:contribslink}}]] {{int:pipe-separator}} [[{{ns:special}}:Block/$2|{{int:blocklink}}]]) for deletion.", "nuke-deleted": "Page '''$1''' has been deleted.", "nuke-deletion-queued": "Page '''$1''' has been queued for deletion.", "nuke-not-deleted": "Page [[:$1]] '''could not''' be deleted.", + "nuke-skipped-summary": "'''{{PLURAL:$1|One page''' was|$1 pages''' were}} not selected for deletion.", + "nuke-skipped": "[[:$1]] ([[:$2|talk]] {{int:pipe-separator}} [[{{ns:special}}:PageHistory/$1|history]])", "nuke-delete-more": "[[Special:Nuke|Delete more pages]]", "nuke-pattern": "SQL LIKE pattern (e.g. %) for the page name:", "nuke-nopages-global": "There are no page titles matching your search.", diff --git a/i18n/qqq.json b/i18n/qqq.json index 6eb2d054..d9d66059 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -37,9 +37,13 @@ "nuke-userorip": "Used as label for \"target\" input box.", "nuke-maxpages": "Used as label for \"nuke limit\" input box.", "nuke-editby": "This message is followed by {{msg-mw|Comma-separator}} and {{msg-mw|Nuke-viewchanges}}.\n\nParameters:\n* $1 - a username", + "nuke-delete-summary": "Used at the top of the Nuke (mass deletion) result page. Shown when a username was not provided.\n\nParameters:\n* $1 - the number of pages queued for deletion\n\nSee also:\n* {{msg-mw|nuke-delete-summary-user}}", + "nuke-delete-summary-user": "Used at the top of the Nuke (mass deletion) result page. Shown when a username was provided.\n\nParameters:\n* $1 - the number of pages queued for deletion\n* $2 - the username of the user targeted for deletion\n\nSee also:\n* {{msg-mw|nuke-delete-summary}}", "nuke-deleted": "Used as success result of deletion. Parameters:\n* $1 - page title\nSee also:\n* {{msg-mw|Nuke-not-deleted}}", "nuke-deletion-queued": "Used when the page is queued for deletion. Parameters:\n* $1 - page title\nSee also:\n* {{msg-mw|Nuke-deleted}}", "nuke-not-deleted": "Used as failure result of deletion. Parameters:\n* $1 - page title\nSee also:\n* {{msg-mw|Nuke-deleted}}", + "nuke-skipped-summary": "Used at the middle (or bottom) of the Nuke (mass deletion) result page. Shown only when some pages were not selected when mass deleting.\n\nParameters:\n* $1 - the number of pages queued for deletion\n\nSee also: \n*{{msg-mw|nuke-skipped}}", + "nuke-skipped": "Used to show links to pages that were skipped, for further action by administrators.\n\nParameters:\n* $1 - the title of the skipped page\n* $2 - the title of the talk page for the skipped page\n\nSee also: \n*{{msg-mw|nuke-skipped}}", "nuke-delete-more": "Used at the bottom of the Nuke (mass deletion) result page.", "nuke-pattern": "{{doc-important|Do not translate LIKE, it is an SQL operator.}}\nUsed as label for \"nuke pattern\" input box.", "nuke-nopages-global": "Used if there are no pages to delete and the username is empty.\n\nSee also:\n* {{msg-mw|Nuke-nopages}}", diff --git a/includes/SpecialNuke.php b/includes/SpecialNuke.php index 7cee8c59..977310a4 100644 --- a/includes/SpecialNuke.php +++ b/includes/SpecialNuke.php @@ -136,12 +136,16 @@ class SpecialNuke extends SpecialPage { && $currentUser->matchEditToken( $req->getVal( 'wpEditToken' ) ) ) { if ( $req->getRawVal( 'action' ) === 'delete' ) { - $pages = $req->getArray( 'pages' ); + $pages = $req->getArray( 'pages' ) ?? []; + $originalPageList + = explode( '|', $req->getText( 'originalPageList' ) ); - if ( $pages ) { - $this->doDelete( $pages, $reason ); - return; + if ( count( $originalPageList ) === 1 && !$originalPageList[0] ) { + // No page list was provided. + $originalPageList = []; } + + $this->doDelete( $pages, $originalPageList, $reason, $target ); } elseif ( $req->getRawVal( 'action' ) === 'submit' ) { // if the target is an ip addresss and temp account lookup is available, // list pages created by the ip user or by temp accounts associated with the ip address @@ -362,6 +366,7 @@ class SpecialNuke extends SpecialPage { 'name' => 'nukelist' ] ) . Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) . + Html::hidden( 'target', $username ) . $dropdown . $reasonField . // Select: All, None, Invert @@ -369,6 +374,8 @@ class SpecialNuke extends SpecialPage { '\n" . + Html::hidden( 'originalPageList', implode( '|', $titles ) ) . Html::submitButton( $this->msg( 'nuke-submit-delete' )->text() ) . '' ); @@ -590,18 +599,47 @@ class SpecialNuke extends SpecialPage { * Does the actual deletion of the pages. * * @param array $pages The pages to delete + * @param array $originalPageList The original list of pages shown to the user * @param string $reason + * @param string $target * @throws PermissionsError */ - protected function doDelete( array $pages, $reason ): void { + protected function doDelete( + array $pages, array $originalPageList, string $reason, string $target + ): void { $res = []; $jobs = []; + $skippedRes = []; $user = $this->getUser(); + $queuedCount = 0; + + // Get a list of all pages involved and what to do with them. + // Pages in $pages will always be deleted, even if they are not present in + // $originalPageList. + $willDeleteList = []; + foreach ( $pages as $page ) { + $willDeleteList[$page] = true; + } + foreach ( $originalPageList as $originalPage ) { + if ( !isset( $willDeleteList[$originalPage] ) ) { + $willDeleteList[$originalPage] = false; + } + } $localRepo = $this->repoGroup->getLocalRepo(); - foreach ( $pages as $page ) { + foreach ( $willDeleteList as $page => $willDelete ) { $title = Title::newFromText( $page ); + if ( !$willDelete ) { + // If this page was skipped, add it to the list of skipped pages and move on. + $skippedRes[] = $this->msg( + 'nuke-skipped', + wfEscapeWikiText( $title->getPrefixedText() ), + wfEscapeWikiText( $title->getTalkPageIfDefined() ) + )->parse(); + continue; + } + $deletionResult = false; if ( !$this->getNukeHookRunner()->onNukeDeletePage( $title, $reason, $deletionResult ) ) { $res[] = $this->msg( @@ -649,11 +687,15 @@ class SpecialNuke extends SpecialPage { 'nuke-deletion-queued', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(); + $queuedCount++; } else { $res[] = $this->msg( $status->isOK() ? 'nuke-deleted' : 'nuke-not-deleted', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(); + if ( $status->isOK() ) { + $queuedCount++; + } } } @@ -661,11 +703,27 @@ class SpecialNuke extends SpecialPage { $this->jobQueueGroup->push( $jobs ); } - $this->getOutput()->addHTML( - "\n" - ); + // Show the main summary, regardless of whether we deleted pages or not. + if ( $target ) { + $this->getOutput()->addWikiMsg( 'nuke-delete-summary-user', $queuedCount, $target ); + } else { + $this->getOutput()->addWikiMsg( 'nuke-delete-summary', $queuedCount ); + } + if ( $queuedCount ) { + $this->getOutput()->addHTML( + "\n" + ); + } + if ( count( $skippedRes ) ) { + $this->getOutput()->addWikiMsg( 'nuke-skipped-summary', count( $skippedRes ) ); + $this->getOutput()->addHTML( + "\n" + ); + } $this->getOutput()->addWikiMsg( 'nuke-delete-more' ); } diff --git a/tests/phpunit/integration/SpecialNukeTest.php b/tests/phpunit/integration/SpecialNukeTest.php index 638cf999..5bf5c342 100644 --- a/tests/phpunit/integration/SpecialNukeTest.php +++ b/tests/phpunit/integration/SpecialNukeTest.php @@ -9,6 +9,7 @@ use MediaWiki\Permissions\UltimateAuthority; use MediaWiki\Request\FauxRequest; use MediaWiki\Tests\User\TempUser\TempUserTestTrait; use MediaWiki\Title\Title; +use MediaWiki\User\User; use PermissionsError; use SpecialPageTestBase; use UploadFromFile; @@ -653,7 +654,7 @@ class SpecialNukeTest extends SpecialPageTestBase { } public function testListFiles() { - $this->uploadTestFile(); + $testFileName = $this->uploadTestFile()['title']->getPrefixedText(); $admin = $this->getTestSysop()->getUser(); $request = new FauxRequest( [ @@ -664,10 +665,8 @@ class SpecialNukeTest extends SpecialPageTestBase { [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); - $expectedTitle = Title::makeTitle( NS_FILE, 'Example.png' ); - // The title should be in the list - $this->assertStringContainsString( $expectedTitle->getPrefixedText(), $html ); + $this->assertStringContainsString( $testFileName, $html ); // There should also be an image preview $this->assertStringContainsString( "executeSpecialPage( '', $request, 'qqx', $performer ); + $deleteCount = count( $pages ); + $this->assertStringContainsString( "(nuke-delete-summary: $deleteCount)", $html ); $this->assertStringContainsString( '(nuke-deletion-queued: Page123)', $html ); $this->assertStringContainsString( '(nuke-deletion-queued: Paging456)', $html ); @@ -753,6 +754,106 @@ class SpecialNukeTest extends SpecialPageTestBase { $this->assertStringContainsString( $fauxReason, $deleteLogHtml ); } + public function testDeleteTarget() { + $pages = []; + + $testUser = $this->getTestUser( "user" )->getUser(); + $testUserName = $testUser->getName(); + + $pages[] = $this->uploadTestFile( $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN, $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Paging456', 'Test', NS_MAIN, $testUser )[ 'title' ]; + + $admin = $this->getTestSysop()->getUser(); + + $fauxReason = "Reason " . wfRandomString(); + $request = new FauxRequest( [ + 'action' => 'delete', + 'wpDeleteReasonList' => 'Vandalism', + 'wpReason' => $fauxReason, + 'target' => $testUserName, + 'pages' => $pages, + 'wpFormIdentifier' => 'nukelist', + 'wpEditToken' => $admin->getEditToken(), + ], true ); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + + $deleteCount = count( $pages ); + $this->assertStringContainsString( + "(nuke-delete-summary-user: $deleteCount, $testUserName)", + $html + ); + } + + public function testDeleteTargetAnon() { + $pages = []; + + $testUser = $this->getTestUser( "user" )->getUser(); + $testUserName = $testUser->getName(); + + $pages[] = $this->uploadTestFile( $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN, $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Paging456', 'Test', NS_MAIN, $testUser )[ 'title' ]; + + $admin = $this->getTestSysop()->getUser(); + + $fauxReason = "Reason " . wfRandomString(); + $request = new FauxRequest( [ + 'action' => 'delete', + 'wpDeleteReasonList' => 'Vandalism', + 'wpReason' => $fauxReason, + 'pages' => $pages, + 'wpFormIdentifier' => 'nukelist', + 'wpEditToken' => $admin->getEditToken(), + ], true ); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + + $deleteCount = count( $pages ); + $this->assertStringContainsString( + "(nuke-delete-summary: $deleteCount)", + $html + ); + } + + public function testDeleteSkipped() { + $pages = []; + $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN )[ 'title' ]; + $pages[] = $this->insertPage( 'Paging 456', 'Test', NS_MAIN )[ 'title' ]; + $skippedPage = $this->insertPage( 'Page 789', 'Test', NS_MAIN )['title']; + + $admin = $this->getTestSysop()->getUser(); + + $request = new FauxRequest( [ + 'action' => 'delete', + 'wpDeleteReasonList' => 'Vandalism', + 'originalPageList' => implode( '|', $pages ), + 'pages' => [ $skippedPage->getPrefixedDBkey() ], + 'wpFormIdentifier' => 'nukelist', + 'wpEditToken' => $admin->getEditToken(), + ], true ); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + + $skippedCount = count( $pages ); + $this->assertStringContainsString( '(nuke-delete-summary: 1)', $html ); + $this->assertStringContainsString( "(nuke-skipped-summary: $skippedCount)", $html ); + + // Ensure all delete jobs are run + $this->runJobs(); + + // Make sure that those pages are not in the deletion log. + $deleteLogHtml = $this->getDeleteLogHtml(); + foreach ( $pages as $title ) { + $this->assertStringNotContainsString( $title->getPrefixedText(), $deleteLogHtml ); + } + $this->assertStringContainsString( $skippedPage->getPrefixedText(), $deleteLogHtml ); + } + public function testDeleteDropdownReason() { $pages = []; $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN )[ 'title' ]; @@ -807,14 +908,14 @@ class SpecialNukeTest extends SpecialPageTestBase { } public function testDeleteFiles() { - $this->uploadTestFile(); + $testFileName = $this->uploadTestFile()['title']->getPrefixedText(); $admin = $this->getTestSysop()->getUser(); $request = new FauxRequest( [ 'action' => 'delete', 'wpDeleteReasonList' => 'Reason', 'wpReason' => 'Reason', - 'pages' => [ 'File:Example.png' ], + 'pages' => [ $testFileName ], 'wpFormIdentifier' => 'nukelist', 'wpEditToken' => $admin->getEditToken(), ], true ); @@ -822,11 +923,9 @@ class SpecialNukeTest extends SpecialPageTestBase { [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); - $expectedTitle = Title::makeTitle( NS_FILE, 'Example.png' ); - // Files are deleted instantly $this->assertStringContainsString( "nuke-deleted", $html ); - $this->assertStringContainsString( $expectedTitle->getPrefixedText(), $html ); + $this->assertStringContainsString( $testFileName, $html ); } public function testDeleteHook() { @@ -879,26 +978,40 @@ class SpecialNukeTest extends SpecialPageTestBase { $this->assertCount( 0, $searchResults2 ); } - private function uploadTestFile() { + /** + * Upload a test file. + * + * @param ?User|null $user + * @return array Title object and page id + */ + private function uploadTestFile( ?User $user = null ): array { $exampleFilePath = realpath( __DIR__ . "/../../assets/Example.png" ); $tempFilePath = $this->getNewTempFile(); copy( $exampleFilePath, $tempFilePath ); + $title = Title::makeTitle( NS_FILE, "Example " . rand() . ".png" ); $request = new FauxRequest( [], true ); $request->setUpload( 'wpUploadFile', [ - 'name' => 'Example.png', + 'name' => $title->getText(), 'type' => 'image/png', 'tmp_name' => $tempFilePath, 'size' => filesize( $tempFilePath ), 'error' => UPLOAD_ERR_OK ] ); $upload = UploadFromFile::createFromRequest( $request ); - $upload->performUpload( + $uploadStatus = $upload->performUpload( "test", false, false, - $this->getTestUser( "user" )->getUser() + $user ?? $this->getTestUser( "user" )->getUser() ); + $this->assertTrue( $uploadStatus->isOK() ); + $this->getServiceContainer()->getJobRunner()->run( [] ); + + return [ + 'title' => $title, + 'id' => $title->getId() + ]; } private function getDeleteLogHtml(): string {