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 {
'
'
);
+ $titles = [];
+
$wordSeparator = $this->msg( 'word-separator' )->escaped();
$commaSeparator = $this->msg( 'comma-separator' )->escaped();
$pipeSeparator = $this->msg( 'pipe-separator' )->escaped();
@@ -379,6 +386,7 @@ class SpecialNuke extends SpecialPage {
/**
* @var $title Title
*/
+ $titles[] = $title->getPrefixedDBkey();
$image = $title->inNamespace( NS_FILE ) ? $localRepo->newFile( $title ) : false;
$thumb = $image && $image->exists() ?
@@ -421,6 +429,7 @@ class SpecialNuke extends SpecialPage {
$out->addHTML(
"
\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- " .
- implode( "
\n- ", $res ) .
- "
\n
\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- " .
+ implode( "
\n- ", $res ) .
+ "
\n
\n"
+ );
+ }
+ if ( count( $skippedRes ) ) {
+ $this->getOutput()->addWikiMsg( 'nuke-skipped-summary', count( $skippedRes ) );
+ $this->getOutput()->addHTML(
+ "\n- " .
+ implode( "
\n- ", $skippedRes ) .
+ "
\n
\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 {